# ADR: Code that reads like code

Status: accepted, 2026-09-15, shipped as 1.0.0.133.

## In plain words

This page decides how the code samples on the site's pages get coloured by meaning (syntax highlighting): one small library (highlight.js) carrying only the languages the pages use, drawn in the site's own palette, with a test holding every colour to a readable contrast.

What that is worth: a developer reads a sample the way an editor would show it, with keywords told apart from comments. The organization shows code worth reading on its public pages without slowing the inventory page, because the renderer arrives with the first document.

## The problem, measured before it was solved

Every document this site serves renders through `marked`, which hands a fenced block back as plain
text. The result is that a hundred and eighty C# samples across the records, the architecture pages and
the Best Practices section were the same color as the sentence above them, on a site whose whole
argument is that the code is worth reading.

The languages were counted rather than guessed, across all eighty-eight documents the catalog serves:
179 C# blocks (32 written by hand, 147 expanded from the build), 59 TypeScript and TSX, 16 YAML, 8
JSON, 7 CSS, 5 SQL, 3 Mermaid, 2 Bicep, and one each of Python, Dockerfile, TOML and HTML.

## What was considered

| Option | What it buys | What it costs |
| --- | --- | --- |
| **highlight.js, this site's palette** | Real grammars for every language above, a theme written in the site's own tokens, one runtime dependency | About 45 KB gzipped in the bundle, and a palette that has to be held to AA like the rest |
| A tokenizer written here | No dependency at all, total control | C# has verbatim strings, interpolation, generics and attributes; a wrong color on a public page reads worse than no color |
| Shiki | The same grammars a code editor uses, the best fidelity available | A WebAssembly engine and grammar files over a megabyte, or a build step that pre-renders every document, against documents that render at request time |
| Leave it plain | Nothing to maintain, no dependency, no palette risk | The code stays monochrome for every reader who opens a record on a phone |

**Decision: highlight.js, registered for the measured languages only, themed in the site's own tokens.**
The dependency is the third in the whole frontend, after React and `marked`.

## How it works

The grammars are imported one by one rather than as the whole library, so the bundle carries what the
documents use and nothing else:

