From 744b36b0342d06ba4699a27b2582592275873a39 Mon Sep 17 00:00:00 2001 From: Damian Stasik <920747+damianstasik@users.noreply.github.com> Date: Tue, 10 Sep 2024 10:02:23 +0200 Subject: [PATCH] Process admonitions with remark Signed-off-by: Damian Stasik <920747+damianstasik@users.noreply.github.com> --- frontend/package-lock.json | 80 +++++++++++++++++++ frontend/package.json | 3 + .../src/components/Markdown/Admonition.tsx | 66 +++++++++++++++ .../src/components/Markdown/Blockquote.tsx | 11 +++ frontend/src/components/Markdown/index.tsx | 67 +++++++++++++++- 5 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/Markdown/Admonition.tsx create mode 100644 frontend/src/components/Markdown/Blockquote.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8f381a3e..f5fb01b3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.20", "clsx": "^2.1.1", + "es-toolkit": "^1.17.0", "eslint": "^9.9.1", "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.11", @@ -38,8 +39,10 @@ "rehype-sanitize": "^6.0.0", "rehype-slug": "^6.0.0", "rehype-stringify": "^10.0.0", + "remark-directive": "^3.0.0", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.0", + "remark-github-admonitions-to-directives": "^2.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.0", "suspend-react": "^0.1.3", @@ -2842,6 +2845,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-toolkit": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.17.0.tgz", + "integrity": "sha512-aJvpNxK7d+I+Rt9tmdwzelxTe4EwtxX1Kv0xv6ZTRWJBpMCxe0vxTLLW4STz6pHYjtyTTrEkonNXLaBsYHg2Yw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -4241,6 +4254,26 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/mdast-util-directive": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.0.0.tgz", + "integrity": "sha512-JUpYOqKI4mM3sZcNxmF/ox04XYFFkNwr0CFlrQIkCwbvH0xzMCqkMqAde9wRd80VAhaUrwFwKm2nxretdT1h7Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.1.tgz", @@ -4637,6 +4670,25 @@ "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-extension-directive": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-3.0.1.tgz", + "integrity": "sha512-VGV2uxUzhEZmaP7NSFo2vtq7M2nUD+WfmYQD+d8i/1nHbzE+rMy9uzTvUybBbNiVbrhOZibg3gbyoARGqgDWyg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-extension-frontmatter": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/micromark-extension-frontmatter/-/micromark-extension-frontmatter-2.0.0.tgz", @@ -6281,6 +6333,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-directive": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-3.0.0.tgz", + "integrity": "sha512-l1UyWJ6Eg1VPU7Hm/9tt0zKtReJQNOA4+iDMAxTyZNWnJnFlbS/7zhiel/rogTLQ2vMYwDzSJa4BiVNqGlqIMA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-directive": "^3.0.0", + "micromark-extension-directive": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-frontmatter": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-5.0.0.tgz", @@ -6315,6 +6383,18 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-github-admonitions-to-directives": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/remark-github-admonitions-to-directives/-/remark-github-admonitions-to-directives-2.0.0.tgz", + "integrity": "sha512-/fXZWZrU+mr5VeRShPPnzUbWPmOktBAN1vqSwzktVdchhhsL1CqfdBwiQH7mkh8yaxOo/RtXysxlVLXwD2a/Dw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-directive": "^3.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3a8dd6f0..cc1c876d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.20", "clsx": "^2.1.1", + "es-toolkit": "^1.17.0", "eslint": "^9.9.1", "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.11", @@ -44,8 +45,10 @@ "rehype-sanitize": "^6.0.0", "rehype-slug": "^6.0.0", "rehype-stringify": "^10.0.0", + "remark-directive": "^3.0.0", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.0", + "remark-github-admonitions-to-directives": "^2.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.0", "suspend-react": "^0.1.3", diff --git a/frontend/src/components/Markdown/Admonition.tsx b/frontend/src/components/Markdown/Admonition.tsx new file mode 100644 index 00000000..5b2cbec5 --- /dev/null +++ b/frontend/src/components/Markdown/Admonition.tsx @@ -0,0 +1,66 @@ +import clsx from "clsx"; +import { ReactNode } from "react"; + +export enum AdmonitionType { + NOTE = "note", + CAUTION = "caution", + WARNING = "warning", + TIP = "tip", + INFO = "info", + IMPORTANT = "important", +} + +function getAdmonitionClassName(type: AdmonitionType) { + switch (type) { + case AdmonitionType.NOTE: + return "bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-100"; + case AdmonitionType.CAUTION: + return "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-100"; + case AdmonitionType.WARNING: + return "bg-yellow-100 text-yellow-800 dark:bg-yellow-950 dark:text-yellow-100"; + case AdmonitionType.TIP: + return "bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-100"; + case AdmonitionType.IMPORTANT: + case AdmonitionType.INFO: + return "bg-purple-100 text-purple-800 dark:bg-purple-950 dark:text-purple-100"; + } +} + +function getAdmonitionTitle(type: AdmonitionType) { + switch (type) { + case AdmonitionType.NOTE: + return "Note"; + case AdmonitionType.CAUTION: + return "Caution"; + case AdmonitionType.WARNING: + return "Warning"; + case AdmonitionType.TIP: + return "Tip"; + case AdmonitionType.IMPORTANT: + case AdmonitionType.INFO: + return "Important"; + } +} + +interface AdmonitionProps { + type?: AdmonitionType; + children: ReactNode; +} + +export function Admonition({ type, children }: AdmonitionProps) { + const className = type ? getAdmonitionClassName(type) : null; + + return ( +
+ {children} ++ ); +} diff --git a/frontend/src/components/Markdown/index.tsx b/frontend/src/components/Markdown/index.tsx index 35044ce6..62bed01c 100644 --- a/frontend/src/components/Markdown/index.tsx +++ b/frontend/src/components/Markdown/index.tsx @@ -7,8 +7,10 @@ import remarkRehype from "remark-rehype"; import rehypeSlug from "rehype-slug"; import remarkGfm from "remark-gfm"; import rehypeReact, { Options } from "rehype-react"; -import rehypeSanitize from "rehype-sanitize"; +import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import rehypeRaw from "rehype-raw"; +import remarkDirective from "remark-directive"; +import remarkGithubAdmonitionsToDirectives from "remark-github-admonitions-to-directives"; import { unified } from "unified"; import { MarkdownH1 } from "./H1"; import { MarkdownP } from "./P"; @@ -25,6 +27,12 @@ import { MarkdownTh } from "./Th"; import { MarkdownImg } from "./Img"; import { MarkdownOl } from "./Ol"; import { MarkdownHr } from "./Hr"; +import { MarkdownBlockquote } from "./Blockquote"; +import { visit, CONTINUE } from "unist-util-visit"; +import { merge } from "es-toolkit"; +import { Admonition, AdmonitionType } from "./Admonition"; +import { Plugin } from "unified"; +import { Directives } from "mdast-util-directive"; const production: Options = { development: false, @@ -47,9 +55,46 @@ const production: Options = { img: MarkdownImg, ol: MarkdownOl, hr: MarkdownHr, + blockquote: MarkdownBlockquote, + admonition: Admonition, }, }; +const makeDirectives: Plugin = () => { + const admonitionTypes: string[] = Object.values(AdmonitionType); + + return (tree) => { + visit( + tree, + ["textDirective", "leafDirective", "containerDirective"], + (node, index, parent) => { + const directiveNode = node as Directives; + + if (!admonitionTypes.includes(directiveNode.name)) return CONTINUE; + + // parent.children.splice(index, 1, { + // type: "element", + // data: { + // hName: "admonition", + // hProperties: { + // type: directiveNode.name, + // }, + // }, + // children: node.children, + // }); + + directiveNode.data ??= {}; + + directiveNode.data.hName = "admonition"; + + directiveNode.data.hProperties = { + type: directiveNode.name, + }; + }, + ); + }; +}; + interface MarkdownProps { text: string; } @@ -61,10 +106,24 @@ export function Markdown({ text }: MarkdownProps) { .use(remarkParse) .use(remarkFrontmatter) .use(remarkGfm) - .use(remarkRehype, { allowDangerousHtml: true }) // This is okay to use dangerous html because we are sanitizing later on in the pipeline - .use(rehypeRaw) - .use(rehypeSanitize) + .use(remarkGithubAdmonitionsToDirectives) + .use(remarkDirective) + .use(makeDirectives) + .use(remarkRehype, { + // This is okay to use dangerous html because we are sanitizing later on in the pipeline + allowDangerousHtml: true, + }) .use(rehypeSlug) + .use(rehypeRaw, { + passThrough: ["admonition"], + }) + .use( + rehypeSanitize, + merge(defaultSchema, { + tagNames: ["admonition"], + attributes: { admonition: ["type"] }, + }), + ) .use(rehypeReact, production) .processSync(text), [text],