From bf6076da8494fc0e0f26737a1788e239907c8a42 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Tue, 28 Jul 2026 21:54:01 +0200 Subject: [PATCH 1/4] docs(adr): Record the client-side Mermaid rendering decision Lessons should render Mermaid fences as diagrams. ADR-0016 keeps the server out of it: commonmark-java already emits the fence as a sanitized code block (no extension exists or is needed), and the browser upgrades it to SVG with a lazily loaded, version-pinned, self-hosted Mermaid running at securityLevel strict. Server-side alternatives (mermaid-cli, Kroki) are rejected as infrastructure disproportionate to the feature. The code block stays in the DOM as the no-JavaScript, syntax-error, and accessibility fallback. Refs #122 --- README.md | 9 +- ...016-render-mermaid-diagrams-client-side.md | 109 ++++++++++++++++++ docs/adr/README.md | 3 +- 3 files changed, 115 insertions(+), 6 deletions(-) create mode 100644 docs/adr/0016-render-mermaid-diagrams-client-side.md diff --git a/README.md b/README.md index e699d6d..cfb6d08 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,10 @@ and [Thymeleaf](https://en.wikipedia.org/wiki/Thymeleaf) frontend. - Language: Java 21 - Frameworks: - - Java Framework used to build (Web) Applications and REST endpoints. - - [Spring Boot](https://spring.io/projects/spring-boot) 3.x: - - Thymeleaf + - [Spring Boot](https://spring.io/projects/spring-boot) 3.x: a Java Framework used to build (Web) Applications (and REST endpoints) + - [Thymeleaf](https://www.thymeleaf.org/index.html) a server-side Java template engine for Web applications - Authentication and authorization framework: - - [Spring Security](https://spring.io/projects/spring-security): + - [Spring Security](https://spring.io/projects/spring-security) 6.5.x: an authentication and access-control framework - Databases: - [PostgreSQL](https://www.postgresql.org/about/) version 17 (relational core) - [MongoDB](https://www.mongodb.com/) version 8, provisioned (Docker) for @@ -52,7 +51,7 @@ and [Thymeleaf](https://en.wikipedia.org/wiki/Thymeleaf) frontend. - [Maven](https://maven.apache.org/what-is-maven.html) - Containerization: - [Podman](https://en.wikipedia.org/wiki/Podman) (preferred over [Docker](https://en.wikipedia.org/wiki/Docker_(software))) - to containerize parts of the application as container images that can run as autonomous containers. + to containerize parts of the application as container images that can run as autonomous containers ### Frontend diff --git a/docs/adr/0016-render-mermaid-diagrams-client-side.md b/docs/adr/0016-render-mermaid-diagrams-client-side.md new file mode 100644 index 0000000..94ca91e --- /dev/null +++ b/docs/adr/0016-render-mermaid-diagrams-client-side.md @@ -0,0 +1,109 @@ +# Render Mermaid diagrams client-side in lessons + +- Status: accepted +- Date: 2026-07-28 +- Deciders: Eric Bouchut + +## Context and Problem Statement + +Instructors want diagrams in lessons, in the Mermaid fence syntax GitHub +and Obsidian share. `commonmark-java` has no Mermaid extension and needs +none: a mermaid fence is an ordinary fenced code block, and the renderer +already lets `code class="language-mermaid"` survive sanitization (see +[ADR-0013](0013-render-lesson-markdown-with-commonmark-java.md)). The real +question is where the text-to-SVG transformation runs, given that the +sanitization pipeline deliberately never lets SVG markup through and the +render cache stores sanitized HTML by content hash. + +## Decision Drivers + +- ADR-0013's posture stays: no SVG, no script, no style crosses the + sanitizer, whatever the Markdown contains +- Author familiarity: content pasted from GitHub or Obsidian should work +- No new server-side infrastructure (no Node, no headless browser, no + extra container) for a feature of this size +- Self-hosted and version-pinned assets, no CDN at page load (privacy, + supply-chain stability) +- The project needs a legitimate, non-decorative client-side JavaScript + feature (DWWM competency CP4, dynamic interfaces) +- Readers without JavaScript, and diagrams with syntax errors, must + degrade to something readable + +## Considered Options + +- Client-side rendering with the Mermaid library, loaded lazily on lesson + pages that contain a diagram +- Server-side rendering with `mermaid-cli` (*Node* plus headless *Chromium* on + the server) +- Server-side rendering through a self-hosted *Kroki* container, embedded + back as images +- Do nothing: mermaid fences stay visible as source code + +## Decision Outcome + +Chosen: *"client-side rendering with the Mermaid library"*, because it is +the only option that adds zero server infrastructure while keeping the +sanitizer contract byte-identical: the server keeps emitting the fenced +source as a sanitized code block (a contract pinned by a renderer test), +and the browser upgrades it to SVG after the fact. The render cache is +unaffected since cached HTML still contains only the code block. + +Implementation decisions that follow: + +- The library ships as the version-pinned `org.webjars.npm:mermaid` + dependency with wildcard exclusions (the *npm* `WebJar` otherwise drags 21 + npm-mirror transitives). *Spring Boot* serves it from the classpath; no + *CDN* is involved. Lesson pages require authentication, so the existing + `anyRequest().authenticated()` rule already covers the *WebJar* path. +- `lesson-mermaid.js` loads the 3.3 MB bundle lazily, only when the page + actually contains a `language-mermaid` block, and initializes Mermaid + with `startOnLoad: false` and `securityLevel: "strict"`. +- Handing instructor-controlled text to a large rendering library is a + new client-side attack surface the server sanitizer cannot see. The + mitigations are the strict security level (labels escaped, click + callbacks disabled), the pinned self-hosted version, and keeping the + library off every page that has no diagram. +- The original code block stays in the *DOM*, hidden behind a + "show diagram source" toggle (`aria-expanded`/`aria-controls`): it is + the no-JavaScript fallback, the syntax-error fallback, and the textual + alternative that accompanies the SVG. Diagrams follow the color scheme + (Mermaid `default`/`dark` themes) and re-render on scheme change. + +### Consequences + +- Good: GitHub/Obsidian-style mermaid fences just work; no house syntax +- Good: sanitizer, allowlist, and render cache are untouched +- Good: the project gains its first substantive JavaScript feature (CP4) + with progressive enhancement built in +- Trade-off: diagram rendering costs client CPU and a 3.3 MB lazy asset + on diagram-bearing lessons; readers on very old browsers see source +- Trade-off: a client-side dependency with a history of XSS advisories + must be kept current (pinned version, Dependabot watches the WebJar) +- Trade-off: diagram accessibility depends on authors adding `accTitle` + and `accDescr` lines; the lesson form hints at this but cannot enforce it + +## Pros and Cons of the Options + +### Client-side Mermaid (chosen) + +- 👍 Zero server infrastructure; sanitizer and cache untouched +- 👍 Lazy, self-hosted, version-pinned asset +- 👎 New client-side attack surface, mitigated but real +- 👎 3.3 MB library on diagram-bearing pages + +### mermaid-cli on the server + +- 👍 Clients get plain SVG images, no client-side surface +- 👎 Node plus headless Chromium inside a Spring Boot deployment, the + heaviest possible dependency for the smallest feature + +### Self-hosted Kroki service + +- 👍 Clean HTTP contract, many diagram languages beyond Mermaid +- 👎 One more container to run, secure, and monitor; cache invalidation + questions for regenerated images + +### Do nothing + +- 👍 No code +- 👎 Pasted GitHub/Obsidian content shows raw diagram source forever diff --git a/docs/adr/README.md b/docs/adr/README.md index 30f53a1..32c8f20 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -45,4 +45,5 @@ NNNN-short-title-in-kebab-case.md | [0012](0012-publish-test-coverage-to-codecov.md) | Publish test coverage to Codecov | accepted | | [0013](0013-render-lesson-markdown-with-commonmark-java.md) | Render lesson Markdown with commonmark-java, sanitized by jsoup | accepted | | [0014](0014-demote-markdown-headings-in-lesson-rendering.md) | Demote Markdown headings one level in lesson rendering | accepted | -| [0015](0015-render-lesson-alerts-with-commonmark-alerts.md) | Render lesson alerts with the commonmark-java alerts extension | accepted | \ No newline at end of file +| [0015](0015-render-lesson-alerts-with-commonmark-alerts.md) | Render lesson alerts with the commonmark-java alerts extension | accepted | +| [0016](0016-render-mermaid-diagrams-client-side.md) | Render Mermaid diagrams client-side in lessons | accepted | \ No newline at end of file From 8c83e89ab21081e27e6d07b9bbff96f9d71c1bf7 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Tue, 28 Jul 2026 21:55:09 +0200 Subject: [PATCH 2/4] build(deps): Add the pinned Mermaid webjar Mermaid ships self-hosted through org.webjars.npm:mermaid so lesson diagrams never load from a CDN and the version is pinned where Dependabot can see it. The npm WebJar declares 21 npm-mirror transitive dependencies; a wildcard exclusion drops them all, leaving the single mermaid artifact, verified with dependency:tree. Spring Boot serves the bundle from the classpath at the versioned /webjars path; lesson pages require authentication, so the existing catch-all rule already authorizes it. Refs #122 --- pom.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pom.xml b/pom.xml index e28634d..2ff07ab 100644 --- a/pom.xml +++ b/pom.xml @@ -60,6 +60,7 @@ 0.8.15 0.29.0 1.21.1 + 11.15.0 @@ -154,6 +155,21 @@ jsoup ${jsoup.version} + + + org.webjars.npm + mermaid + ${mermaid.version} + + + * + * + + + org.springframework.boot spring-boot-starter-cache From 002ea22d8d9472e18471406acb70df2acdecf550 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Tue, 28 Jul 2026 21:55:43 +0200 Subject: [PATCH 3/4] test(markdown): Pin the mermaid fence rendering contract The Mermaid feature has no server-side code at all: the browser renders whatever the sanitized code block contains (ADR-0016). That makes the renderer output the load-bearing contract, so a test pins it: the language-mermaid class and the diagram source survive rendering and sanitization, and no SVG appears in server output. Refs #122 --- .../learndev/course/MarkdownRendererTest.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/test/java/com/ericbouchut/learndev/course/MarkdownRendererTest.java b/src/test/java/com/ericbouchut/learndev/course/MarkdownRendererTest.java index 910791b..ba81cad 100644 --- a/src/test/java/com/ericbouchut/learndev/course/MarkdownRendererTest.java +++ b/src/test/java/com/ericbouchut/learndev/course/MarkdownRendererTest.java @@ -192,6 +192,23 @@ void strips_class_spoofing_while_alert_classes_survive() { assertThat(html).contains("

