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
102 changes: 102 additions & 0 deletions docs/adr/0017-highlight-lesson-code-client-side.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| [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 |
61 changes: 61 additions & 0 deletions docs/plans/2026-07-31-lesson-syntax-highlighting.md
Original file line number Diff line number Diff line change
@@ -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 <changes> --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.
10 changes: 10 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
<commonmark.version>0.29.0</commonmark.version>
<jsoup.version>1.21.1</jsoup.version>
<mermaid.version>11.15.0</mermaid.version>
<highlightjs.version>11.11.1</highlightjs.version>
</properties>

<dependencies>
Expand Down Expand Up @@ -170,6 +171,15 @@
</exclusion>
</exclusions>
</dependency>
<!-- highlight.js browser bundle, self-hosted for client-side code
highlighting in lessons (ADR-0017). The cdn-assets artifact is
deliberate: the plain highlight.js webjar ships no browser
build since v11. lesson.html references the versioned path. -->
<dependency>
<groupId>org.webjars.npm</groupId>
<artifactId>highlightjs__cdn-assets</artifactId>
<version>${highlightjs.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
Expand Down
44 changes: 44 additions & 0 deletions src/main/resources/static/css/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
39 changes: 39 additions & 0 deletions src/main/resources/static/js/lesson-highlight.js
Original file line number Diff line number Diff line change
@@ -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);
}
});
}
})();
5 changes: 5 additions & 0 deletions src/main/resources/templates/courses/lesson.html
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,10 @@ <h1 class="page-title" th:text="${lesson.title}">Lesson title</h1>
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>
<!-- Client-side syntax highlighting (ADR-0017): lesson-highlight.js
loads the bundle below only when the lesson has a language-tagged
code fence. Version also pinned in pom.xml (highlightjs.version). -->
<script defer th:src="@{/js/lesson-highlight.js}"
th:attr="data-hljs-src=@{/webjars/highlightjs__cdn-assets/11.11.1/highlight.min.js}"></script>
</body>
</html>
2 changes: 2 additions & 0 deletions src/main/resources/templates/instructor/lesson-form.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
Fenced <code>```mermaid</code> blocks render as diagrams;
add an <code>accTitle:</code> line inside so screen readers
announce a title for the diagram.
Tag other fences with a language (<code>```java</code>)
to get syntax coloring.
</span>
<textarea class="form__input" th:field="*{contentMarkdown}" rows="14"
aria-describedby="contentMarkdown-hint"></textarea>
Expand Down