---
title: "Diagrams"
description: "Render Mermaid diagrams to inline SVG at build time with Pele, themed with your own CSS and no client JavaScript."
order: 2
---

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

```mermaid
flowchart LR
  Source[Markdown source] --> Plugin[remark plugin]
  Plugin --> Svg[Inline SVG]
  Svg --> Page[Static 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](https://pele.run) 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

```sh
pnpm add -D pele
```

## The plugin

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

```ts file="examples/recipes/diagrams/src/lib/remark-mermaid.ts"
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.

```ts file="examples/recipes/diagrams/eco.config.ts"
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:

```mdx file="examples/recipes/diagrams/src/pages/index.mdx"
# 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.

```css file="examples/recipes/diagrams/src/styles/diagram.css" region="theme"
.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.
