-
Notifications
You must be signed in to change notification settings - Fork 6.5k
feat(mdx): add mermaid diagram support #9107
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| 'use client'; | ||
|
|
||
| import Mermaid from '@node-core/ui-components/MDX/Mermaid'; | ||
| import { useTheme } from 'next-themes'; | ||
|
|
||
| import type { FC } from 'react'; | ||
|
|
||
| /** | ||
| * Site-level wrapper that maps the next-themes resolved theme onto the | ||
| * shared Mermaid component from @node-core/ui-components. | ||
| */ | ||
| const MermaidDiagram: FC<{ children: string }> = ({ children }) => { | ||
| const { resolvedTheme } = useTheme(); | ||
|
|
||
| return ( | ||
| <Mermaid theme={resolvedTheme === 'dark' ? 'dark' : 'light'}> | ||
| {children} | ||
| </Mermaid> | ||
| ); | ||
| }; | ||
|
|
||
| export default MermaidDiagram; |
| Original file line number | Diff line number | Diff line change | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,6 +4,7 @@ import { shikiOptions } from '#platform/shiki.mjs'; | ||||||||||
| import rehypeShikiji from '@node-core/rehype-shiki/plugin'; | |||||||||||
| import remarkHeadings from '@vcarl/remark-headings'; | |||||||||||
| import rehypeAutolinkHeadings from 'rehype-autolink-headings'; | |||||||||||
| import rehypeMermaid from 'rehype-mermaid'; | |||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Doesn't this dramatically slow down builds since it requires initializing a whole browser instance?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No — with
So build cost is a single AST walk per document, and rendering happens client-side — with the Happy to switch to
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you do some benchmarks and compare building site previously and after? + adding an example storybook with mermaid content so we can render/test?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. both done. stories are in packages/ui-components/src/MDX/Mermaid/index.stories.tsx (flowchart, sequence, dark, invalid-source). for the benchmark i compiled 25 real blog posts through the production chain, with vs without the plugin, 3 warmed runs — 5.62 vs 5.19 ms/doc, so no measurable build cost (pre-mermaid is ast-only, no browser involved).
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you please provide a table with the benchmark results?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
25 real blog posts per run through the production chain (slug, autolink, shiki), warmed shiki singleton, 3 runs each. delta is within run variance, so no measurable build cost. |
|||||||||||
| import rehypeSlug from 'rehype-slug'; | |||||||||||
| import remarkGfm from 'remark-gfm'; | |||||||||||
| import readingTime from 'remark-reading-time'; | |||||||||||
|
|
@@ -21,6 +22,9 @@ export const rehypePlugins = [ | ||||||||||
| rehypeSlug, | |||||||||||
| // Automatically add anchor links to headings (H1, ...) | |||||||||||
| [rehypeAutolinkHeadings, { behavior: 'wrap' }], | |||||||||||
| // Transforms ```mermaid code blocks into renderable diagrams; | |||||||||||
| // must run before Shiki so they are not highlighted as plain code | |||||||||||
| [rehypeMermaid, { strategy: 'pre-mermaid' }], | |||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we're using rehype-mermaid, do we need to add mermaid support on code boxes? Or what exactly this rehype plugin does?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it's pretty minimal — the plugin just rewrites ```mermaid fenced blocks into at compile time. MDXCodeBox routes those to the mermaid component instead of a code box, and regular code blocks are untouched. so no, no extra codebox support needed.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So why we need a mermaid UI component?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the component is the host that runs the renderer on the client. the wasm module needs a dom node to render into, plus theme handling and an error fallback, and the component owns those. it is the piece i want to keep. what should go away is the pre transformation in front of it, which is the part that grew weird. |
|||||||||||
| // Transforms sequential code elements into code tabs and | |||||||||||
| // adds our syntax highlighter (Shikiji) to Codeboxes | |||||||||||
| () => singletonShiki, | |||||||||||
|
|
|||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| .mermaid { | ||
| display: flex; | ||
| justify-content: center; | ||
| margin: 1rem 0; | ||
| } | ||
|
|
||
| .mermaid svg { | ||
| max-width: 100%; | ||
| height: auto; | ||
| } | ||
|
|
||
| .fallback { | ||
| overflow-x: auto; | ||
| padding: 1rem; | ||
| border-radius: 0.5rem; | ||
| background: rgb(0 0 0 / 0.05); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
|
|
||
| import type { Meta as MetaObj, StoryObj } from '@storybook/react-webpack5'; | ||
| import type { ComponentProps } from 'react'; | ||
|
|
||
| import Mermaid from './index'; | ||
|
|
||
| type Story = StoryObj<typeof Mermaid>; | ||
| type Meta = MetaObj<typeof Mermaid>; | ||
|
|
||
| const defaultArgs: ComponentProps<typeof Mermaid> = { | ||
| children: 'graph TD;\n A[Start] --> B[Process];\n B --> C[End];', | ||
| }; | ||
|
|
||
| export const Flowchart: Story = {}; | ||
|
|
||
| export const SequenceDiagram: Story = { | ||
| args: { | ||
| children: | ||
| 'sequenceDiagram\n Alice->>Bob: Hello Bob\n Bob-->>Alice: Hi Alice', | ||
| }, | ||
| }; | ||
|
|
||
| export const Dark: Story = { | ||
| args: { | ||
| theme: 'dark', | ||
| }, | ||
| }; | ||
|
|
||
| export const InvalidSource: Story = { | ||
| args: { | ||
| children: 'this is not a diagram', | ||
| }, | ||
| }; | ||
|
|
||
| export default { | ||
| title: 'MDX/Mermaid', | ||
| component: Mermaid, | ||
| args: defaultArgs, | ||
| } as Meta; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| 'use client'; | ||
|
|
||
| import type { FC } from 'react'; | ||
|
|
||
| import useMermaid from '../../hooks/useMermaid'; | ||
|
|
||
| import styles from './index.module.css'; | ||
|
|
||
|
|
||
| type MermaidProps = { | ||
| /** The Mermaid diagram source. */ | ||
| children: string; | ||
| /** The host appearance used for the rendered diagram. */ | ||
| theme?: 'light' | 'dark'; | ||
| }; | ||
|
|
||
| const Mermaid: FC<MermaidProps> = ({ children, theme = 'light' }) => { | ||
| const { containerRef, error } = useMermaid({ | ||
| source: String(children), | ||
| theme, | ||
| }); | ||
|
|
||
| if (error) { | ||
| // If the diagram source is invalid, fall back to showing the source | ||
| return ( | ||
| <pre className={styles.fallback}> | ||
| <code>{children}</code> | ||
| </pre> | ||
| ); | ||
| } | ||
|
|
||
| return <div ref={containerRef} className={styles.mermaid} />; | ||
| }; | ||
|
|
||
| export default Mermaid; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import { describe, it } from 'node:test'; | ||
|
|
||
| import { render, screen, waitFor } from '@testing-library/react'; | ||
|
|
||
| import useMermaid from '../useMermaid'; | ||
|
|
||
| const fakeMermaidModule = { | ||
| initMerman: async () => ({}), | ||
| renderSvgToElement: (target, source) => { | ||
| target.innerHTML = `<svg data-diagram="${source}"></svg>`; | ||
| }, | ||
| }; | ||
|
|
||
| const TestComponent = ({ source, loader }) => { | ||
| const { containerRef, error } = useMermaid({ source, loader }); | ||
|
|
||
| return ( | ||
| <div data-testid="container"> | ||
| {error ? 'error' : ''} | ||
| <div ref={containerRef} data-testid="diagram" /> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| await describe('useMermaid', async () => { | ||
| await it('renders the diagram through the injected loader', async () => { | ||
| render( | ||
| <TestComponent | ||
| source="graph TD; A-->B;" | ||
| loader={async () => fakeMermaidModule} | ||
| /> | ||
| ); | ||
|
|
||
| await waitFor(() => | ||
| assert.ok( | ||
| screen.getByTestId('diagram').innerHTML.includes('<svg'), | ||
| 'expected the diagram SVG to be rendered' | ||
| ) | ||
| ); | ||
| }); | ||
|
|
||
| await it('surfaces loader failures as an error state', async () => { | ||
| const failingLoader = async () => { | ||
| throw new Error('wasm failed to load'); | ||
| }; | ||
|
|
||
| render(<TestComponent source="graph TD; A-->B;" loader={failingLoader} />); | ||
|
|
||
| await waitFor(() => | ||
| assert.ok(screen.getByTestId('container').textContent.includes('error')) | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| 'use client'; | ||
|
|
||
| import { useEffect, useRef, useState } from 'react'; | ||
|
|
||
| type MermaidModule = typeof import('@mermanjs/web'); | ||
|
|
||
| type UseMermaidOptions = { | ||
| /** The Mermaid diagram source. */ | ||
| source: string; | ||
| /** The host appearance used for the rendered diagram. */ | ||
| theme?: 'light' | 'dark'; | ||
| /** Injectable module loader, so unit tests can stub the WASM renderer. */ | ||
| loader?: () => Promise<MermaidModule>; | ||
| }; | ||
|
|
||
| const defaultLoader: () => Promise<MermaidModule> = () => | ||
| import('@mermanjs/web'); | ||
|
|
||
| /** | ||
| * Renders a Mermaid diagram through the Merman WASM renderer into the | ||
| * returned container ref. Rendering happens lazily on the client, so the | ||
| * WASM module is only loaded when a diagram is actually on the page. | ||
| */ | ||
| export const useMermaid = ({ | ||
| source, | ||
| theme = 'light', | ||
| loader = defaultLoader, | ||
| }: UseMermaidOptions) => { | ||
| const containerRef = useRef<HTMLDivElement>(null); | ||
| const [error, setError] = useState<Error | null>(null); | ||
|
|
||
| useEffect(() => { | ||
| let cancelled = false; | ||
|
|
||
| const renderDiagram = async () => { | ||
| try { | ||
| const merman = await loader(); | ||
| await merman.initMerman(); | ||
|
|
||
| if (!cancelled && containerRef.current) { | ||
| merman.renderSvgToElement(containerRef.current, source.trim(), { | ||
| host_theme: { appearance: theme }, | ||
| }); | ||
| setError(null); | ||
| } | ||
| } catch (renderError) { | ||
| if (!cancelled) { | ||
| setError(renderError as Error); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| renderDiagram(); | ||
|
|
||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, [source, theme, loader]); | ||
|
|
||
| return { containerRef, error }; | ||
| }; | ||
|
|
||
| export default useMermaid; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think our codebox component should have anything to do with Mermaid. If Mermaid renders a
prethen it shouldn't. It is ambiguous that we have two transformations and I wonder if this is the right path. As asked on other comments could you describe whypreelement and renders as mermaid? Wouldn't it be more efficient if only one pass is done? Is that possible? Or at the very least it'd be good to remove the dependency of the Mermaid component within the MDX CodeBox component. Point being, that the CodeBox component should have no logic that goes to Mermaid path. And I believe that can be easily fixed on our remark package within this monorepo, so that you early on catch that this is a Mermaid and easily point to the right component as we do for code boxes 🤔There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
agreed on all of it. codebox should not know mermaid exists, and i would not defend the two-transformation flow as a design. it accreted through the review rounds. plan: a small remark plugin in the monorepo that catches ```mermaid nodes early and emits the Mermaid component directly, registered in the mdx component map, with the rehype-mermaid dependency removed and codebox reverted to its original state. single pass, no coupling. that also answers aviv's remark suggestion.