|
7 | 7 | */ |
8 | 8 |
|
9 | 9 | import * as React from "react" |
10 | | -import ReactMarkdown from "react-markdown" |
| 10 | +import ReactMarkdown, { type Components } from "react-markdown" |
11 | 11 | import remarkGfm from "remark-gfm" |
12 | 12 | import { remarkAlert } from "remark-github-blockquote-alert" |
13 | 13 | import rehypeSanitize, { defaultSchema } from "rehype-sanitize" |
14 | 14 | import rehypeSlug from "rehype-slug" |
15 | 15 | import rehypeHighlight from "rehype-highlight" |
16 | 16 | import rehypeAutolinkHeadings from "rehype-autolink-headings" |
17 | 17 | import { ensureMarkdownStyles } from "./markdown-theme" |
| 18 | +import { Mermaid } from "./Mermaid" |
18 | 19 |
|
19 | 20 | /** |
20 | 21 | * Props for the Markdown component implementation. |
@@ -99,11 +100,39 @@ const rehypePlugins = [ |
99 | 100 | rehypeNormalizeClassName, |
100 | 101 | // Skip code blocks tagged with an unknown language instead of throwing; |
101 | 102 | // highlight only what it recognises. |
102 | | - [rehypeHighlight, { detect: true, ignoreMissing: true }], |
| 103 | + [rehypeHighlight, { detect: true, ignoreMissing: true, plainText: ["mermaid"] }], |
103 | 104 | [rehypeAutolinkHeadings, { behavior: "append", properties: { className: ["md-anchor"], ariaHidden: true, tabIndex: -1 }, content: { type: "text", value: "#" } }], |
104 | 105 | [rehypeSanitize, sanitizeSchema], |
105 | 106 | ] as React.ComponentProps<typeof ReactMarkdown>["rehypePlugins"] |
106 | 107 |
|
| 108 | +// Flatten a react-markdown code child down to its raw text. mermaid blocks are |
| 109 | +// emitted as a plain text node (rehype-highlight is told to skip "mermaid"), so |
| 110 | +// this yields the diagram source verbatim. |
| 111 | +function nodeText(node: React.ReactNode): string { |
| 112 | + if (typeof node === "string") return node |
| 113 | + if (typeof node === "number") return String(node) |
| 114 | + if (Array.isArray(node)) return node.map(nodeText).join("") |
| 115 | + if (React.isValidElement(node)) return nodeText((node.props as { children?: React.ReactNode }).children) |
| 116 | + return "" |
| 117 | +} |
| 118 | + |
| 119 | +// Intercept fenced ```mermaid blocks at the <pre> level (so no <pre> wrapper is |
| 120 | +// emitted) and render them as diagrams; every other block renders normally. |
| 121 | +const mdComponents: Components = { |
| 122 | + pre({ node: _node, children, ...rest }) { |
| 123 | + const child = React.Children.toArray(children)[0] |
| 124 | + const className = |
| 125 | + React.isValidElement(child) && typeof (child.props as { className?: unknown }).className === "string" |
| 126 | + ? ((child.props as { className?: string }).className as string) |
| 127 | + : "" |
| 128 | + if (/\blanguage-mermaid\b/.test(className)) { |
| 129 | + const source = nodeText((child as React.ReactElement<{ children?: React.ReactNode }>).props.children) |
| 130 | + return <Mermaid chart={source} /> |
| 131 | + } |
| 132 | + return <pre {...rest}>{children}</pre> |
| 133 | + }, |
| 134 | +} |
| 135 | + |
107 | 136 | /** |
108 | 137 | * Internal Markdown implementation component. |
109 | 138 | * This contains the actual react-markdown import (heavy ~100-200 KB). |
@@ -139,6 +168,7 @@ export default function MarkdownImpl({ content, className }: MarkdownImplProps) |
139 | 168 | <ReactMarkdown |
140 | 169 | remarkPlugins={remarkPlugins} |
141 | 170 | rehypePlugins={rehypePlugins} |
| 171 | + components={mdComponents} |
142 | 172 | // Defense-in-depth beyond rehype-sanitize: these never reach the DOM. |
143 | 173 | disallowedElements={['script', 'style', 'iframe', 'object', 'embed']} |
144 | 174 | unwrapDisallowed={true} |
|
0 commit comments