-
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'; | ||
| 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. |
||
| // 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.
Doesn't this dramatically slow down builds since it requires initializing a whole browser instance?
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.
No — with
strategy: 'pre-mermaid'no browser is ever started at build time. Verified in the dependency source:createMermaidRenderer()only creates a lazybrowserPromise(browserPromise ||= getBrowser(...)inside the returned render function inmermaid-isomorphic) — nothing launches at plugin setup.rehype-mermaidnever calls that render function forpre-mermaid; it only rewrites the AST (<pre><code class="language-mermaid">→<pre class="mermaid">). The package comments this exact path as "No need to start a browser in this case."inline-svg/img-*strategies.So build cost is a single AST walk per document, and rendering happens client-side — with the
mermaidlibrary itself lazy-loaded via dynamicimport(), so it stays out of the initial bundle too.Happy to switch to
inline-svgif the team prefers zero client-side JS — that's the trade-off (build-time Chromium vs. client rendering).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.
Can you do some benchmarks and compare building site previously and after? + adding an example storybook with mermaid content so we can render/test?
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.
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).