Diagrams

Write a mermaid code fence in an MDX page and get an SVG in the static HTML. The page ships no diagram runtime, and the diagram follows your light and dark theme.

Markdown sourceremark pluginInline SVGStatic page

The diagram above is rendered by the plugin on this page. The docs app uses the same file as the fixture that CI builds.

The problem

Mermaid's own library draws diagrams in the browser. That ships a large script to every reader, renders after the page loads, and needs its own theming and dark-mode handling.

Pele renders the same Mermaid syntax to SVG synchronously and has no runtime dependencies. A remark plugin can run it while MDX compiles, so the SVG is part of the HTML you build.

Install

pnpm add -D pele

The plugin

The plugin replaces each mermaid code block with a div holding the SVG.

import { PeleError, render } from 'pele';
 
interface MermaidNode {
	type: string;
	lang?: string | null;
	value?: string;
	children?: MermaidNode[];
	position?: { start: { line: number; column: number } };
	[key: string]: unknown;
}
 
interface MermaidFile {
	path?: string;
}
 
const UNSAFE_HTML_IDENTIFIER = 'diagramUnsafeHtml';
 
const unsafeHtmlImport: MermaidNode = {
	type: 'mdxjsEsm',
	value: `import { unsafeHtml as ${UNSAFE_HTML_IDENTIFIER} } from '@ecopages/jsx';`,
	data: {
		estree: {
			type: 'Program',
			sourceType: 'module',
			body: [
				{
					type: 'ImportDeclaration',
					specifiers: [
						{
							type: 'ImportSpecifier',
							imported: { type: 'Identifier', name: 'unsafeHtml' },
							local: { type: 'Identifier', name: UNSAFE_HTML_IDENTIFIER },
						},
					],
					source: { type: 'Literal', value: '@ecopages/jsx', raw: "'@ecopages/jsx'" },
				},
			],
		},
	},
};
 
/**
 * Builds a flow element whose only child is the SVG marked as trusted markup.
 *
 * @remarks
 * MDX drops raw `html` nodes and the JSX runtime escapes string children, so the SVG
 * goes through `unsafeHtml`, imported once per page by {@link unsafeHtmlImport}. The
 * SVG comes from Pele, never from page text, so it is trusted.
 */
function diagramElement(svg: string): MermaidNode {
	return {
		type: 'mdxJsxFlowElement',
		name: 'div',
		attributes: [{ type: 'mdxJsxAttribute', name: 'class', value: 'diagram' }],
		children: [
			{
				type: 'mdxFlowExpression',
				value: `${UNSAFE_HTML_IDENTIFIER}(${JSON.stringify(svg)})`,
				data: {
					estree: {
						type: 'Program',
						sourceType: 'module',
						body: [
							{
								type: 'ExpressionStatement',
								expression: {
									type: 'CallExpression',
									optional: false,
									callee: { type: 'Identifier', name: UNSAFE_HTML_IDENTIFIER },
									arguments: [{ type: 'Literal', value: svg }],
								},
							},
						],
					},
				},
			},
		],
	} as MermaidNode;
}
 
/**
 * Formats a Pele failure as `<file>:<line>:<column>`, with the line moved from the
 * diagram source to the page by offsetting it by the code fence position.
 */
function describePeleError(error: PeleError, filePath: string, fenceLine: number | undefined): string {
	const line = fenceLine === undefined ? error.line : fenceLine + error.line;
	return `Invalid Mermaid diagram at ${filePath}:${line}:${error.column}: ${error.message}`;
}
 
/**
 * Replaces ```mermaid code blocks with SVG rendered at build time by Pele.
 *
 * @remarks
 * Each diagram gets an `idPrefix` derived from the last two path segments and its index on the
 * page, so ids stay unique within a page and stable between builds. Colours come from `--pele-*`
 * variables in the site CSS, so theme switches need no re-render. Invalid diagrams throw with the
 * file and the page line and column.
 */
