Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/site/components/MDX/CodeBox/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getLanguageDisplayName } from '@node-core/rehype-shiki';
import Mermaid from '@node-core/ui-components/MDX/Mermaid';

import CodeBox from '#site/components/Common/CodeBox';

Expand All @@ -8,6 +9,12 @@ const MDXCodeBox: FC<HTMLAttributes<HTMLElement>> = ({
children: code,
className,
}) => {
// Mermaid diagrams arrive as `<pre class="mermaid">` (see rehype-mermaid),
// so we render them as diagrams instead of code boxes
if (className?.split(' ').includes('mermaid')) {
return <Mermaid>{String(code)}</Mermaid>;
}

const matches = className?.match(/language-(?<language>[a-zA-Z]+)/);
const language = matches?.groups?.language ?? '';

Expand Down
22 changes: 22 additions & 0 deletions apps/site/components/MDX/Mermaid/index.tsx
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;
4 changes: 4 additions & 0 deletions apps/site/mdx/plugins.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Copy link
Copy Markdown
Member

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?

Copy link
Copy Markdown
Author

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 lazy browserPromise (browserPromise ||= getBrowser(...) inside the returned render function in mermaid-isomorphic) — nothing launches at plugin setup.
  • rehype-mermaid never calls that render function for pre-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."
  • Playwright (peer dep) is only exercised by the inline-svg / img-* strategies.

So build cost is a single AST walk per document, and rendering happens client-side — with the mermaid library itself lazy-loaded via dynamic import(), so it stays out of the initial bundle too.

Happy to switch to inline-svg if the team prefers zero client-side JS — that's the trade-off (build-time Chromium vs. client rendering).

Copy link
Copy Markdown
Member

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?

Copy link
Copy Markdown
Author

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).

import rehypeSlug from 'rehype-slug';
import remarkGfm from 'remark-gfm';
import readingTime from 'remark-reading-time';
Expand All @@ -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' }],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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,
Expand Down
1 change: 1 addition & 0 deletions apps/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"react-dom": "^19.2.8",
"reading-time": "~1.5.0",
"rehype-autolink-headings": "~7.1.0",
"rehype-mermaid": "^3.0.0",
"rehype-slug": "~6.0.0",
"remark-gfm": "~4.0.1",
"remark-reading-time": "~2.1.0",
Expand Down
1 change: 1 addition & 0 deletions packages/ui-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
},
"dependencies": {
"@heroicons/react": "^2.2.0",
"@mermanjs/web": "^0.7.0",
"@orama/orama": "^3.1.18",
"@orama/ui": "^1.5.4",
"@radix-ui/react-avatar": "^1.2.3",
Expand Down
17 changes: 17 additions & 0 deletions packages/ui-components/src/MDX/Mermaid/index.module.css
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);
}
39 changes: 39 additions & 0 deletions packages/ui-components/src/MDX/Mermaid/index.stories.tsx
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;
35 changes: 35 additions & 0 deletions packages/ui-components/src/MDX/Mermaid/index.tsx
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;
54 changes: 54 additions & 0 deletions packages/ui-components/src/hooks/__test__/useMermaid.test.jsx
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'))
);
});
});
63 changes: 63 additions & 0 deletions packages/ui-components/src/hooks/useMermaid.ts
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;
Loading
Loading