Part of the jedee wiki — pages written by an AI (Claude Code), supervised by Johan.
Syntax highlighting
Syntax highlighting on a static site is a build-time job. The markdown processor hands each fenced code block to a highlighter, which wraps the interesting substrings in <span class="token …"> elements; a stylesheet colors those classes. No JavaScript reaches the browser, and the highlighting is part of the HTML.
Prism is the usual choice for this. Two things about how it is packaged matter more than they look:
- Every language is a separate component.
prismjson its own knows almost nothing;prism-ruby,prism-toml, and the rest are loaded individually. Integrations likemarkdown-it-prismdo this on demand, so naming a language in a fence is all the registration a language needs. - A language Prism doesn’t have fails quietly. There is no error and no warning. The integration falls back to a configured default, and the block renders as escaped text in a
<pre>that looks deliberate. This is the failure mode to watch for, because an unhighlighted block and a block you chose not to highlight are indistinguishable on the page.
Some Prism languages are more than a grammar. The markup-templating mechanism — a component in Prism 1.x rather than a plugin, which is why it ships in components/ — handles languages that interleave a template syntax with HTML — PHP, EJS, Handlebars, Django/Jinja2. It registers a before-tokenize hook that lifts the template delimiters out into placeholders, tokenizes the remainder as markup, and puts them back. That hook is keyed on the language name, not the grammar object, so borrowing such a grammar under a different name silently gets you the worse half of it: the {% … %} highlighted, the surrounding HTML not.
Auditing which blocks are highlighted
Counting fence tags across a content tree is a one-liner, with one trap in it:
grep -rhoE '^```[a-zA-Z0-9+-]*' src/posts src/wiki | sort | uniq -c | sort -rnA closing fence is also a line of three backticks, so the count for the empty tag is mostly closers, not untagged blocks. Track the open/close state to count real bare fences.
An untagged block is often correct. Terminal sessions mixing a command with its output, ASCII diagrams with pointer lines, error text, and tabular data all render worse tagged than bare — bash colors the output as if it were commands, and a diagram’s │ and ← tokenize as operators.
In jedee
Markdown fences go through markdown-it-prism, configured in src/_config/plugins/markdown.js with defaultLanguage: 'plaintext'. plaintext is not a Prism grammar either, so the fallback path emits class="language-plaintext" and no tokens at all — deliberate for bare fences, invisible when it happens by accident.
It happened by accident to every ```njk fence, 15 of them, almost all on wiki pages quoting layouts. Prism has no Nunjucks grammar. Every one of them had been rendering flat while the js and css blocks beside them were colored.


That is the whole difficulty in one picture. Nothing about the flat block says failure; it says plaintext, which is a legitimate thing for a fence to be. The two are only distinguishable side by side, and only ever became visible because a colored js block happened to sit next to one.
The mockup is src/wiki/_sources/syntax-highlighting.html. Its two panels are the literal output of markdownLib.render() for the same source string, pasted in, and it links the site’s compiled global.css so the token colors are code.css’s own rather than a copy of them. Regenerate the two panels with the one-liner at the foot of this page; npm run mockups re-shoots it. ⚠️ The size and white-space have to be forced onto every descendant of both <pre>s: code.css sizes code as well as pre, and left alone the highlighted half wrapped where the flat half did not — a difference in line breaks that has nothing to do with the finding.
The fix leans on jinja2 (an alias of Prism’s django), whose delimiters are Nunjucks’ delimiters. Aliasing the grammar directly — prism.languages.njk = prism.languages.jinja2 — is the version that looks right and reads wrong, because it skips the markup-templating hook described above and leaves the HTML around the tags as undifferentiated token operator / token variable. What works is rewriting the fence’s language before Prism sees it, so the name stays jinja2:
// Prism has no Nunjucks grammar, so ```njk fences are rendered as jinja2 — close enough, and it highlights the surrounding HTML too. Must run before markdown-it-prism.
.use(md => {
md.core.ruler.push('njk_as_jinja2', state => {
for (const token of state.tokens) {
if (token.type === 'fence' && token.info.trim() === 'njk') token.info = 'jinja2';
}
});
})The block’s class becomes language-jinja2; nothing depends on the language class, since code.css styles .token.* generically.
The audit behind the fix found every other tag in use — js, css, json, html, markdown, yaml, toml, python, bash, jinja2 — already mapped to a real grammar, and the 13 genuinely bare fences all correctly bare.
EE stock vs jedee: markdown-it-prism, the plaintext default, and the .token.* rules in code.css are Eleventy Excellent stock; the color values they point at are jedee’s (see Colors). The njk_as_jinja2 core rule is jedee’s own. @11ty/eleventy-plugin-syntaxhighlight is also registered (EE stock) but serves the {% highlight %} template shortcode, which nothing in the repo uses; markdown fences never touch it.
Verify by rendering, not by reading. The markdown pipeline is importable on its own, so a change can be checked in a second without a build:
node -e "import('./src/_config/plugins/markdown.js').then(m => console.log(m.markdownLib.render('\`\`\`njk\n{% if x %}<a href=\"{{ y }}\">z</a>{% endif %}\n\`\`\`')))"Colors
The token colors started as Eleventy Excellent’s palette, which names its variables by hue in code.css: orange 30°, indigo 260°, violet 314°, pink 350°, a gray and a blue. jedee changed saturation and lightness per theme to clear WCAG AA against the code block background (commit ce80897, 2026-03-08). On 2026-10-11 the palette was reworked after GitHub’s color-blind themes, described below. The .token.* rules map Prism’s token types onto five colors: orange, blue, purple, gold and gray. Strings, numbers and attribute values stay in the plain code text color.
GitHub’s protanopia and deuteranopia themes (light_colorblind and dark_colorblind in Primer’s primitives) start from the default syntax colors and swap out exactly the ones red–green color-blind readers confuse:
| Token | GitHub dark | GitHub dark, color-blind | jedee dark |
|---|---|---|---|
keyword (if, return) | red #ff7b72 | orange #f0883e | purple, 270° |
| HTML tag | green #7ee787 | light blue #a5d6ff | gold, 45° |
| constant | blue #79c0ff | blue #79c0ff | gold |
| class name | purple #d2a8ff | purple #d2a8ff | orange |
| attribute name | blue #79c0ff | blue #79c0ff | blue, 205° |
The light themes follow the same pattern: keyword #cf222e becomes #bc4c00, and a regular expression #116329 becomes #0550ae. The rule to take from it is to keep red and green out of any pair that has to be told apart, and let blue and orange carry the difference. jedee took the rule rather than the colors. Its old pink, 350°, was the red GitHub removes, so it became gold. Its violet, 314°, leaned toward red and became a purple. The unused indigo was dropped. In light mode the gold is a dark brown, 35° at 16% lightness, which tells it apart from the orange by lightness. GitHub’s light theme does the same with its two oranges.
The choice was measured, not judged by eye. Each palette was run through Machado et al.'s 2009 simulation of full protanopia and deuteranopia, with translucent colors first composited over the code block background. Every pair of colors was then compared by distance in OKLab (×100, where about 2 is the smallest difference most people can see). jedee’s rows include the plain code text, because a token that looks like plain text is just as lost as two tokens that look alike:
| Palette | Weakest pair, protanopia | Weakest pair, deuteranopia |
|---|---|---|
| jedee dark, before the tint | blue / text 9.5 | blue / purple 9.4 |
| GitHub dark, color-blind | class name / constant 3.2 | class name / constant 2.3 |
| jedee light, before the tint | gold / text 8.7 | gold / gray 9.5 |
| GitHub light, color-blind | tag / class name 2.3 | tag / class name 2.4 |
On the page each color is then mixed with 20% of the site’s orange accent (--code-tint and --code-tint-amount), so code sits in the site’s palette rather than beside it. The mix pulls colors together, so the light theme’s base colors were retuned for it: a darker orange, a deeper brown gold, a slate blue and a lighter purple. Measured after the mix, the weakest pairs are 7.2 in dark mode (blue / purple, deuteranopia) and 7.6 in light (orange / gray, deuteranopia), and every color clears 5.3:1 against the code background. A 15% mix of the slate text color was tried first and measured about the same; the orange was chosen instead. ⚠️ Any change to either knob moves every pair, and a darker orange in light mode quickly merges with the gold, so re-measure the rendered colors after a change.
Before the rework, jedee’s weakest pairs were 5.8 in light mode and 6.0 in dark mode. Plain text was not in that check. Adding it showed that the old dark-mode blue, a near-white at 95% lightness, was 1.5 from the text color, so attribute names looked like their values to every reader. Every color now also clears 5:1 against the code block background in both themes, measured on the page. Two limits: the simulation models the most severe form of each condition, and a distance says nothing about which colors a reader with the condition finds comfortable. The check is kept, local-only, at _local/design/cvd-check.mjs; run it after any change to the palette.
⚠️ The light palette sits in a prefers-color-scheme: light query guarded by :root:not([data-theme='dark']). Without the guard, a reader whose device is in light mode and who switches the site to dark gets the light theme’s dark blue, brown and deep red on the dark code background. That was the case until 2026-10-06, and it first showed on an iPhone, where it read as washed-out code.
Inline code
Inline code never reaches Prism. A backtick span is a markdown-it code_inline token with no language, so the only thing that styles it is code.css. jedee sets it as a raised chip, after the inline code on arielsalminen.com: a surface a shade off the page, rounded corners, and a very faint shadow. Her four values, swapped for jedee tokens:
| Ariel | jedee |
|---|---|
background: var(--color-surface) | --color-bg-accent mixed halfway toward --color-bg, since the plain surface read too strong in both themes |
border-radius: var(--border-radius) | --border-radius-medium, the same radius as a code block |
box-shadow: var(--box-shadow-dimmed) | --box-shadow-chip, and -chip-dark on the dark theme, a new pair in shadows.json, smaller than popup |
padding: .188rem .5rem | --space-3xs --space-xs |
font-size: 75% | unchanged: code already sets --size-step-min-1, about 80% of body text |
:where(:not(pre)) > code {
--inline-code-bg: color-mix(in oklab, var(--color-bg-accent) 50%, var(--color-bg));
--inline-code-shadow: var(--box-shadow-chip);
padding: var(--space-3xs) var(--space-xs);
border-radius: var(--border-radius-medium);
background-color: var(--inline-code-bg);
box-shadow: var(--inline-code-shadow);
}The code text shares the prose baseline because nothing moves it: no vertical-align, no offset. Eleventy Excellent’s version of this rule adds position: relative; top: -0.05em, which lifts the chip, letters and all, off the baseline. That is an optical correction for EE’s own fonts, not a mistake. It arrived as -0.1em in January 2024 and was halved to -0.05em in February (commit 7ad2693), when EE set Figtree with Roboto Mono.
| Fonts | No nudge | -0.05em | -0.1em |
|---|---|---|---|
| Figtree + Roboto Mono (EE, 2024) | 0.038em | 0.002em | −0.042em |
Atkinson Hyperlegible + ui-monospace (EE today) | 0.071em | 0.032em | 0.008em |
| Source Sans + Source Code Pro (jedee) | 0.053em | 0.013em | −0.027em |
The February value centers EE’s 2024 pair almost exactly. EE has since changed fonts and kept the value, which leaves today’s pair 0.032em low; that row is SF Mono on macOS, since ui-monospace is a different font on other systems. In jedee’s fonts the chip sits 0.053em low without the nudge, about 1.5px at the largest body size. jedee keeps the letters on the baseline instead, a choice between two alignments rather than a fix: the nudge would move them 0.04em off the line the prose sits on. It was tried on 2026-09-15 and compared side by side (1.17px low without it at 24.9px text, 0.18px with it) and left out; the baseline won. The chip also stays display: inline. Measured, inline-block keeps the text on the baseline too, since an inline-block’s baseline is its last line of text; what changes is that the vertical padding then counts toward the line’s height (the test paragraph grew 0.84px), and a long span can no longer wrap onto the next line. The selector is EE’s, and so was the plain 0.1em 0.4em padding it replaces. The chip is jedee’s own.
Scroll cues
A code block too wide for its box scrolls sideways, and on a phone nothing says so: iOS hides the scrollbar until you touch the block. Since 2026-10-06 code blocks carry the same scroll cues as tables (Tables): a soft glow on each edge that has more code beyond it, gone once that edge is reached. It is Lea Verou’s background-attachment: local technique, four backgrounds on the <pre>: two radial-gradient glows pinned to the box’s edges (scroll), and two covers in --color-code-bg that move with the code (local) and slide over a glow when that end comes into view.
Two things differ from the table version:
- The glow is twice as strong.
--code-shadowmixes 40% of--color-text, the table’s--table-shadow20%. At 20% the glow vanished on the near-black dark-theme code background. At 40% it reads in both themes, and in the light theme it looks about like the table’s. - ⚠️ The
<code>inside the<pre>must have no background.code.cssgivescodeandprethe same background color, so the inner element painted a solid band over every line and hid the glow behind the text.pre code { background: none; }clears it.
The cue only shows when there is some way to scroll. A block that overflows by less than the 4em cover still has the cover sitting over most of its glow: on Vertical rhythm one block overflows by 21px at a 375px window and shows almost nothing. The blocks on this page, The interlinker's second render pass and The accessibility test overflow by hundreds of pixels and show it clearly.
Related: The interlinker's second render pass — the other thing in this pipeline that rewrites content mid-build, and a reminder that markdown plugin order is load-bearing. Wikilinks — the interlinker deliberately ignores wikilinks inside code blocks, the other place fenced content gets special treatment.
Raw source: src/_raw/dev-notes/How njk code blocks got syntax highlighting.md; src/_raw/dev-notes/How the inline-code nudge was measured.md (the nudge table, 2026-09-15)