Adapted from the repo's docs/adr/0012-mermaid-diagrams-render-client-side.md — Part of Internals: the platform's design set, published as content on the platform it describes.
- Status: Accepted
- Date: 2026-07-06
- Deciders: contract-owner, lead
- Tiers affected: frontend, backend (sanitiser obligation), docs
- Related: ADR-0007
(amends/extends — introduces one narrow client-side rendering exception to its
"one server pipeline" rule), ADR-0005
(diagram source is authored Markdown on disk),
../ui-ux.md§2 (near-zero-client-JS posture, sanctioned-island count)
Context
Authors want diagrams-as-code: a ```mermaid fenced block in an article's Markdown
should reach the reader as a rendered diagram. Mermaid is a client JavaScript library that
parses its DSL and draws SVG against a live DOM. This collides with ADR-0007, which fixed a
single server-side pipeline (CommonMark → allowlist sanitiser → Article.body HTML) as the
only place Markdown becomes HTML, injected by the frontend via dangerouslySetInnerHTML
with no client parsing or sanitising — and with the docs/ui-ux.md §2 posture of a
server-rendered reading site shipping near-zero client JS (today exactly three sanctioned
islands: error.tsx, /shell, /search).
We must decide where a Mermaid block is turned into a diagram without server-side DOM rendering (a Node/Puppeteer sidecar) and without weakening the ADR-0007 XSS boundary. This is decided now because it shapes the sanitiser allowlist and the frontend's JS budget, and because Mermaid has a documented history of XSS via HTML labels and click bindings — the trust posture must be explicit before anyone implements.
Decision
We will render ```mermaid blocks client-side, as a narrow, explicit exception to
ADR-0007 that leaves prose rendering and sanitisation fully server-side:
- Backend pipeline unchanged. The ADR-0007 renderer + sanitiser treat a
```mermaidfence as an ordinary fenced code block: the diagram source is delivered inert insideArticle.bodyas sanitised text —<pre><code class="language-mermaid">…diagram source…</code></pre>. No Markdown, HTML, or diagram is rendered on the server for this case; the source is escaped like any other code block. - Frontend renders lazily, in a scoped island. The reader detects
code.language-mermaidblocks in the injected body and renders them to SVG withmermaid.jsin a lazy-loaded'use client'island that is imported only on pages that actually contain a diagram. Diagram-free pages ship zero additional JS, preserving the SSR/near-zero-JS posture. - No contract change. Diagrams flow entirely through the existing
Article.body(bodyFormat: "html", ADR-0007). No endpoint, payload, or event changes; the contract stays at its current version.
This amends and extends ADR-0007: its "one server pipeline for all HTML" rule now carries a
single, named exception — client-side rendering of the mermaid fenced language — while every
other byte of Article.body remains server-rendered and server-sanitised. The two ADRs
cross-reference each other.
Alternatives considered
Framing fact that bounds the whole space: Mermaid needs a browser engine (a DOM) to lay out and measure text, so there is no pure-PHP path that rasterises/serialises a diagram to SVG in the request. Every "server-side" option below therefore either shells out to a browser engine, calls an external service, or is not actually a renderer at all — which is why in-process PHP rendering was never on the table.
- Server-side Mermaid via a Node/Puppeteer headless-browser sidecar — render the diagram to
SVG in the pipeline so the posture of ADR-0007 stays literally intact. Rejected: pulls a
headless-browser sidecar into
infra/, adding heavy runtime, memory, and maintenance cost to serve one fenced language — disproportionate to the value. jbzoo/mermaid-php(or any pure-PHP Mermaid library/builder) — the user suggestedjbzoo/mermaid-php; evaluated explicitly. It is a diagram-definition builder — a fluent PHP API to programmatically construct Mermaid syntax ("zero dependencies: only PHP + ext-json") — not a server-side SVG renderer. Its output paths arerenderHtml()(emit a standalone HTML page that embeds mermaid.js and renders client-side in the browser) andgetLiveEditorUrl()(a URL to the external Mermaid live editor); it cannot rasterise to SVG in pure PHP, because (see framing fact) Mermaid needs a browser engine. Rejected on two counts: (i) wrong shape — our authors write```mermaidfences in Markdown, so we render author-supplied text, not PHP-constructed diagrams; the programmatic builder is irrelevant to our input. (ii) no server render —renderHtml()emits whole HTML pages, not fragments that compose into our sanitisedArticle.bodyor the Next reader, and under the hood it is still client-side mermaid.js. Net: it would insert an ill-fitting PHP wrapper in front of the exact client-side rendering we already do — so we use mermaid.js directly in the lazy frontend island instead.- An external Mermaid render service (e.g.
mermaid.ink) — POST the diagram source to a hosted renderer and embed the returned image. Rejected: sends authored content off-box to a third party on every render, adding an external runtime dependency and a privacy/availability coupling that conflicts with the self-contained, read-only API posture (ADR-0006). - Pre-render diagrams to committed SVGs out-of-band — a build/sync step converts each diagram to a checked-in SVG that flows through the existing image path. Rejected: breaks diagrams-as-code (source and output drift, authors edit generated SVG), and couples authoring to a build step that the filesystem-content model (ADR-0005) deliberately avoids.
Consequences
- Easier: authors get diagrams-as-code with no new authoring step; the server pipeline and its single XSS boundary are untouched for all prose; no contract or API version change.
- Harder / relaxed: the "one server pipeline renders all HTML" purity of ADR-0007 is
relaxed for exactly one fenced language. The reading site gains a fourth client island
(scoped + lazy), which
docs/ui-ux.md§2 must record — its sanctioned-island count and JS-budget wording change from "three islands" to include the on-demand Mermaid island. A new client dependency (mermaid.js) enters the frontend and must be pinned. - Security — residual trust assumption (record explicitly): the diagram source is trusted
authored content (same trust model as all content-root prose, ADR-0005). Mermaid nonetheless
has a history of XSS via HTML labels and click-bindings, so the frontend implementation must:
(a) pin the
mermaidversion; (b) configuresecurityLevel: 'strict'(no raw HTML in labels, no script/clickhandler bindings); and (c) render diagrams only from the already server-sanitised body (Mermaid runs after the ADR-0007 sanitiser, never on unsanitised input). The residual assumption is that authored diagram source is non-hostile;securityLevel: 'strict'- a pinned version bound the blast radius if that assumption is ever wrong.
- Follow-up tasks (owners):
data-engineer— confirm/extend theTrustedHtmlSanitizerallowlist so<pre><code class="language-mermaid">survives verbatim. Note the current gap:code/preare allowed today as plain elements with no attributes, so theclassattribute is stripped — the fence class must be explicitly permitted (ideally constrained tolanguage-*) with an XSS fixture proving no other attribute leaks through. Add a test asserting a```mermaidfence round-trips as<pre><code class="language-mermaid">…source…</code></pre>.frontend-engineer— implement the lazy, diagram-only Mermaid island (securityLevel: 'strict', pinned version, rendered fromArticle.body); updatedocs/ui-ux.md§2 to record the new island and its JS-budget carve-out in the same task.contract-owner— no action; confirm at review that no contract change is proposed.
Compliance
- Fence-preservation test (backend/sanitiser): feed a
```mermaidblock through the ADR-0007 pipeline and assert the output contains<pre><code class="language-mermaid">with the source intact and noon*/script/other attributes — verifiable inddev composer verify. - No-contract-change check:
Article.body/bodyFormatare unchanged andinfo.versionis not bumped for this feature; confirmed by the breaking-change review atddev contract-lint. - Island-scoping check: diagram-free article pages ship no
mermaidJS (the island is imported only on detection); verifiable against the frontend JS-budget assertion indocs/ui-ux.md§14 andddev frontend npm run verify. - Security-config check: the Mermaid initialisation uses
securityLevel: 'strict'and a pinned version (lockfile + config inspection); no path renders a diagram from unsanitised input.