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