```ts
/**
 * The name on a fence, to the grammar that reads it. The keys are what the
 * documents write and what the live-sample expander emits from a file
 * extension, so `ts`, `tsx` and `typescript` all have to land on the same
 * grammar rather than one of them quietly falling through to plain text.
 */
const LANGUAGES = {
  bash,
  csharp,
  css,
  dockerfile,
  ini,
  javascript,
  json,
  markdown,
  plaintext,
  powershell,
  python,
  sql,
  typescript,
  xml,
  yaml,
} as const;

const ALIASES: Record<string, keyof typeof LANGUAGES> = {
  'c#': 'csharp',
  cs: 'csharp',
  html: 'xml',
  js: 'javascript',
  mjs: 'javascript',
  md: 'markdown',
  py: 'python',
  ps1: 'powershell',
  sh: 'bash',
  shell: 'bash',
  text: 'plaintext',
  toml: 'ini',
  ts: 'typescript',
  tsx: 'typescript',
  yml: 'yaml',
};

let registered = false;

function register(): void {
  if (registered) return;
  for (const [name, grammar] of Object.entries(LANGUAGES)) {
    hljs.registerLanguage(name, grammar);
  }
  registered = true;
}
```
*Live from [`src/lib/highlight.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/highlight.ts#L35-L85), region `languages`, read from this build at 1d6b50b.*

A fence names a language, the name is resolved against the grammars this bundle holds, and anything
unresolved is escaped and rendered as text. Bicep and Mermaid are exactly that case today: the
expander still names them, no grammar reads them, and a reader sees plain code rather than an error.

```ts
/** The five characters that turn code into markup if they reach the page unescaped. */
export function escapeHtml(text: string): string {
  return text
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#39;');
}

/** The grammar a fence names, or null when nothing here reads that language. */
export function grammarFor(language: string | undefined): string | null {
  const name = (language ?? '').trim().toLowerCase();
  if (!name) return null;
  const resolved = ALIASES[name] ?? name;
  register();
  return hljs.getLanguage(resolved) ? resolved : null;
}

/**
 * The code as HTML: tokenized when the language is one of ours, escaped and
 * nothing else when it is not. `ignoreIllegals` matters because a record shows
 * a region of a file rather than a whole one, and a fragment that starts
 * mid-class is not a parse error worth a blank block.
 */
export function highlight(code: string, language: string | undefined): string {
  const grammar = grammarFor(language);
  if (!grammar) return escapeHtml(code);
  return hljs.highlight(code, { language: grammar, ignoreIllegals: true }).value;
}
```
*Live from [`src/lib/highlight.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/highlight.ts#L89-L118), region `highlight`, read from this build at 1d6b50b.*

`marked` is handed one renderer, which is the only place a fence becomes markup:

```ts
// Every fenced block in a served document goes through the highlighter
// (ADR: Code that reads like code). marked hands back the code and the name on
// the fence; the name is checked against the grammars this bundle carries
// before it reaches a class attribute, so a fence cannot write markup of its
// own, and the highlighter escapes everything it does not tokenize. A block is
// reachable from the keyboard, like a table's scroller, because a long line
// scrolls sideways and a reader without a mouse has to be able to scroll it.
marked.use({
  renderer: {
    code({ text, lang }) {
      const name = (lang ?? '').trim().split(/\s+/)[0];
      // A `swatches` fence is not code: it is a list of tokens, drawn as a
      // sheet of swatches from the token sheet itself (ADR-016, the addendum on
      // the style section).
      if (name === 'swatches') return swatchSheet(text, tokenSheet);
      // The Style section's tiles, readouts, glossary and ribbon strip (src/library/styleBlocks.ts).
      if ((STYLE_FENCES as readonly string[]).includes(name))
        return styleBlock(name as StyleFence, text);
      const grammar = grammarFor(name);
      const className = grammar ? `hljs language-${grammar}` : 'hljs';
      return `<pre tabindex="0"><code class="${className}">${highlight(text, name)}</code></pre>\n`;
    },
  },
});
```
*Live from [`src/lib/markdown.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/markdown.ts#L75-L98), region `code-renderer`, read from this build at 1d6b50b.*

The expander names the language from the file's name, and two of the files the records show had no
answer before this change: a Dockerfile has no extension and `.editorconfig` is nothing but one, so
both rendered as plain text. `LiveSamples.LanguageFor` now answers for those, for TOML, Python,
JavaScript, HTML and PowerShell, and a theory in `LiveSamplesTests` holds every one of those answers.

## The palette, and why it is measured

The theme is six colors from the Urban slate family rather than a stock light theme, because a stock
theme arrives from another design and sits next to this one badly.

| Token | Value | Contrast on the code ground |
| --- | --- | --- |
| `--color-code-text` | `#332f2c` | 11.90 |
| `--color-code-keyword` | `#6d4a83` | 6.37 |
| `--color-code-type` | `#1f5c8b` | 6.36 |
| `--color-code-string` | `#8a5a3c` | 5.22 |
| `--color-code-comment` | `#25663a` | 6.20 |
| `--color-code-number` | `#96363b` | 6.52 |
| `--color-code-meta` | `#6b5a1e` | 6.07 |

Code sits on `--color-surface-muted`, so that is the ground each one is measured against, and 4.5 is
the floor because a keyword is normal text at 0.9em. `tokens.test.ts` reads the real stylesheet and
holds every one of those ratios, and it holds a second thing a ratio cannot: that no two of the six are
the same value and none of them is the prose color, because six colors that all clear AA and cannot be
told apart is a theme that passed a test and failed a reader.

```css
code .hljs-keyword,
code .hljs-literal,
code .hljs-selector-tag,
code .hljs-section,
code .hljs-doctag,
code .hljs-template-tag {
  color: var(--color-code-keyword);
}

code .hljs-built_in,
code .hljs-type,
code .hljs-title,
code .hljs-class,
code .hljs-name,
code .hljs-tag {
  color: var(--color-code-type);
}

code .hljs-string,
code .hljs-regexp,
code .hljs-symbol,
code .hljs-bullet,
code .hljs-quote,
code .hljs-addition {
  color: var(--color-code-string);
}

code .hljs-number,
code .hljs-deletion {
  color: var(--color-code-number);
}

code .hljs-comment {
  color: var(--color-code-comment);
  font-style: italic;
}

/* Attributes and the preprocessor: [Fact] above a test, #region in a sample. */
code .hljs-meta,
code .hljs-meta .hljs-keyword,
code .hljs-meta .hljs-string {
  color: var(--color-code-meta);
}

/* The key half of a pair: a JSON property, a YAML key, an XML attribute. */
code .hljs-attr,
code .hljs-attribute,
code .hljs-property,
code .hljs-variable,
code .hljs-params,
code .hljs-template-variable {
  color: var(--color-code-type);
}

code .hljs-emphasis {
  font-style: italic;
}

code .hljs-strong {
  font-weight: var(--weight-semibold);
}
```
*Live from [`src/styles/code-highlight.css`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/styles/code-highlight.css#L17-L77), region `code-theme`, read from this build at 1d6b50b.*

## Addendum, the same afternoon: what the owner saw

The first version of this theme put comments in slate gray and left the code itself in the prose
colour. Read on the live page rather than in a swatch, both were wrong. Comments are green in every
editor this project's reader has ever used, and gray comments on a gray ground read as disabled text
rather than as commentary. The code itself needed to be darker than the prose, because it sits on
`--color-surface-muted` rather than on white and 6.4 against that ground reads thin at 0.9em.

So the palette gained `--color-code-text` at 11.9 against the code ground, comments moved to green,
numbers moved off green to brick so that two token types are not one colour, and attributes moved to
olive. The table above is the current set and `tokens.test.ts` holds every ratio in it, plus a floor
of 7 for the code colour itself. **The lesson, and it is the one this project keeps relearning: a
palette is read on the page it ships to, not in the file it is written in.**

## What this does not do

- Bicep and Mermaid have no grammar here. Both render as escaped text.
- Nothing is highlighted on the server. The documents are still served as markdown, and a reader who
  fetches `/api/docs/<slug>` gets the same markdown they always did.
- The diagram pages are SVG and are untouched.

## Where it sits

This sits almost entirely in the front end: src/lib/highlight.ts and src/lib/markdown.ts import nothing from React, and the host Api's only part is LiveSamples.LanguageFor naming a fence's language from a file name. Single responsibility, one reason to change, holds here because markdown.ts is the one file that imports marked and the highlighter, so a change of renderer touches one file and the dialog loads it on demand. It cost about 45 KB gzipped in its own chunk and a palette that tokens.test.ts must hold to AA contrast. If the documents were rendered at build time instead of request time, Shiki's editor-grade grammars would be worth their megabyte.

## Files

- [`src/lib/highlight.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/highlight.ts): the grammars, the aliases and the one function that turns code into HTML.
- [`src/lib/highlight.test.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/highlight.test.ts): what happens to a C# class, to a region that starts mid-file, and to a tag inside a sample.
- [`src/styles/code-highlight.css`](https://github.com/SteveStout/TheYard/blob/main/src/styles/code-highlight.css): the theme, in tokens.
- [`src/styles/colors.css`](https://github.com/SteveStout/TheYard/blob/main/src/styles/colors.css): the six colors.
- [`src/styles/colors.test.ts`](https://github.com/SteveStout/TheYard/blob/main/src/styles/colors.test.ts): the contrast floor and the told-apart rule.
- [`src/lib/markdown.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/markdown.ts): the renderer, loaded with the first document.
- [`src/lib/markdown.test.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/markdown.test.ts): the rendered shape of a fence and a link.
- [`src/library/DocDialog.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/library/DocDialog.tsx): the dialog, and the import that fetches the renderer on demand.
- [`api/TheYard.Api/LiveSamples.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/LiveSamples.cs): the language a file name implies.
- [`api/TheYard.Tests/LiveSamplesTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/LiveSamplesTests.cs): the theory that holds those answers.
- [`tests/e2e/practices.spec.ts`](https://github.com/SteveStout/TheYard/blob/main/tests/e2e/practices.spec.ts): the browser check that a keyword and a comment are their own elements on the live page.

## Addendum, 2026-09-17: the renderer arrives with the first document

Steve, 9/17: the fastest site at no extra cost. The measurement that opened
that work (perflane, `mentor\logs\lane0917-963-perflane-measure.log`) put the
whole frontend in one chunk, and `marked` and the highlighter with its
sixteen grammars were the largest things in it after React itself. Every
visitor downloaded and parsed the renderer on the inventory page, where no
document is ever rendered.

The two libraries now live in `src/lib/markdown.ts`, the one file that
imports either, and the documents dialog imports that file on demand the
first time a reader opens a record. Vite turns the dynamic import into a
chunk of its own, named by its hash like every other bundle file, so the
inventory page's script no longer carries the renderer and a reader who
opens a document fetches it once and keeps it for a year (ADR: Cache
headers). The renderer's two settings, the link hook and the code renderer
shown above, moved with it unchanged; the live block above reads them from
their new file. `markdown.test.ts` holds the rendered shape end to end: a
fenced block comes back highlighted under its grammar's class, a fence in a
language nobody here reads is escaped, and the links are rewritten the way
the dialog needs them.

The before and after are on the Performance page, measured on the live
site: the bytes the inventory page's script carried, and the bytes it
carries now, with the renderer's chunk beside it.

```tsx
// The document and the code that renders it are asked for together, not one
// after the other: the renderer (marked and the highlighter, the two
// heaviest things the frontend carries) is a chunk of its own that the
// inventory never needs (ADR: Code that reads like code, addendum), and
// fetched one after the other its request started only once the document's
// had finished, 775 ms in. Both now start at once, and the Author page's layout with them.
// The browser keeps each chunk for a year like every other hashed file, and
// the page fetches the renderer ahead when it is idle (src/lib/prefetch.ts).
Promise.all([
  fetch(DOCS[key].url).then(async (response) => {
    if (!response.ok) throw new Error(String(response.status));
    return response.text();
  }),
  import('../lib/markdown'),
  key === 'author' ? import('../lib/author') : Promise.resolve(null),
])
  .then(async ([markdown, { renderDocument }, author]) => {
    const rendered = await renderDocument(markdown);
```
*Live from [`src/library/DocDialog.tsx`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/library/DocDialog.tsx#L113-L130), region `renderer-on-demand`, read from this build at 1d6b50b.*

## Addendum, 2026-09-28 (1.0.3.34): code.css is code-highlight.css

The code theme is `src/styles/code-highlight.css`, named for what it does (ADR-016, the addendum on the four token files). Its rules are unchanged; the six code colours it draws in are in `colors.css`.

## Addendum, 2026-09-28 (1.0.3.35): the renderer and the Admin tab are fetched ahead

The renderer arriving with the first document had a cost the 17 September addendum did not measure: the order. Read on the live site on 28 September, opening a document fetched the markdown, and only when the markdown had arrived (775 ms in) did the renderer's chunk start (781 ms): a waterfall, the reader waiting on two trips in a row. The Admin tab and each of its cards have the same shape, a chunk fetched on the click that needs it.

- **The document and its renderer start together.** The dialog asks for the document, the renderer and, for the Author page, its layout in one `Promise.all`, so the two trips overlap rather than follow each other. `prefetch.spec.ts` holds the overlap: with the document held back half a second, the renderer's request has to begin before the document's response ends.
- **Fetched ahead, when the page is idle.** Once the first page has loaded and the browser is idle (`requestIdleCallback`, or a short timer where a browser has none), the page fetches the renderer and the Admin tab's chunk; once the Admin tab has mounted, it fetches every card's chunk in the rail the same way (`src/lib/prefetch.ts`). A reader whose browser asks to save data, or rates the connection as 2G, gets none of it, which `prefetch.test.ts` holds. The browser suite opens every page as such a reader (`tests/e2e/app.ts`), because the specs that hold a card fetched when it is opened, and a chunk a deploy replaced loading the page again, are about that very fetch; `prefetch.spec.ts` turns the prefetch back on and holds it. None of it is on the first page's critical path: it starts after the page's `load` event.
- **What a card waits on now.** With every card's chunk already in the browser, choosing a card waits on its first data read and not on its code, so the read is not started any earlier than the card's own `useRead` starts it: there is no second request for the same address to race the first.