export function remarkMermaid() {
	return (tree: MermaidNode, file?: MermaidFile) => {
		const filePath = file?.path ?? 'unknown file';
		const slug = filePath
			.replace(/\.[^./\\]+$/, '')
			.split(/[\\/]/)
			.slice(-2)
			.join('-')
			.replace(/[^a-zA-Z0-9]+/g, '-')
			.replace(/^-|-$/g, '');
		let index = 0;
 
		const visit = (parent: MermaidNode) => {
			parent.children?.forEach((child, childIndex) => {
				if (child.type === 'code' && child.lang === 'mermaid') {
					const idPrefix = `mermaid-${slug}-${index++}`;
					try {
						const { svg } = render(child.value ?? '', { idPrefix, responsive: true });
						parent.children![childIndex] = diagramElement(svg);
					} catch (error) {
						if (error instanceof PeleError) {
							throw new Error(describePeleError(error, filePath, child.position?.start.line), {
								cause: error,
							});
						}
						throw error;
					}
					return;
				}
				visit(child);
			});
		};
 
		visit(tree);
		if (index > 0) {
			tree.children?.unshift(unsafeHtmlImport);
		}
	};
}
  • Build time only. render runs while MDX compiles. Nothing of Pele reaches the browser.
  • A unique idPrefix per diagram. Pele prefixes the ids inside each SVG, and two diagrams on one page must not share ids. The prefix comes from the page path and the diagram index, so it is unique within a page and the same on every build.
  • Errors fail the build. A PeleError becomes an error that names the file, line and column of the mistake in the page, not in the diagram.
  • Trusted markup. MDX drops raw HTML nodes and the JSX runtime escapes string children, so the plugin emits the SVG through unsafeHtml from @ecopages/jsx. That is safe here because the SVG comes from Pele, which escapes diagram text.

Register it

Add the plugin to the MDX options of your JSX integration.

import { defineConfig } from '@ecopages/core/config';
import { ecopagesJsxPlugin } from '@ecopages/ecopages-jsx';
import { remarkMermaid } from './src/lib/remark-mermaid';
 
export default defineConfig({
	rootDir: import.meta.dirname,
	baseUrl: 'http://localhost:3000',
	integrations: [
		ecopagesJsxPlugin({
			extensions: ['.tsx'],
			mdx: { remarkPlugins: [remarkMermaid] },
		}),
	],
});

Write a diagram

Put the diagram in a code fence in any MDX page. This page, from the fixture, has a flowchart and a sequence diagram:

# Diagrams
 
A flowchart:
 
```mermaid
flowchart LR
  Source[Markdown source] --> Plugin[remark plugin]
  Plugin --> Svg[Inline SVG]
  Svg --> Page[Static page]
```
 
A sequence diagram:
 
```mermaid
sequenceDiagram
  Browser->>Server: GET /
  Server-->>Browser: HTML with the SVG already inline
```

Theme it

Pele reads its colors and font from CSS variables, so you theme diagrams in your own CSS. Set the --pele-* variables on the wrapper the plugin emits, here .diagram, and map them to your own colors.

.diagram {
	--pele-bg: var(--color-bg);
	--pele-fg: var(--color-fg);
	--pele-muted: color-mix(in oklab, var(--color-fg) 60%, transparent);
	--pele-line: var(--color-fg);
	--pele-surface: var(--color-surface);
	--pele-surface-alt: color-mix(in oklab, var(--color-surface) 70%, var(--color-bg));
	--pele-border: var(--color-border);
	--pele-accent: var(--color-accent);
	--pele-critical: var(--color-danger);
	--pele-font: system-ui, sans-serif;
	margin-block: 1.5rem;
	overflow-x: auto;
}
 
.diagram svg {
	display: block;
	height: auto;
	margin-inline: auto;
	max-width: 100%;
}

Because the SVG uses the variables rather than fixed colors, a change of theme, such as flipping a data-theme attribute, restyles it without rendering again.

Trade-offs

  • Pele targets Mermaid 12.1.0 syntax with its own layout and visual style. A diagram can look different from Mermaid's, and a rarely used feature can be missing.
  • The SVG is inlined into every page that uses it. Large diagrams add to the HTML size.
  • Diagrams are static. There is no pan, zoom or click handling unless you add your own script.
  • The plugin does not work in .md files unless your integration compiles them through the same remark pipeline.