From 28a4190032b04e9f33454b970b1e32480ba905bc Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Sat, 1 Aug 2026 15:37:15 +0200 Subject: [PATCH 1/4] docs(plan): Add the lesson syntax highlighting plan Fenced code blocks in lessons render monochrome even though the renderer has emitted language-tagged markup since ADR-0013. The plan adds a client-side highlighter on the ADR-0016 pattern: pinned self-hosted highlight.js, lazy-loaded, explicit languages only, token colors mapped to the surface-safe theme accents. Refs #128 --- .../2026-07-31-lesson-syntax-highlighting.md | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/plans/2026-07-31-lesson-syntax-highlighting.md diff --git a/docs/plans/2026-07-31-lesson-syntax-highlighting.md b/docs/plans/2026-07-31-lesson-syntax-highlighting.md new file mode 100644 index 0000000..656b617 --- /dev/null +++ b/docs/plans/2026-07-31-lesson-syntax-highlighting.md @@ -0,0 +1,61 @@ +# Lesson Syntax Highlighting Implementation Plan + +**Goal:** Color fenced code blocks in lessons by language (issue #128). +The server side has been ready since +[ADR-0013](../adr/0013-render-lesson-markdown-with-commonmark-java.md): +the renderer emits `code class="language-java"` and the sanitizer keeps +the class for a future syntax highlighter. This plan adds that +highlighter, following the client-side precedent of +[ADR-0016](../adr/0016-render-mermaid-diagrams-client-side.md); the +decision is recorded in +[ADR-0017](../adr/0017-highlight-lesson-code-client-side.md). + +**Out of scope:** highlighting outside lessons, line numbers, copy +buttons, and languages beyond the highlight.js common bundle. + +--- + +## Version Control (GitButler) + +- Commit with `but commit --branch feat/lesson-syntax-highlighting` + from the main repository. +- **NEVER push.** The user reviews in GitButler and pushes manually. + +--- + +## Tasks + +- [ ] `docs(plan): Add the lesson syntax highlighting plan` (this document) +- [ ] `docs(adr): Record the client-side highlighting decision` + ([ADR-0017](../adr/0017-highlight-lesson-code-client-side.md) and the + ADR index row) +- [ ] `build(deps): Add the pinned highlight.js webjar` + (`org.webjars.npm:highlightjs__cdn-assets` 11.11.1; the plain + `highlight.js` webjar ships no browser bundle since v11; jar content + and zero transitives verified before committing) +- [ ] `feat(frontend): Highlight lesson code blocks by language` + (closes #128) + - `static/js/lesson-highlight.js`, same shape as `lesson-mermaid.js`: + select `.lesson-content pre > code[class*="language-"]`, skip + `language-mermaid`, bail out when nothing matches, lazy-load the + bundle from the script tag's `data-hljs-src`, highlight only blocks + whose language `hljs.getLanguage()` recognizes + - `base.css`: map hljs token classes to the surface-safe accent + tokens (keyword/type => primary, string => success, number/literal + => warning, comment => text-muted italic, title/attr => link); no + vendor theme CSS + - `templates/courses/lesson.html`: script tag with the versioned + webjar path in `data-hljs-src` + - `templates/instructor/lesson-form.html`: one hint sentence about + language hints on fences + +## Verification + +- Full test suite and Checkstyle; the existing renderer test pinning + `language-java` survival must stay green untouched. +- Browser, seeded lesson (restored afterwards): java, python, sql, and + bash fences show colored tokens in both themes; an unknown-language + fence and a bare fence stay monochrome; a mermaid fence still renders + as a diagram; axe (WCAG 2.1 A/AA) zero violations; no horizontal page + scroll (RGAA 10.11). +- Throwaway data cleaned; `but status` clean; nothing pushed. From 60a9e0b34dfbcf37ae9e0075fe260410022dde99 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Sat, 1 Aug 2026 15:37:19 +0200 Subject: [PATCH 2/4] docs(adr): Record the client-side highlighting decision ADR-0017 closes the seam ADR-0013 left open (the sanitizer keeps the code class for a future syntax highlighter) with the same shape as ADR-0016: the browser upgrades sanitized markup, the server changes nothing. highlight.js is chosen over Prism (needs a bundler or per-language tags for equal coverage) and over any server-side highlighter (would force span classes through the sanitizer and bake presentation into the content-addressed cache). Token colors map to the theme accents already proven surface-safe, so both themes pass AA by construction. Refs #128 --- .../0017-highlight-lesson-code-client-side.md | 102 ++++++++++++++++++ docs/adr/README.md | 3 +- 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0017-highlight-lesson-code-client-side.md diff --git a/docs/adr/0017-highlight-lesson-code-client-side.md b/docs/adr/0017-highlight-lesson-code-client-side.md new file mode 100644 index 0000000..b9a6076 --- /dev/null +++ b/docs/adr/0017-highlight-lesson-code-client-side.md @@ -0,0 +1,102 @@ +# Highlight lesson code client-side with highlight.js + +- Status: accepted +- Date: 2026-07-31 +- Deciders: Eric Bouchut + +## Context and Problem Statement + +Fenced code blocks in lessons render monochrome. The rendering pipeline +has anticipated highlighting since +[ADR-0013](0013-render-lesson-markdown-with-commonmark-java.md): the +sanitizer allowlist keeps `class` on `code` precisely so the +`language-*` hint survives, and a renderer test pins that contract. +Where should the tokens-to-colors transformation run, given that the +sanitizer deliberately strips every other class and the render cache +stores sanitized HTML by content hash? + +## Decision Drivers + +- ADR-0013's posture stays: the sanitizer allowlist must not widen for + presentation markup (a server-side highlighter would need + `span[class]` plus a large token-class value allowlist) +- The [ADR-0016](0016-render-mermaid-diagrams-client-side.md) precedent: + server ships sanitized source, the browser upgrades, assets are + self-hosted and version-pinned, features load lazily +- Colors must meet WCAG AA on the code-card surface in both themes + (RGAA 3.2), without maintaining a vendor theme fork +- Predictability: no auto-detect guessing on unhinted blocks + +## Considered Options + +- highlight.js, client-side, lazy-loaded (self-hosted WebJar) +- Prism.js, client-side +- A server-side Java highlighter emitting token spans through the + sanitizer +- Do nothing: code blocks stay monochrome + +## Decision Outcome + +Chosen: "highlight.js, client-side, lazy-loaded", because it consumes +exactly the markup the renderer already emits (`pre > code.language-*`), +ships as a single self-hosted browser bundle with the common languages +baked in, and leaves the sanitizer and the render cache byte-identical. +Prism expects a bundler or per-language script tags to reach the same +coverage; a server-side highlighter would force presentation spans +through the sanitizer, widening the allowlist ADR-0013 fought to keep +narrow, and binding highlighting into the content-addressed cache. + +Implementation decisions that follow: + +- The bundle ships as the version-pinned + `org.webjars.npm:highlightjs__cdn-assets` dependency (zero + transitives). The plain `highlight.js` WebJar is a known trap: since + v11 it contains only bundler modules, no browser build. +- `lesson-highlight.js` mirrors `lesson-mermaid.js`: it loads the + bundle only when the lesson contains a `language-*` block other than + `language-mermaid` (Mermaid owns those), and highlights only blocks + whose language `hljs.getLanguage()` recognizes; unknown hints and + bare fences stay monochrome rather than getting auto-detect guesses. +- No vendor theme: hljs token classes map to the theme accent tokens + already proven surface-safe in the light-theme contrast audit + (keyword/type to primary, string to success, number/literal to + warning, comment to muted italic, title/attr to link), so both themes + pass AA by construction and follow future token retuning for free. + +### Consequences + +- Good: the "future syntax highlighter" seam ADR-0013 left open closes + with zero server-side change; the contract test keeps guarding it +- Good: colors track the design tokens in both themes automatically +- Good: lessons without code hints pay nothing (lazy load) +- Trade-off: one more pinned client-side dependency to keep current + (highlight.js has had ReDoS advisories; Dependabot watches the WebJar) +- Trade-off: readers without JavaScript see monochrome code, the same + graceful floor as before + +## Pros and Cons of the Options + +### highlight.js client-side (chosen) + +- 👍 Consumes the existing sanitized markup as-is; single-file bundle +- 👍 Common-languages build covers the curriculum (java, js, python, + sql, bash, ...) +- 👎 One more client-side dependency to pin and update + +### Prism.js client-side + +- 👍 Fine-grained language plugins +- 👎 Reaching the same coverage needs a bundler or a script tag per + language; no advantage for a no-build project + +### Server-side Java highlighter + +- 👍 No client-side work at all +- 👎 Forces `span[class]` and a token-class allowlist through the + sanitizer, and bakes presentation into the content-addressed cache; + no maintained Java highlighter matches hljs coverage + +### Do nothing + +- 👍 No code +- 👎 The renderer keeps emitting language hints nothing consumes diff --git a/docs/adr/README.md b/docs/adr/README.md index 32c8f20..e387e14 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -46,4 +46,5 @@ NNNN-short-title-in-kebab-case.md | [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 | -| [0016](0016-render-mermaid-diagrams-client-side.md) | Render Mermaid diagrams client-side in lessons | accepted | \ No newline at end of file +| [0016](0016-render-mermaid-diagrams-client-side.md) | Render Mermaid diagrams client-side in lessons | accepted | +| [0017](0017-highlight-lesson-code-client-side.md) | Highlight lesson code client-side with highlight.js | accepted | \ No newline at end of file From e2eef79ba8f24dfd942f815a561d5b1bc66048fe Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Sat, 1 Aug 2026 15:37:48 +0200 Subject: [PATCH 3/4] build(deps): Add the pinned highlight.js webjar The bundle ships self-hosted through org.webjars.npm:highlightjs__cdn-assets, version pinned where Dependabot can see it (highlight.js has had ReDoS advisories). The cdn-assets artifact is deliberate: since v11 the plain highlight.js webjar contains only bundler modules and no browser build. Jar content verified (highlight.min.js with the common languages baked in) and the POM declares zero transitive dependencies, confirmed with dependency:tree. Lesson pages require authentication, so the existing catch-all rule already authorizes the /webjars path. Refs #128 --- pom.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pom.xml b/pom.xml index 2ff07ab..00ddabf 100644 --- a/pom.xml +++ b/pom.xml @@ -61,6 +61,7 @@ 0.29.0 1.21.1 11.15.0 + 11.11.1 @@ -170,6 +171,15 @@ + + + org.webjars.npm + highlightjs__cdn-assets + ${highlightjs.version} + org.springframework.boot spring-boot-starter-cache From 4342f9598da604656e9a02c293aab64be4cf1d8a Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Sat, 1 Aug 2026 15:41:28 +0200 Subject: [PATCH 4/4] feat(frontend): Highlight lesson code blocks by language Language-tagged code blocks rendered monochrome even though the renderer has emitted the language-* class since ADR-0013, kept through sanitization for exactly this feature. lesson-highlight.js now closes that seam on the ADR-0017 decision: it loads the self-hosted highlight.js bundle lazily, only when the lesson contains a language-tagged fence other than mermaid, and highlights only blocks whose language the bundle recognizes. Unknown hints and bare fences stay monochrome by design (no auto-detect guessing), and mermaid fences remain the diagram feature's territory. Token colors come from a small hand-written mapping to the theme accent tokens already proven to clear 4.5:1 on the surface background in both themes, so highlighted code follows the design system and any future token retuning with no vendor CSS to fork. The lesson form hints that fences take a language tag. Fixes #128 --- src/main/resources/static/css/base.css | 44 +++++++++++++++++++ .../resources/static/js/lesson-highlight.js | 39 ++++++++++++++++ .../resources/templates/courses/lesson.html | 5 +++ .../templates/instructor/lesson-form.html | 2 + 4 files changed, 90 insertions(+) create mode 100644 src/main/resources/static/js/lesson-highlight.js diff --git a/src/main/resources/static/css/base.css b/src/main/resources/static/css/base.css index 38ad515..53f1d9b 100644 --- a/src/main/resources/static/css/base.css +++ b/src/main/resources/static/css/base.css @@ -865,3 +865,47 @@ textarea.form__input { margin-bottom: var(--space-3); font-size: var(--font-size-sm); } + +/* Syntax highlighting tokens (ADR-0017): spans injected client-side by + lesson-highlight.js. Colors reuse the accent tokens proven to clear + 4.5:1 on the surface background in both themes, so highlighted code + inherits the theme's contrast guarantees and follows token retuning + for free. */ +.lesson-content .hljs-keyword, +.lesson-content .hljs-built_in, +.lesson-content .hljs-type, +.lesson-content .hljs-selector-tag { + color: var(--color-primary); +} + +.lesson-content .hljs-string, +.lesson-content .hljs-regexp, +.lesson-content .hljs-addition { + color: var(--color-success); +} + +.lesson-content .hljs-number, +.lesson-content .hljs-literal, +.lesson-content .hljs-symbol, +.lesson-content .hljs-attribute { + color: var(--color-warning); +} + +.lesson-content .hljs-comment, +.lesson-content .hljs-quote { + color: var(--color-text-muted); + font-style: italic; +} + +.lesson-content .hljs-title, +.lesson-content .hljs-attr, +.lesson-content .hljs-selector-class, +.lesson-content .hljs-selector-id, +.lesson-content .hljs-variable { + color: var(--color-link); +} + +.lesson-content .hljs-meta, +.lesson-content .hljs-deletion { + color: var(--color-error); +} diff --git a/src/main/resources/static/js/lesson-highlight.js b/src/main/resources/static/js/lesson-highlight.js new file mode 100644 index 0000000..ab93643 --- /dev/null +++ b/src/main/resources/static/js/lesson-highlight.js @@ -0,0 +1,39 @@ +/* + * Colors fenced code blocks in lesson content by language. + * + * The server has shipped language-tagged, sanitized code blocks since + * ADR-0013; this script (ADR-0017) lazily loads the self-hosted + * highlight.js bundle only when the lesson contains one, and highlights + * only blocks whose language hint the bundle recognizes. Unknown hints + * and bare fences stay monochrome on purpose (no auto-detect guessing), + * and mermaid fences belong to lesson-mermaid.js. + */ +(function () { + "use strict"; + + var codeBlocks = Array.prototype.filter.call( + document.querySelectorAll( + '.lesson-content pre > code[class*="language-"]'), + function (code) { + return !code.classList.contains("language-mermaid"); + }); + if (codeBlocks.length === 0) { + return; + } + + var loader = document.querySelector("script[data-hljs-src]"); + var script = document.createElement("script"); + script.src = loader.getAttribute("data-hljs-src"); + script.onload = highlightAll; + // On load failure the styled monochrome blocks simply stay. + document.head.appendChild(script); + + function highlightAll() { + codeBlocks.forEach(function (code) { + var hint = /language-([\w-]+)/.exec(code.className); + if (hint && window.hljs.getLanguage(hint[1])) { + window.hljs.highlightElement(code); + } + }); + } +})(); diff --git a/src/main/resources/templates/courses/lesson.html b/src/main/resources/templates/courses/lesson.html index 5116bbe..28b8cc1 100644 --- a/src/main/resources/templates/courses/lesson.html +++ b/src/main/resources/templates/courses/lesson.html @@ -35,5 +35,10 @@

Lesson title

mermaid fence. Version also pinned in pom.xml (mermaid.version). --> + + diff --git a/src/main/resources/templates/instructor/lesson-form.html b/src/main/resources/templates/instructor/lesson-form.html index 4476a41..5155f47 100644 --- a/src/main/resources/templates/instructor/lesson-form.html +++ b/src/main/resources/templates/instructor/lesson-form.html @@ -43,6 +43,8 @@ Fenced ```mermaid blocks render as diagrams; add an accTitle: line inside so screen readers announce a title for the diagram. + Tag other fences with a language (```java) + to get syntax coloring.