diff --git a/lib/index.js b/lib/index.js index 06f07e1..2641b13 100644 --- a/lib/index.js +++ b/lib/index.js @@ -347,7 +347,34 @@ function post(tree, options) { return toJsxRuntime(tree, { Fragment, - components, + components: { + ...components, + code: ({node, className, children, ...props}) => { + const match = /language-(\w+)/.exec(className || '') + + if (match && match[1] === 'mermaid') { + // We need to use a side effect to render mermaid, but this is happening in render + // This is a simplified implementation for demonstration + // In a real library, we would want to move this to a proper useEffect hook inside a component + // But since we are modifying the library to support it generally, let's wrap it in a component + return jsx(MermaidGraph, { + chart: String(children), + component: components && components.code, + node, + className, + children, + ...props + }) + } + + // If user provided a code component, use it + if (components && components.code) { + return jsx(components.code, {node, className, children, ...props}) + } + + return jsx('code', {className, ...props, children}) + } + }, ignoreInvalidStyle: true, jsx, jsxs, @@ -371,6 +398,29 @@ function post(tree, options) { /** @type {string} */ let key + // Unwrap `pre` for `mermaid`. + if ( + node.tagName === 'pre' && + node.children.length === 1 && + node.children[0].type === 'element' && + node.children[0].tagName === 'code' + ) { + const child = node.children[0] + const className = child.properties.className + + if ( + parent && + typeof index === 'number' && + Array.isArray(className) && + className.some(function (d) { + return d === 'language-mermaid' || d === 'mermaid' + }) + ) { + parent.children[index] = child + return index + } + } + for (key in urlAttributes) { if ( Object.hasOwn(urlAttributes, key) && @@ -409,6 +459,91 @@ function post(tree, options) { } } +/** @type {Promise | undefined} */ +let mermaidPromise + +/** + * @param {object} props + * Props. + * @param {string} props.chart + * Chart definition. + * @param {ComponentType | undefined} [props.component] + * Component. + * @param {string} [props.className] + * Class name. + * @returns {ReactElement} + * Element. + */ +/** + * @param {object} props + * Props. + * @param {string} props.chart + * Chart definition. + * @param {ComponentType | undefined} [props.component] + * Component. + * @param {Element} [props.node] + * Node. + * @param {string} [props.className] + * Class name. + * @param {ReadonlyArray | ReactNode | undefined} [props.children] + * Children. + * @returns {ReactElement} + * Element. + */ +function MermaidGraph({chart, component, node, ...props}) { + const [svg, setSvg] = useState('') + const [error, setError] = useState(false) + + useEffect(() => { + let cancelled = false + setError(false) + + if (chart) { + if (!mermaidPromise) { + mermaidPromise = import('mermaid').then(({default: mermaid}) => { + mermaid.initialize({ + startOnLoad: false, + theme: 'default', + securityLevel: 'strict' + }) + return mermaid + }) + } + + mermaidPromise + .then((mermaid) => { + if (cancelled) return + const id = `mermaid-${Math.random().toString(36).slice(2, 11)}` + return mermaid.render(id, chart.replace(/\n$/, '')) + }) + .then((result) => { + if (cancelled || !result) return + setSvg(result.svg) + }) + .catch(() => { + if (cancelled) return + setError(true) + }) + } + + return () => { + cancelled = true + } + }, [chart]) + + if (error) { + if (component) { + return jsx(component, {node, ...props}) + } + return jsx('pre', {children: jsx('code', props)}) + } + + return jsx('div', { + className: `mermaid ${props.className || ''}`, + dangerouslySetInnerHTML: {__html: svg} + }) +} + /** * Make a URL safe. * diff --git a/package.json b/package.json index 35fe1fa..b078163 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", + "mermaid": "^11.12.2", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", diff --git a/readme.md b/readme.md index 949b180..9085b48 100644 --- a/readme.md +++ b/readme.md @@ -23,6 +23,8 @@ React component to render markdown. (many plugins you can pick and choose from) * [x] **[compliant][section-syntax]** (100% to CommonMark, 100% to GFM with a plugin) +* [x] **[mermaid][section-mermaid]** + (built-in support for mermaid diagrams) ## Contents @@ -47,6 +49,7 @@ React component to render markdown. * [Use custom components (syntax highlight)](#use-custom-components-syntax-highlight) * [Use remark and rehype plugins (math)](#use-remark-and-rehype-plugins-math) * [Plugins](#plugins) +* [Mermaid](#mermaid) * [Syntax](#syntax) * [Compatibility](#compatibility) * [Architecture](#architecture) @@ -595,6 +598,24 @@ Here are three good ways to find plugins: [`rehype-plugin`][github-topic-rehype-plugin] topics — any tagged repo on GitHub +## Mermaid + +`react-markdown` has built-in support for mermaid diagrams. +You can use code blocks with `mermaid` as the language: + +```markdown +```mermaid +graph TD; + A-->B; + A-->C; + B-->D; + C-->D; +``` +``` + +This will automatically render the diagram using [mermaid][mermaid]. +The library is lazily loaded, so it won't affect your initial bundle size if you don't use it. + ## Syntax `react-markdown` follows CommonMark, which standardizes the differences between @@ -942,3 +963,5 @@ abide by its terms. [section-syntax]: #syntax [typescript]: https://www.typescriptlang.org +[mermaid]: https://mermaid-js.github.io/mermaid/ +[section-mermaid]: #mermaid diff --git a/test.jsx b/test.jsx index 1b254db..403111e 100644 --- a/test.jsx +++ b/test.jsx @@ -145,6 +145,15 @@ test('Markdown', async function (t) { ) }) + await t.test('should support mermaid', function () { + assert.equal( + renderToStaticMarkup( + B;\n```'} /> + ), + '
' + ) + }) + await t.test('should support an html (default)', function () { assert.equal( renderToStaticMarkup(),