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 render — renderHtml() 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.