Skip to content
bflo.sh

ADR-0012: Mermaid diagrams render client-side; the server pipeline leaves the fence inert

ADR-0012 — Accepted 2026-07-06. Part of the platform's decision record.

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:

  1. Backend pipeline unchanged. The ADR-0007 renderer + sanitiser treat a ```mermaid fence as an ordinary fenced code block: the diagram source is delivered inert inside Article.body as 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.
  2. Frontend renders lazily, in a scoped island. The reader detects code.language-mermaid blocks in the injected body and renders them to SVG with mermaid.js in 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.
  3. 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.

  1. 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.
  2. jbzoo/mermaid-php (or any pure-PHP Mermaid library/builder) — the user suggested jbzoo/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 are renderHtml() (emit a standalone HTML page that embeds mermaid.js and renders client-side in the browser) and getLiveEditorUrl() (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 ```mermaid fences in Markdown, so we render author-supplied text, not PHP-constructed diagrams; the programmatic builder is irrelevant to our input. (ii) no server renderrenderHtml() emits whole HTML pages, not fragments that compose into our sanitised Article.body or 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.
  3. 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).
  4. 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 mermaid version; (b) configure securityLevel: 'strict' (no raw HTML in labels, no script/click handler 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 the TrustedHtmlSanitizer allowlist so <pre><code class="language-mermaid"> survives verbatim. Note the current gap: code/pre are allowed today as plain elements with no attributes, so the class attribute is stripped — the fence class must be explicitly permitted (ideally constrained to language-*) with an XSS fixture proving no other attribute leaks through. Add a test asserting a ```mermaid fence 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 from Article.body); update docs/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 ```mermaid block through the ADR-0007 pipeline and assert the output contains <pre><code class="language-mermaid"> with the source intact and no on*/script/other attributes — verifiable in ddev composer verify.
  • No-contract-change check: Article.body / bodyFormat are unchanged and info.version is not bumped for this feature; confirmed by the breaking-change review at ddev contract-lint.
  • Island-scoping check: diagram-free article pages ship no mermaid JS (the island is imported only on detection); verifiable against the frontend JS-budget assertion in docs/ui-ux.md §14 and ddev 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.