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 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 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. 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("