Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
109 changes: 109 additions & 0 deletions docs/adr/0016-render-mermaid-diagrams-client-side.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| [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 |
16 changes: 16 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
<jacoco-maven-plugin.version>0.8.15</jacoco-maven-plugin.version>
<commonmark.version>0.29.0</commonmark.version>
<jsoup.version>1.21.1</jsoup.version>
<mermaid.version>11.15.0</mermaid.version>
</properties>

<dependencies>
Expand Down Expand Up @@ -154,6 +155,21 @@
<artifactId>jsoup</artifactId>
<version>${jsoup.version}</version>
</dependency>
<!-- Mermaid, self-hosted for client-side lesson diagrams (ADR-0016);
the wildcard exclusion drops the npm WebJar's 21 npm-mirror
transitives, only the mermaid bundle itself is served.
lesson.html references the versioned /webjars path. -->
<dependency>
<groupId>org.webjars.npm</groupId>
<artifactId>mermaid</artifactId>
<version>${mermaid.version}</version>
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
Expand Down
23 changes: 23 additions & 0 deletions src/main/resources/static/css/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
107 changes: 107 additions & 0 deletions src/main/resources/static/js/lesson-mermaid.js
Original file line number Diff line number Diff line change
@@ -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;
}
})();
6 changes: 6 additions & 0 deletions src/main/resources/templates/courses/lesson.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,11 @@ <h1 class="page-title" th:text="${lesson.title}">Lesson title</h1>
</main>

<th:block th:replace="~{fragments/layout :: footer}"></th:block>

<!-- Client-side diagram rendering (ADR-0016): lesson-mermaid.js loads
the Mermaid bundle below only when the lesson contains a
mermaid fence. Version also pinned in pom.xml (mermaid.version). -->
<script defer th:src="@{/js/lesson-mermaid.js}"
th:attr="data-mermaid-src=@{/webjars/mermaid/11.15.0/dist/mermaid.min.js}"></script>
</body>
</html>
3 changes: 3 additions & 0 deletions src/main/resources/templates/instructor/lesson-form.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 <code>```mermaid</code> blocks render as diagrams;
add an <code>accTitle:</code> line inside so screen readers
announce a title for the diagram.
</span>
<textarea class="form__input" th:field="*{contentMarkdown}" rows="14"
aria-describedby="contentMarkdown-hint"></textarea>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,23 @@ void strips_class_spoofing_while_alert_classes_survive() {
assertThat(html).contains("<p class=\"markdown-alert-title\">Note</p>");
}

@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("<code class=\"language-mermaid\">");
assertThat(html).contains("flowchart LR");
assertThat(html).contains("accTitle: Flow");
assertThat(html).doesNotContain("<svg");
}

@Test
void blank_content_renders_to_an_empty_string() {
assertThat(markdownRenderer.render("")).isEmpty();
Expand Down