Note

"); } + @Test + void preserves_mermaid_fences_for_client_side_rendering() { + // Arrange (Given): a mermaid fence; the server never renders + // diagrams, the browser does (ADR-0016), so the language class and + // the source must survive sanitization and no SVG may appear here + String markdown = "```mermaid\nflowchart LR\n accTitle: Flow\n A --> B\n```"; + + // Act (When) + String html = markdownRenderer.render(markdown); + + // Assert (Then): the client-side contract holds + assertThat(html).contains(""); + assertThat(html).contains("flowchart LR"); + assertThat(html).contains("accTitle: Flow"); + assertThat(html).doesNotContain(" Date: Tue, 28 Jul 2026 22:04:09 +0200 Subject: [PATCH 4/4] feat(frontend): Render Mermaid diagrams in lessons Mermaid fences used to stay visible as source code. lesson-mermaid.js, the project's first client-side JavaScript feature, now upgrades them to SVG diagrams in the browser, exactly as ADR-0016 decided: the server keeps shipping sanitized code blocks and knows nothing about diagrams. The script loads the self-hosted Mermaid bundle lazily, only when the lesson actually contains a language-mermaid block, and initializes it with securityLevel strict. Each diagram gets a figure plus a show/hide source toggle wired with aria-expanded and aria-controls; the original code block stays in the DOM as the no-JavaScript, syntax-error, and screen-reader fallback (an accTitle line in the source becomes the SVG title). Diagrams follow the color scheme at load and re-render on scheme change; invalid diagrams degrade to the styled source block and Mermaid's detached error artifact is removed. Wide diagrams scroll inside the figure, never the page (RGAA 10.11). The lesson form hints at the fence and the accTitle line. Fixes #122 --- src/main/resources/static/css/base.css | 23 ++++ .../resources/static/js/lesson-mermaid.js | 107 ++++++++++++++++++ .../resources/templates/courses/lesson.html | 6 + .../templates/instructor/lesson-form.html | 3 + 4 files changed, 139 insertions(+) create mode 100644 src/main/resources/static/js/lesson-mermaid.js diff --git a/src/main/resources/static/css/base.css b/src/main/resources/static/css/base.css index 4e016a5..0b562d2 100644 --- a/src/main/resources/static/css/base.css +++ b/src/main/resources/static/css/base.css @@ -802,3 +802,26 @@ textarea.form__input { --alert-accent: var(--color-text-muted); --alert-icon: url("data:image/svg+xml,%3Csvg xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 width%3D%2216%22 height%3D%2216%22 viewBox%3D%220 0 16 16%22%3E%3Cpath d%3D%22M1.75 2.5h10.5a.75.75 0 0 1 0 1.5H1.75a.75.75 0 0 1 0-1.5Zm4 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2.5 7.75v6a.75.75 0 0 1-1.5 0v-6a.75.75 0 0 1 1.5 0Z%22%2F%3E%3C%2Fsvg%3E"); } + +/* Mermaid diagrams (ADR-0016): the figure and toggle are injected + client-side by lesson-mermaid.js, so BEM classes are fine here, + unlike the sanitized Markdown output styled above. */ +.lesson-content .lesson-mermaid { + margin: 0 0 var(--space-2); + padding: var(--space-3); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + overflow-x: auto; /* wide diagrams scroll here, never the page (RGAA 10.11) */ + text-align: center; +} + +.lesson-content .lesson-mermaid svg { + max-width: 100%; + height: auto; +} + +.lesson-content .lesson-mermaid__toggle { + margin-bottom: var(--space-3); + font-size: var(--font-size-sm); +} diff --git a/src/main/resources/static/js/lesson-mermaid.js b/src/main/resources/static/js/lesson-mermaid.js new file mode 100644 index 0000000..f4dc597 --- /dev/null +++ b/src/main/resources/static/js/lesson-mermaid.js @@ -0,0 +1,107 @@ +/* + * Upgrades mermaid fences in lesson content to SVG diagrams. + * + * The server deliberately ships diagrams as sanitized code blocks (see + * ADR-0016): this script lazily loads the self-hosted Mermaid bundle only + * when the page actually contains one, renders each block client-side, + * and keeps the original code block in the DOM behind a toggle as the + * no-JavaScript, syntax-error, and screen-reader fallback. + */ +(function () { + "use strict"; + + var sourceBlocks = document.querySelectorAll( + ".lesson-content pre > code.language-mermaid"); + if (sourceBlocks.length === 0) { + return; + } + + var darkScheme = window.matchMedia("(prefers-color-scheme: dark)"); + var renderPass = 0; + + loadMermaid().then(renderAll).catch(function () { + // The library failed to load: the styled source blocks stay visible. + }); + + darkScheme.addEventListener("change", function () { + if (window.mermaid) { + renderAll(); + } + }); + + function loadMermaid() { + return new Promise(function (resolve, reject) { + var loader = document.querySelector("script[data-mermaid-src]"); + var script = document.createElement("script"); + script.src = loader.getAttribute("data-mermaid-src"); + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); + } + + function renderAll() { + renderPass += 1; + window.mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + theme: darkScheme.matches ? "dark" : "default" + }); + sourceBlocks.forEach(function (code, index) { + renderBlock(code.parentElement, code, index); + }); + } + + function renderBlock(pre, code, index) { + // A fresh id per pass: Mermaid refuses to render into a used id, and + // a theme change re-renders every diagram. + var id = "lesson-mermaid-" + renderPass + "-" + index; + window.mermaid.render(id, code.textContent) + .then(function (result) { + ensureFigure(pre, index).innerHTML = result.svg; + }) + .catch(function () { + // Invalid diagram source: the code block stays visible; drop the + // detached error element Mermaid leaves behind. + var artifact = document.getElementById("d" + id); + if (artifact) { + artifact.remove(); + } + }); + } + + function ensureFigure(pre, index) { + var figureId = "lesson-mermaid-figure-" + index; + var figure = document.getElementById(figureId); + if (figure) { + return figure; + } + + figure = document.createElement("figure"); + figure.id = figureId; + figure.className = "lesson-mermaid"; + + if (!pre.id) { + pre.id = "lesson-mermaid-source-" + index; + } + pre.hidden = true; + + var toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "button button--ghost lesson-mermaid__toggle"; + toggle.textContent = "Show diagram source"; + toggle.setAttribute("aria-expanded", "false"); + toggle.setAttribute("aria-controls", pre.id); + toggle.addEventListener("click", function () { + var expanded = toggle.getAttribute("aria-expanded") === "true"; + toggle.setAttribute("aria-expanded", String(!expanded)); + toggle.textContent = + expanded ? "Show diagram source" : "Hide diagram source"; + pre.hidden = expanded; + }); + + pre.parentNode.insertBefore(figure, pre); + pre.parentNode.insertBefore(toggle, pre); + return figure; + } +})(); diff --git a/src/main/resources/templates/courses/lesson.html b/src/main/resources/templates/courses/lesson.html index 6ed6ee9..5116bbe 100644 --- a/src/main/resources/templates/courses/lesson.html +++ b/src/main/resources/templates/courses/lesson.html @@ -29,5 +29,11 @@

Lesson title

+ + + diff --git a/src/main/resources/templates/instructor/lesson-form.html b/src/main/resources/templates/instructor/lesson-form.html index e503877..4476a41 100644 --- a/src/main/resources/templates/instructor/lesson-form.html +++ b/src/main/resources/templates/instructor/lesson-form.html @@ -40,6 +40,9 @@ Markdown is rendered and sanitized before students see it. The lesson page already shows the title above the content, so there is no need to repeat it as a first heading. + Fenced ```mermaid blocks render as diagrams; + add an accTitle: line inside so screen readers + announce a title for the diagram.