diff --git a/WIP.md b/WIP.md index 905ce53a..aab8363d 100644 --- a/WIP.md +++ b/WIP.md @@ -432,6 +432,16 @@ The site builds via [builder/](builder/), a custom Node.js static site generator The diff and verify harnesses (`_triage.mjs`, `_diff.mjs`, `_diff_all.mjs`, `_audit_accepted.mjs`, `_sitemap_diff.mjs`, `_spot.mjs`, `verify-phase{1..8}.mjs`, `accepted-divergences.mjs`) were retired in the Phase 10 cutover commit. Regression detection now relies on `scripts/check_links.mjs` (expanded into a site-integrity checker; see [docs/check.bat](docs/check.bat)). +### Phase 11 — parity update + +Phase 11 (in progress, see [builder/PLAN-11.md](builder/PLAN-11.md)) lands the output-changing follow-ups that the Phase 3-9 byte-vs-Jekyll discipline had deferred. Five independent PRs: + +- **B2 — Shiki theme generated from `.theme` source.** Shipped. `scripts/extract_theme_colors.py` and `builder/assets/css/rouge.css` are gone; `builder/highlight-theme.mjs` parses the vendored `builder/themes/Light.theme` + `Dark.theme` files and emits `_site/assets/css/tb-highlight.css` at build time. Per-span class names switched from Rouge tokens (`k`, `s`, `mi`) to a palette scheme (`c1`, `c2`, …). `builder/highlight.mjs` shrank from ~470 lines to ~190 — the per-language Rouge-quirk overrides folded into the scope-to-Symbol table. +- **B1 — Mermaid `.mmd` → `.svg` automation.** Shipped. `builder/mermaid.mjs` runs before Phase 1's discover, walks `docs/assets/images/mmd/*.mmd`, and invokes `mmdc` (via `npx --no-install` rooted at `builder/`) for any source whose `.svg` sibling is missing or older. The `.mmd` is now the canonical input; the SVG is a build artifact. Adds `@mermaid-js/mermaid-cli` as a devDependency in `builder/package.json`. The PDF render step already pulls in `puppeteer` at the repo root (and CI runs `npx puppeteer browsers install chrome --install-deps`), so `mermaid.mjs` reuses that cached Chrome via `PUPPETEER_EXECUTABLE_PATH` — no second Chrome download. A missing `mmdc` (e.g. someone never ran `npm install` in `builder/`) or a missing Chrome cache downgrades to a graceful warning; the existing on-disk SVG is retained and the build continues. +- **B5 — Server-side copy-code button.** Shipped. `builder/highlight.mjs` emits the ` +
...
+ + ``` + + The container shape matches what just-the-docs JS produces at + runtime; the JS hook for the click handler stays. + - `builder/assets/js/just-the-docs.js` retires the runtime DOM- + injection path (the `processCodeBlocks` function). The click + handler stays; the script is ~20 lines smaller. + +- **B10**: + - `_site/assets/js/search-data.json` is unaffected (Phase 6 + output still pretty-printed for direct fetch). + - `_site-offline/assets/js/search-data.js` is **minified**: + drops insignificant whitespace, ~2.8 MB → ~1.7 MB. The wrap + `window.SEARCH_DATA = {...};` stays intact. + +- **B11**: + - [offline.mjs](offline.mjs)'s `deriveOfflineJtdJs` function + drops the two regex patches and rewrites them as `acorn` AST + transformations. The patched `just-the-docs.js` output is + byte-comparable to the current regex output; the test for + success is `check.bat` clean + the offline tree's search still + works. + - One new dep: `acorn` (and possibly `acorn-walk`). + +--- + +## 3. Module split + +``` +builder/ + highlight.mjs -390 / +20. Drop SCOPE_TO_ROUGE_CLASS, + bestRougeClass, JS_BUILTINS, the + per-language quirk block at lines + 218-340, the CPP_LIKE_RE / CPP_TOKEN_RE + fallback. Keep the run-coalescing + (`append` / `flush` / `pendingNewlines`) + and the line-continuation absorption. + Emit color-palette classes via the new + highlight-theme.mjs interface. Also + injects the B5 copy-button container. + highlight-theme.mjs NEW ~60-80 lines. Parses .theme files, + builds the palette + class table, + returns the generated CSS string. + themes/ Vendored upstream .theme source files + (Light/Dark/Classic + upstream + README.txt). Landed alongside this + plan; see § 6.1 for the resolution + of the FUTURE-WORK §D3 investigation. + mermaid.mjs NEW ~80 lines. Pre-Phase-5 regen step + (B1). + offline.mjs -25 / +60. AST-based JTD patcher (B11); + minification step folded into + searchDataJs (B10). + template.mjs +1 / -1. Stylesheet link swap (B2). + book.mjs +1 / -1. Same swap for the PDF tree. + pdf.mjs +1 / -1. REQUIRED_CSS entry swap. + index.mjs +20. Orchestrator: invoke mermaid.mjs + before Phase 5; thread the highlight + theme through Phase 3's init. + assets/css/rouge.css DELETED. Replaced by the build-time + generated tb-highlight.css. + assets/js/just-the-docs.js -20 lines. Drop processCodeBlocks + (B5). + package.json +2 deps (@mermaid-js/mermaid-cli, acorn); + possibly +1 (acorn-walk). + PLAN.md Phase 11 → shipped; Build Phases table + updates. + PLAN-11.md (this file) + FUTURE-WORK.md B1, B2, B5, B10, B11 → shipped. + +scripts/ + extract_theme_colors.py DELETED (B2 commit). + themes/ DELETED (B2 commit -- regenerated + artifact, no longer needed). + check_links.mjs Possibly +5 lines: the duplicate-id + check (Phase 10 addition) may need to + allow-list the B5 copy-button SVG ids + if any collide. Inspect after B5 + lands. +``` + +The module count grows by two (`highlight-theme.mjs`, `mermaid.mjs`) +and loses one asset (`rouge.css`). Net `builder/` line delta across +all five PRs: estimated -300 lines (B2 dominates the reduction; +B1+B5+B11 add small ports of work the runtime currently did). + +--- + +## 4. Implementation order + +Per [FUTURE-WORK.md §D5](FUTURE-WORK.md), Phase 11 lands as **five +independent PRs**, not one combined cutover. B2 lands first +(largest change, sets the pattern); the other four are independent +and can land in any order. + +| PR | Item | Verifies by | +|---|---|---| +| 1 | B2 — Shiki theme from `.theme` source | `build.bat && check.bat` clean. Visual spot of a Reference/.md page: tB code block colours match the previous (Rouge-driven) palette within the rendered noise (the `.theme` source is the canonical truth on both sides; only the class-name layer changed). Generated `tb-highlight.css` exists and is referenced from the page ``. | +| 2 | B1 — Mermaid auto-regen | `build.bat` regenerates the SVG; second `build.bat` is a no-op (timestamp check). Manual: change the `.mmd` source by one character, rebuild, confirm the SVG byte-changed. Revert. | +| 3 | B5 — Copy-button SSR | `check.bat` clean (no new duplicate-ids, well-formed HTML). Manual: load a page, click the copy button, confirm it copies. Confirm `just-the-docs.js` shrunk. | +| 4 | B10 — Search-data minification | `check.bat` clean. Manual: load an offline page, run a search, confirm hits return correctly. Confirm `_site-offline/assets/js/search-data.js` is ~1.7 MB. | +| 5 | B11 — AST JTD patcher | `check.bat` clean. Manual: confirm offline-tree navigation still highlights the current page in the sidebar; confirm offline search still works. The patched `just-the-docs.js` output may differ byte-for-byte from the pre-B11 regex-patched output -- that's fine if both produce the same observable behaviour. | + +### PR policy + +One PR per row. Each PR contains: + +- The implementation commit(s) for the item. +- The PLAN-11 / FUTURE-WORK / PLAN.md updates marking that item + shipped. +- The cleanup commit(s) for any artifacts the item retires + (`extract_theme_colors.py` / `scripts/themes/` for B2; the + `processCodeBlocks` block in `just-the-docs.js` for B5; the + regex patches for B11). + +Each PR must produce a working build before merge. Hook +enforcement stays as Phase 10 set it: **no `--no-verify`**. + +The five PRs do not have a strict order beyond "B2 first". A +caller picking up Phase 11 mid-stream can land B1/B5/B10/B11 in +whatever order their reviewers prefer; the only cross-PR +dependency is between B2 and B5 (B5 modifies the same +`renderCodeBlock` function B2 rewrote, so doing them in reverse +order means rebasing B5 onto B2's reduced shape -- minor, but +worth noting). + +--- + +## 5. Per-item specifications + +### 5.1. B2 — Shiki theme generated from `.theme` source + +**Source**: [FUTURE-WORK.md §B2](FUTURE-WORK.md) (headline), §B2a +(output-mode investigation), §D1 / §D3 / §D4 (sequencing notes). +The Phase 9 → 10/11 split planning recorded the design intent; +this section turns it into a concrete commit sequence. + +**Background**. The current pipeline indirects twice between +upstream colour and rendered span: + +``` +.theme files (USERPROFILE/Desktop/...) + │ + │ scripts/extract_theme_colors.py + ▼ +docs/_sass/custom/_twinbasic-{light,dark}.scss + │ (then through the just-the-docs Sass pipeline) + ▼ +builder/assets/css/just-the-docs-combined.css ← shipped + AND +builder/assets/css/rouge.css ← shipped (Rouge classes) + │ + │ Dim ← class names emitted by + │ highlight.mjs's SCOPE_TO_ROUGE_CLASS + ▼ +rendered page +``` + +The Rouge-class layer existed because Rouge's class set is fixed +and `rouge.css` was the only stylesheet that styled them; matching +Rouge's per-language quirks (`nf` for tB functions but `nc` for +JS function-CALL identifiers containing an uppercase letter, etc.) +required the ~120 lines of overrides in +[highlight.mjs:218-340](highlight.mjs:218). Under Phase 11, the +indirection drops: + +``` +.theme files (location TBD per §6.1 investigation) + │ + │ builder/highlight-theme.mjs (parses Symbol* properties, + │ builds palette + class table) + ▼ +builder/_site/assets/css/tb-highlight.css ← generated at build time + │ (~1-2 KB; one rule per unique colour in the palette) + │ + │ Dim ← palette class + ▼ +rendered page +``` + +The class names are now colour-derived (`c1`, `c2`, …). The +Rouge per-language quirks no longer matter because the rendered +colour comes from the token's Shiki scope chain directly mapped +to a palette entry; whether Rouge would have called the same +token `k` or `kd` is irrelevant. + +**Commit sequence within PR 1** (the investigation step from +[FUTURE-WORK.md §D3](FUTURE-WORK.md) ran ahead of the PR -- the +three `.theme` files are vendored under +[`builder/themes/`](themes/), resolution recorded in §6.1): + +1. **highlight-theme.mjs port**. Implements the parser + palette + builder + CSS emitter. ~60-80 lines. Stand-alone testable: + reads `builder/themes/{Light,Dark,Classic}.theme`, returns + `{ palette, classForScope, cssText }`. The Rouge mapping in + `extract_theme_colors.py`'s `ROUGE_TO_SYMBOL` table is the + source-of-truth ordering for which Symbol → which palette + entry, but the palette emits colour-based class names not + Rouge tokens. + +2. **highlight.mjs rewrite**. Drops `SCOPE_TO_ROUGE_CLASS`, + `bestRougeClass`, `JS_BUILTINS`, all the per-language `if + (!isTb && ...)` blocks at lines 218-340, `CPP_LIKE_RE`, + `CPP_TOKEN_RE`, `IDENT_FALLBACK_RE`, `STORAGE_TYPE_BARE_RE`, + `NONTB_PUNCT_OPERATOR_RE`, `JS_BUILTINS`, `CONTAINER_TOP_LEVEL`, + `hasContainerParent`. Keeps `renderRougeStyleSpans` (renamed to + `renderThemedSpans`) since the run-coalescing + line-continuation + absorption + multi-line-comment merging logic stays useful + under the new class scheme. The `classForScope` lookup replaces + `bestRougeClass`. + +3. **Asset + template updates**. Delete + `builder/assets/css/rouge.css`. Add a Phase 3 substep that + writes `/assets/css/tb-highlight.css` (the generated + stylesheet from step 1). Update `template.mjs`'s `` + stylesheet list to swap the link target. Update + `book.mjs:538` and `pdf.mjs:35` similarly for the PDF tree. + +4. **Cleanup**. Delete `scripts/extract_theme_colors.py` and + `scripts/themes/twinbasic-{classic,dark,light}.css`. Delete + `docs/_sass/custom/_twinbasic-{light,dark}.scss` IF Phase 10 + commit 7 has already landed (the Jekyll source set deletion); + otherwise leave them for commit 7 to take. Update + `builder/assets/README.md` to drop the `rouge.css` row and add + a "Phase 11 reference" line pointing at this PLAN. + +**Verification** (per [FUTURE-WORK.md §D2](FUTURE-WORK.md)): no +verify harness. The acceptance gates are: + +- `build.bat` succeeds; the new `tb-highlight.css` lands in + `_site/assets/css/`. +- `check.bat` clean (all five integrity flags, both online and + offline trees). +- Visual spot of a tB code block: colours match the previous + Rouge-driven render within rendering-noise tolerance. The + source of truth on both sides is the same `.theme` data; only + the class-name layer changed. A pixel-level diff is not + required. +- The page `` references `tb-highlight.css`, not + `rouge.css`. `_site/assets/css/rouge.css` is absent. + +**Light / dark mode coordination**. The current Jekyll-built +just-the-docs-combined.css carries `.language-tb .highlight .X` +rules inside `html.dark-mode { ... }`. Under Phase 11 the +generated `tb-highlight.css` is emitted in two passes -- the +light palette at root, the dark palette under `html.dark-mode` +-- so the dark-mode toggle continues to flip the syntax highlight +in lockstep with the rest of the chrome. See [§6.3](#63-light--dark-coordination). + +### 5.2. B1 — Mermaid `.mmd` → `.svg` automation + +**Source**: [FUTURE-WORK.md §B1](FUTURE-WORK.md), [PLAN-3 §15](PLAN-3.md). + +**Background**. The site currently has one mermaid diagram: +[docs/assets/images/mmd/2eff33e25804bc6ae919f694439dccbf.mmd](../docs/assets/images/mmd/2eff33e25804bc6ae919f694439dccbf.mmd) +with a hand-exported SVG sibling. The hand-export is what Phase 5 +copies; the `.mmd` source is editorial-only. Under Phase 11 the +SVG becomes a build artifact regenerated from the source whenever +the source is newer (or the SVG is missing). + +**New module** `builder/mermaid.mjs` (~80 lines): + +```js +// Phase 11 mermaid preprocessor: regenerates assets/images/mmd/*.svg +// from the matching *.mmd source when the SVG is missing or stale. +// Pre-Phase-5 step; the SVGs are copied verbatim by write.mjs once +// they exist. +// +// Idempotent: a second build with no source changes is a no-op +// (mtime check). Requires @mermaid-js/mermaid-cli at devDependency +// scope; the binary is invoked via `npx --no-install mmdc ...`. +// +// On the current tree (one source) the cold-cache regen is ~200 ms; +// the warm-cache check is sub-millisecond. + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import fg from "fast-glob"; +import { spawn } from "node:child_process"; + +export async function regenerateMermaid(srcRoot) { + const mmdRoot = path.join(srcRoot, "assets/images/mmd"); + const sources = await fg("*.mmd", { cwd: mmdRoot, absolute: true }); + for (const src of sources) { + const svg = src.replace(/\.mmd$/, ".svg"); + if (await upToDate(svg, src)) continue; + await invokeMmdc(src, svg); + } +} +``` + +**Wiring**: + +- Orchestrator calls `await regenerateMermaid(srcRoot)` once, + immediately before Phase 5's WRITE ONLINE. The output SVGs + show up in Phase 1's discovered `staticFiles[]` only on the + *next* build -- on the regen build they need a one-shot + re-scan after `regenerateMermaid` returns. Cleanest: invoke + the regen step from the *top* of the orchestrator (before + Phase 1's discover), so Phase 1 picks up the freshly-emitted + SVGs. + +**Dependency**: + +- `@mermaid-js/mermaid-cli` as a devDependency. Invoked via + `npx --no-install mmdc -i -o -b transparent`. The + binary is ~250 MB installed (puppeteer / chromium); justify the + install cost only when at least one `.mmd` source exists -- + early-out the regen call when `sources.length === 0`. + +**Verification**: no harness; `build.bat && check.bat` clean. +Manual: edit the existing source by adding a node, rebuild, +confirm the SVG byte-changed; revert. + +**Edge cases**: + +- Source newer than SVG but SVG hand-edited: the regen + overwrites. Document in the WIP.md update: SVGs under + `assets/images/mmd/` are build artifacts; edit the `.mmd` + source, not the SVG. +- `mmdc` not installed: the spawn fails; the orchestrator + catches and logs `mermaid: skipped (mmdc not installed; run + npm install in builder/)`. The build continues using the + existing on-disk SVG. Caller can opt out of B1 entirely by + declining to install the devDependency. +- CI: the GitHub Actions workflow needs `npm ci` to include + devDependencies (it does by default with `actions/setup-node@v4`). + No extra setup required. + +### 5.3. B5 — Server-side copy-code button + +**Source**: [FUTURE-WORK.md §B5](FUTURE-WORK.md), [PLAN-3 §15](PLAN-3.md). + +**Current**: every `
` block ships unwrapped; the just-the-docs
+client JS (`processCodeBlocks` in `just-the-docs.js`) walks the DOM
+on `load`, finds each `
`, wraps it in a `
`, +and prepends a ` +
+
...
+
+
+``` + +The `` references the inline SVG +sprite that `template.mjs`'s `svgSprites` already injects at the +top of ``. Both `bi-clipboard` and `bi-clipboard-check-fill` +need to be in the sprite set; check the current set and add the +fill-variant if it's not there (the JS swaps the icon on click). + +**Client-side change**: + +- `just-the-docs.js`: delete `processCodeBlocks` (the DOM-injection + function). Keep the click handler that swaps the icon + copies + to clipboard; rebind it to the now-prebuilt button selector. +- Net JS reduction: ~20 lines. + +**Verification**: + +- `check.bat` clean (no new duplicate-ids from the per-page + buttons — each `
` gets its own button with no shared
+  `id`; the click handler selects via class, not id).
+- Manual: load a page, click the button, confirm the clipboard
+  receives the code. Confirm the icon swaps to the check-mark
+  on success.
+- Manual: disable JS, reload, confirm the button is visible
+  (server-side render) but inert. Acceptable degradation.
+
+**Risk**: the just-the-docs runtime may target the button via a
+selector that depended on its DOM position. Inspect the click
+handler before deleting `processCodeBlocks` and re-bind to the
+pre-rendered shape. The JS hook is preserved; only the injection
+path retires.
+
+### 5.4. B10 — `search-data.js` minification
+
+**Source**: [FUTURE-WORK.md §B10](FUTURE-WORK.md), [PLAN-7 §13](PLAN-7.md).
+
+**Current** ([offline.mjs](offline.mjs)'s `searchDataJs` step):
+generates `_site-offline/assets/js/search-data.js` by wrapping the
+pretty-printed `search-data.json` content in
+`window.SEARCH_DATA = {...};`. The wrapper preserves the source
+JSON's whitespace; the file is ~2.8 MB.
+
+**Change**: re-stringify the parsed JSON with `JSON.stringify(data)`
+(no indent) before wrapping. Estimated size after minification:
+~1.7 MB.
+
+**Implementation**:
+
+```js
+// offline.mjs, inside the searchDataJs step:
+const jsonText = await fs.readFile(jsonPath, "utf8");
+const parsed = JSON.parse(jsonText);
+const minified = JSON.stringify(parsed);                  // no indent
+const wrapped = `window.SEARCH_DATA = ${minified};\n`;
+await fs.writeFile(jsWrapperPath, wrapped, "utf8");
+```
+
+The online tree's `search-data.json` stays pretty-printed (Phase
+6 unchanged); the offline tree's `search-data.js` is the new
+minification target. The Lunr consumer (`initSearch` in
+`just-the-docs.js`) reads `window.SEARCH_DATA` and doesn't care
+about formatting.
+
+**Verification**:
+
+- `check.bat` clean.
+- Manual: load `_site-offline/index.html`, type a search query,
+  confirm hits return.
+- `wc -c _site-offline/assets/js/search-data.js` shows ~1.7 MB
+  (vs ~2.8 MB before).
+
+**Note on PLAN-7 §13's wider concern**: the original FUTURE-WORK
+entry mentioned `_site-offline.zip` size pressure. There's no
+`_site-offline.zip` artifact on the current build (the offline
+tree is consumed directly by users who clone or download the
+site). The minification is still worthwhile -- ~1.1 MB shaved
+off the offline tree's footprint -- but the "zip size" framing
+in FUTURE-WORK is a forward-looking concern, not a current one.
+
+### 5.5. B11 — AST-based `just-the-docs.js` patching
+
+**Source**: [FUTURE-WORK.md §B11](FUTURE-WORK.md), [PLAN-7 §13](PLAN-7.md).
+
+**Current** ([offline.mjs](offline.mjs)'s `deriveOfflineJtdJs`):
+two regex patches against the bundled `just-the-docs.js`:
+
+1. `navLink()` body — replaces the upstream `document.location.pathname`
+   active-page test with a resolved `link.href` test (so the
+   sidebar's active-class lookup works under `file://`).
+2. `initSearch()` body — replaces the upstream
+   `XMLHttpRequest('search-data.json')` fetch with a read of
+   `window.SEARCH_DATA` (XHR to `file://` is browser-blocked).
+
+The regexes anchor on specific function signatures inside the
+upstream source. A cosmetic upstream edit (variable rename,
+whitespace change, etc.) breaks the regex match and the patch
+silently no-ops -- `deriveOfflineJtdJs` returns warning lines but
+the build continues.
+
+**Change**: rewrite the patches as `acorn` AST rewrites. Parse
+`just-the-docs.js` once; walk the AST; locate the `navLink` and
+`initSearch` function declarations by name; replace the relevant
+body fragments; serialise the modified AST back to JS. Survives
+upstream cosmetic edits because the AST walker matches structure,
+not text.
+
+**Dependency**: `acorn` (production dep). Possibly `acorn-walk`
+for the walker convenience. Both small (~150 KB combined).
+
+**Implementation sketch**:
+
+```js
+import * as acorn from "acorn";
+import * as walk from "acorn-walk";
+// Optional: a serialiser like astring, OR build the output by
+// in-place string slicing using the AST node's range info (start
+// / end offsets in the source). The string-slicing approach
+// produces a smaller diff vs upstream because non-patched regions
+// stay byte-identical.
+
+function patchJtdAst(source) {
+  const ast = acorn.parse(source, { ecmaVersion: 2022, ranges: true });
+  const edits = [];   // [{ start, end, replacement }, ...]
+  walk.simple(ast, {
+    FunctionDeclaration(node) {
+      if (node.id.name === "navLink") {
+        edits.push(replaceNavLinkBody(node, source));
+      } else if (node.id.name === "initSearch") {
+        edits.push(replaceInitSearchBody(node, source));
+      }
+    },
+  });
+  return applyEdits(source, edits);
+}
+```
+
+**The two replacements** stay the same shape as the current regex
+versions; only the locator changes from regex to AST. The
+specific body fragments are the existing `OFFLINE_NAV_LINK` /
+`OFFLINE_INIT_SEARCH` constants in `offline.mjs`. The serialiser
+should preserve upstream byte-formatting outside the patched
+regions, which the string-slice approach does naturally.
+
+**Verification**:
+
+- `check.bat` clean.
+- Manual: load `_site-offline/index.html`, click around the
+  sidebar; confirm the current page highlights. Type a search;
+  confirm results return. Both behaviours regression-test the
+  two patches.
+- Byte-diff the new patched `just-the-docs.js` against the
+  pre-Phase-11 regex-patched version. Acceptable shapes:
+  byte-identical (the string-slice approach preserved all
+  non-patched bytes), or differences confined to whitespace /
+  trailing-newline normalisation inside the patched regions.
+  Anything beyond that needs investigation -- the AST walker
+  may have rewritten more than intended.
+
+**Risk**: an `acorn` parse failure on the upstream
+`just-the-docs.js` (e.g. a future just-the-docs release that uses
+syntax acorn hasn't caught up to). Mitigation: `ecmaVersion: "latest"`
+so acorn accepts everything it can parse; the build fails loudly
+on parse error. No regex fallback is shipped -- per [§7.D13](#71-decision-record),
+`just-the-docs.js` is a vendored asset re-extracted only on
+deliberate gem-bump operations, so a parse failure at build time
+is a clear signal to fix the asset (or the AST patcher's expectations)
+at the moment of the bump.
+
+**Note**: the upstream just-the-docs version is pinned via
+`docs/Gemfile` (`gem "just-the-docs", "= 0.10.1"`). Under Phase 11
+post-cutover, the Gemfile is no longer the source of truth -- the
+captured-once asset in `builder/assets/js/just-the-docs.js` is.
+A bump to a new just-the-docs version is an explicit re-capture
+step (see `builder/assets/README.md`); the AST patcher reduces
+the manual effort each bump requires.
+
+---
+
+## 6. B2 design notes
+
+(Expanded notes for §5.1. Skip this section if you're not
+implementing B2.)
+
+### 6.1. Where the `.theme` source files live (resolved)
+
+**Resolution**: vendored in repo under
+[`builder/themes/`](themes/). The three files
+(`Light.theme`, `Dark.theme`, `Classic.theme`) plus the upstream
+`README.txt` were copied verbatim from the twinBASIC IDE BETA's
+themes directory and committed alongside this plan, ahead of B2's
+PR proper. The pre-Phase-11 `scripts/extract_theme_colors.py`
+read them from `%USERPROFILE%/Desktop/twinBASIC_IDE_BETA_982/themes/`
+-- a local-only path that couldn't survive the move to CI. The
+vendor-in-repo shape replaces it; B2's `highlight-theme.mjs`
+reads from `builder/themes/` directly.
+
+This matches the convention `builder/assets/README.md` already
+established for the just-the-docs / Rouge / Lunr assets: the
+canonical source is upstream, the working copy lives in tree
+alongside a documented refresh procedure. For the themes the
+upstream's own `README.txt` (vendored alongside) carries the
+"do not modify" guidance.
+
+**Refresh procedure** (when a new twinBASIC IDE BETA changes the
+syntax-highlight palette):
+
+1. Locate the new build's `themes/` directory (today: under the
+   IDE installer's footprint).
+2. Diff the new `Light.theme` / `Dark.theme` / `Classic.theme`
+   against `builder/themes/`. If no `Symbol*` properties moved,
+   carry the diff over verbatim. If renamed or new `Symbol*`
+   entries appear, update `highlight-theme.mjs`'s `ROUGE_TO_SYMBOL`-
+   equivalent table (the scope → Symbol mapping kept from the
+   retired `extract_theme_colors.py`) to cover them, then carry
+   the diff over.
+3. Run `cd docs && build.bat && check.bat`. Spot a tB code block
+   to confirm the new palette renders. Commit `builder/themes/`
+   + any `highlight-theme.mjs` table changes in one commit.
+
+**Open notes carried over from the investigation**:
+
+- **File-format invariant.** The vendored files exhibit the
+  property-line `Name: value;` shape and the `Symbol*` grouping
+  the retired Python parser depended on. The B2 parser stays
+  forward-compatible (silently skip unknown `Symbol*`
+  properties) rather than hard-failing on novel entries -- a
+  future IDE adding a new symbol shouldn't break the build
+  before the docs caller catches up. The flip-side bound (an
+  *expected* `Symbol` going missing) is caught by the test
+  suite's spot of one rule per palette class in the rendered
+  CSS output.
+- **Licence.** The vendored upstream `README.txt` carries no
+  explicit licence statement, but the twinBASIC IDE BETA ships
+  the same three files in its public installer; vendoring the
+  files to support documentation that links to the IDE itself
+  is consistent with their distribution intent. If the upstream
+  ever publishes a more restrictive licence, the fallback is
+  fetch-at-build-time (a small step in `index.mjs` that pulls
+  the release artifact instead of reading from `builder/themes/`).
+- **Version recording.** The vendored copy was taken from the
+  twinBASIC_IDE_BETA_982 build. When refreshing, prepend a
+  comment to `builder/themes/Light.theme` (or similar) noting
+  the source version. The IDE's own theme files don't carry a
+  version stamp internally; tracking it in a comment is the
+  least-invasive shape.
+
+### 6.2. Palette and class-name scheme
+
+**Goal**: minimise per-span HTML bytes while keeping the
+stylesheet small and the class names stable across builds.
+
+**Approach** (per [FUTURE-WORK.md §B2a](FUTURE-WORK.md)
+output-mode investigation): custom-transformer Shiki output with
+class names derived from unique theme colours.
+
+1. **Parse** all three theme files (Light, Dark, Classic) into
+   `{ symbol -> properties }` maps.
+2. **Collect** the set of unique `Color` values across Light and
+   Dark (the two palettes that drive runtime; Classic is light-
+   inspection-only). Each unique colour gets a stable two-char
+   class ID:
+   - Sort the unique colours deterministically (e.g. alphabetical
+     by hex value).
+   - Assign `c1`, `c2`, … in sort order. Stable across builds
+     because the sort key is deterministic.
+3. **Build** the `Symbol -> classId` map for each palette
+   (Light, Dark). Symbols whose `Color` is the same fold to the
+   same `classId` (which is what we want -- the source already
+   identifies same-coloured symbols as the same visual category).
+4. **Build** the `scope -> classId` table the renderer reads.
+   The current `ROUGE_TO_SYMBOL` table in
+   `scripts/extract_theme_colors.py` encodes which Symbol owns
+   which scope; carry that table over verbatim (~25 entries).
+   The renderer's lookup becomes: "scope → Symbol → classId".
+5. **Emit** the stylesheet:
+
+   ```css
+   /* Light palette (root) */
+   .c1 { color: #448a63; font-style: normal; ... }   /* SymbolComment */
+   .c2 { color: #ad8c98; ... }                       /* SymbolConditionalCompilationDirective */
+   .c3 { color: #385ba9; ... }                       /* SymbolKeyword */
+   ...
+
+   /* Dark palette (under html.dark-mode) */
+   html.dark-mode .c1 { color: #6ab886; ... }
+   html.dark-mode .c2 { color: #c7a4b1; ... }
+   ...
+   ```
+
+**Estimated size**: ~25 palette entries × ~40 bytes per rule × 2
+palettes ≈ 2 KB. The current `rouge.css` is 2.3 KB; net wash.
+
+**Why class names not inline styles**: per
+[FUTURE-WORK.md §B2a](FUTURE-WORK.md), inline styles add
+~31 bytes per span vs ~22 for class wrappers (``).
+The 837-page corpus has ~50 K syntax-highlighted spans; the per-
+span overhead matters at scale. The class-based approach also
+keeps the dark-mode toggle working (inline styles would need CSS-
+variables-with-fallback, which ``
+shape doubles per-span overhead -- the worst option).
+
+### 6.3. Light / dark coordination
+
+**Current** (Jekyll-built `just-the-docs-combined.css`): the dark
+palette lives inside `html.dark-mode { ... }`. The `theme-switch.js`
+toggle flips that class on `` and every dark rule activates.
+
+**Under Phase 11**: same pattern. The generated `tb-highlight.css`
+emits the light palette as root rules and the dark palette inside
+`html.dark-mode`. The toggle JS is unchanged; the dark-mode flip
+continues to work in lockstep with the rest of the chrome.
+
+For the PDF tree: `_site-pdf/book.html` ships with the print
+stylesheet only (no dark mode). The PDF version of `tb-highlight.css`
+strips the `html.dark-mode` rules (or the writer chooses not to
+emit them for the PDF tree). Minor: ~50 bytes saved in the PDF
+artifact.
+
+### 6.4. The line-continuation / multi-line-comment carve-outs
+
+**Keep**. The current
+[highlight.mjs](highlight.mjs):130-380 logic that absorbs the
+leading whitespace of the line after `_` into the same `lc` span,
+and that merges adjacent block-comment spans across newlines into
+one multi-line span, both reflect the visual intent of the
+rendered code -- a multi-line comment looks better as one
+contiguous coloured block than as N per-line fragments separated
+by uncoloured newlines. Phase 11 keeps the run-coalescing machinery
+even though the Rouge-class-name shape (`lc`, `cm`) is gone; the
+new palette-class-name shape preserves the same merge invariants
+(adjacent runs with the same class merge; line-continuation
+absorbs the next line's leading whitespace).
+
+The carve-outs are about **rendered visual grouping**, not about
+**class-name semantics**, and they survive the class-naming
+change.
+
+### 6.5. Edge cases
+
+- **Empty / unrecognised Symbol** in the theme file. Fall back
+  to the parent scope or to `c0` (the default text colour --
+  derived from the theme's `TextColor` or equivalent). The
+  current Python script omits empty values; the JS port should
+  do the same.
+- **A code fence with an unknown language**. The current
+  highlight.mjs's `wrapperLang = lang ? lang.trim().toLowerCase() : "plaintext"`
+  shape stays. Tokens get `null` class (no `` wrap); they
+  inherit the default text colour from the `.highlight` rule.
+  No regression vs the current shape.
+- **A theme file that's missing entirely** (the location TBD per
+  §6.1 hasn't resolved at build time). Hard-fail with a clear
+  message; the build can't produce a syntax-highlight stylesheet
+  without it. No silent fall-back to a generic palette -- if the
+  upstream goes missing, the operator should know.
+
+---
+
+## 7. Design decisions and assumptions
+
+### 7.1. Decision record
+
+| ID | Decision | Why |
+|---|---|---|
+| D1 | Phase 11 lands as **five independent PRs**, not one combined cutover | Per [FUTURE-WORK.md §D5](FUTURE-WORK.md). B2 is large enough to deserve its own review window. The smaller items (B1, B5, B10, B11) are clean independent commits whose per-item revert path stays simple when bundled separately. |
+| D2 | `scripts/extract_theme_colors.py` and `scripts/themes/*.css` delete in **B2's PR**, not as a separate cleanup commit | Per [FUTURE-WORK.md §D4](FUTURE-WORK.md). The script exists only to feed the Rouge-class indirection in highlight.mjs that B2 retires; without B2's `SCOPE_TO_ROUGE_CLASS` consumer it has no caller. Same commit, same revert boundary. |
+| D3 | The `.theme` source files are **vendored under [`builder/themes/`](themes/)** rather than fetched at build time | The investigation step from [FUTURE-WORK.md §D3](FUTURE-WORK.md) resolved to "vendor in repo" (option 2.a in the original question set) before B2's PR opens. The vendored shape matches the convention `builder/assets/README.md` established for the just-the-docs / Rouge / Lunr assets, keeps the build deterministic (no network call), and survives a future restrictive-licence fall-back to fetch-at-build-time without a re-plan. §6.1 records the resolution and refresh procedure. |
+| D4 | **No Phase 11 verify harness** | Per [FUTURE-WORK.md §D2](FUTURE-WORK.md). Phase 10's expanded `check_links.mjs` (HTML well-formedness, duplicate-id, anchor resolution, sitemap / search completeness) is the regression detector. Adding a per-PR harness would duplicate effort; manual smoke-tests + `check.bat` are sufficient at this scale. |
+| D5 | B2 generates **palette-class names** (`c1`, `c2`, …), not inline styles or CSS variables | Per [FUTURE-WORK.md §B2a](FUTURE-WORK.md). Inline styles add ~31 bytes per span vs ~22 for class wrappers; CSS variables add ~60+. The 837-page corpus has ~50 K syntax-highlighted spans -- per-span overhead is the dominant cost. |
+| D6 | B2 generates the stylesheet **at build time** under `_site/assets/css/tb-highlight.css`, not vendored as a pre-extracted artifact | The build-time path keeps the upstream `.theme` data as the single source of truth: edit the `.theme` source, rebuild, the new colours flow through. A vendored stylesheet would require a manual two-step refresh (edit `.theme`, re-run an extractor) that's exactly what B2 is trying to retire. |
+| D7 | B1's mermaid regen runs **before Phase 1**, so the generated SVGs land in `staticFiles[]` naturally | The alternative (regen between Phase 1 and Phase 5) requires re-scanning the static-files set after regen. Running the preprocessor before discover folds the new SVGs into Phase 1's normal sweep. |
+| D8 | B1 depends on `@mermaid-js/mermaid-cli` as a **devDependency**, invoked via `npx --no-install mmdc` | Build-time only; runtime users don't need it. The `--no-install` flag avoids npx's silent auto-install behaviour. A missing `mmdc` is a hard fail with a clear message; the operator opts in by running `npm install` in `builder/`. |
+| D9 | B5's copy-button container is rendered by `highlight.mjs`, not by `template.mjs` | The container wraps individual code blocks, not the whole page. The natural home is the function that emits the code block. `template.mjs` stays page-level. |
+| D10 | B5 keeps the just-the-docs click-handler intact; only the DOM-injection path retires | The click-to-copy interaction is unchanged; only the moment of button creation moves from runtime to build-time. Minimal surface area to debug if a regression shows up. |
+| D11 | B10 minifies only the **offline** `search-data.js`, not the **online** `search-data.json` | The online JSON is fetched by Lunr at runtime and could-be-minified, but two consumer paths use it (the search runtime and any human-readable use-case for inspecting search hits). The offline `.js` is a write-once wrapper for `window.SEARCH_DATA` and has no other consumer; minification there is risk-free. |
+| D12 | B11's AST rewrites use **string-slice serialisation** (preserve upstream byte-formatting outside patched regions) rather than full re-serialisation via astring or similar | Keeps the diff vs upstream small and reviewable. A full re-serialisation would normalise whitespace site-wide and inflate the diff to "every line changed", obscuring whether the patch did anything beyond the two intended sites. |
+| D13 | B11 drops the regex patcher entirely; only the AST patcher ships | `just-the-docs.js` is a vendored asset re-extracted only on deliberate gem-bump operations, not silently. The "defence in depth" the original plan envisioned protects against an upstream churn pattern that doesn't apply here. A parse error at build time is a clear signal to fix the re-extracted asset (or the AST patcher's expectations) at the moment of the bump -- no second code path to maintain. |
+| D14 | Phase 11 does **not** touch `docs/_config.yml` | Same as Phase 10's [§7.D8](PLAN-10.md). The remaining Jekyll-only config keys are harmless ballast; cleaning them up is a separate task once the Phase 10 follow-up commit lands. |
+| D15 | `docs/_sass/custom/_twinbasic-{light,dark}.scss` deletes **in B2's PR if Phase 10 commit 7 has landed**, otherwise it stays for commit 7 to take | Avoids the bookkeeping question of "who owns these files now"; B2 retires their generator (`extract_theme_colors.py`), commit 7 retires the Jekyll source set including these SCSS partials. Whichever lands first claims the deletion. |
+
+### 7.2. Why no Phase 11 verify harness
+
+Phase 10 expanded `scripts/check_links.mjs` with five integrity
+check categories:
+
+- `--check-html` (HTML well-formedness)
+- `--check-a11y` (accessibility basics: missing alt, empty links)
+- `--check-ids` (duplicate `id="..."`)
+- `--check-sitemap` (sitemap.xml completeness)
+- `--check-search` (search-data.json completeness)
+
+`check.bat` runs all five against both `_site/` and
+`_site-offline/`. Every Phase 11 change is covered:
+
+- **B2**: changes per-span class names. The class-attribute
+  syntax stays well-formed; no new ids; the stylesheet link
+  resolves; the sitemap and search index are unaffected.
+  `--check-html` / `--check-a11y` / `--check-ids` catch any
+  malformed output.
+- **B1**: generates SVG files. The SVG well-formedness is
+  outside the link checker's scope, but the references to them
+  in HTML are checked (existing link-check).
+- **B5**: adds buttons and a container around code blocks.
+  `--check-html` catches mismatched-tag bugs; `--check-a11y`
+  catches the `aria-label` if it's missing (the spec calls for
+  one).
+- **B10**: changes a JS file's formatting. The link checker
+  doesn't parse JS; manual smoke (offline search works) is
+  the regression detector.
+- **B11**: same as B10 -- a JS-formatting change. Manual smoke
+  (offline navigation highlight + offline search) is the
+  detector.
+
+The Phase 11 acceptance bar is `check.bat` clean + the per-item
+manual smoke from §5.1-§5.5. No per-PR harness needed.
+
+### 7.3. Scope guardrails
+
+The line between Phase 11 and out-of-scope items is the criterion
+stated in [§intro](#plan-11-phase-11--parity-update-output-changing-follow-ups):
+the change is one of the five items routed to Phase 11 in
+[FUTURE-WORK.md §B](FUTURE-WORK.md). Implementer test for "is
+this Phase 11 or out of scope?":
+
+1. Check FUTURE-WORK.md §B for the candidate. If it's routed to
+   Phase 11 (B1, B2, B5, B10, B11) → Phase 11.
+2. If it's routed to "drop" (B6, B18) → out of scope; don't
+   reopen.
+3. If it's not in FUTURE-WORK.md at all → file a new entry
+   first; routing decision comes before implementation.
+
+The five items aren't a frozen set -- a Phase 12 (or a later
+Phase 11.5) can be filed if new output-changing follow-ups
+emerge. But mid-Phase-11 scope creep is the failure mode this
+guardrail prevents.
+
+---
+
+## 8. What's NOT in Phase 11
+
+These belong to Phase 12+ or are out of scope.
+
+### 8.1. Out of scope by routing
+
+- **B6 Linkify exception list** — dropped to "Re-add the entry
+  if a content shift makes bare URLs common in body prose"
+  ([FUTURE-WORK.md §B6](FUTURE-WORK.md)). Do not reopen.
+- **B18 Streaming write of `book.html`** — dropped because the
+  current scale (~5 MB) is two orders of magnitude below the
+  problem threshold ([FUTURE-WORK.md §B18](FUTURE-WORK.md)).
+  Do not reopen.
+
+### 8.2. Out of scope by topic
+
+- **Watch-mode / incremental rebuild.** Same as Phase 10's
+  [§7.D11](PLAN-10.md). Out of scope for Phase 11; if it ever
+  lands, it's a phase of its own.
+- **`docs/_config.yml` config-key cleanup.** The remaining
+  Jekyll-only keys are harmless ballast (see Phase 10 §5.8). If
+  Phase 10's follow-up commit (the Jekyll source set deletion)
+  hasn't landed when Phase 11 finishes, the config-cleanup pass
+  is still owed to that commit, not to Phase 11.
+- **The `one-offs/` directory.** Phase 9 §8.4 and Phase 10 §8.2
+  both ruled this out of scope; Phase 11 doesn't reopen it.
+- **A new build phase.** Phase 11 is feature work landing in the
+  existing modules; the eight-phase orchestrator shape doesn't
+  change. B1's mermaid step is a pre-phase preprocessor, not a
+  new phase.
+
+### 8.3. Things Phase 11 is **not** allowed to slip in
+
+- **`builder/assets/` re-extraction** triggered by a `just-the-docs`
+  gem version bump. The version is pinned at 0.10.1 via
+  `docs/Gemfile`; bumps are a separate manual procedure (see
+  `builder/assets/README.md`). B11 makes future bumps easier to
+  re-patch but doesn't perform a bump itself.
+- **markdown-it plugin updates.** Phase 3's plugin stack is
+  stable; any update risks per-page output drift that the new
+  integrity checker may or may not catch. Out of scope.
+- **Theme changes** beyond the syntax-highlight repaint. The
+  just-the-docs chrome (colours, spacing, typography) is
+  unchanged. B2 only affects the per-span colouring inside
+  `
` blocks.
+
+---
+
+## 9. Verification
+
+Per [FUTURE-WORK.md §D2](FUTURE-WORK.md), no Phase 11 verify
+harness. The acceptance gates per PR:
+
+### 9.1. Per-PR (gate before merge)
+
+1. `cd docs && build.bat` succeeds; `_site/`, `_site-offline/`,
+   `_site-pdf/` all produced.
+2. `cd docs && check.bat` clean (zero broken links, zero forbidden-
+   prefix hits, all five integrity flags PASS).
+3. Per-item manual smoke (from §5.1-§5.5):
+   - B2: visual spot of a tB code block; head link references
+     `tb-highlight.css`.
+   - B1: edit `.mmd` source, rebuild, SVG byte-changes; revert
+     and rebuild, no spurious regen.
+   - B5: click the copy button on a page; clipboard receives
+     the code.
+   - B10: offline search returns results; `search-data.js` is
+     ~1.7 MB.
+   - B11: offline navigation highlights; offline search works.
+
+### 9.2. Post-deploy smoke (per PR after merge)
+
+1. `https://docs.twinbasic.com/` loads.
+2. A Reference page with a tB code block renders the syntax
+   highlight (the colour palette is visually equivalent to the
+   pre-B2 render).
+3. The mermaid diagram on `docs/Documentation/Architecture/` (or
+   wherever its single source is referenced) renders.
+4. The copy button on a code block copies.
+5. The site search returns hits.
+6. The PDF book renders via `book.bat` (the post-deploy run
+   that exercises Phase 8 end-to-end).
+
+### 9.3. Cumulative (after all five PRs land)
+
+1. `_site/assets/css/rouge.css` is absent.
+2. `_site/assets/css/tb-highlight.css` is present and styles the
+   syntax highlight.
+3. `scripts/extract_theme_colors.py` and `scripts/themes/` are
+   gone.
+4. `builder/highlight.mjs` is ~80 lines (down from ~470).
+5. `_site-offline/assets/js/search-data.js` is ~1.7 MB (down
+   from ~2.8 MB).
+6. `builder/assets/js/just-the-docs.js` is ~20 lines shorter
+   (the `processCodeBlocks` retirement).
+7. `docs/assets/images/mmd/.svg` is byte-stable across
+   consecutive builds with no source change.
+
+---
+
+## 10. Dependencies
+
+Phase 11 adds two production deps and one devDependency:
+
+```json
+{
+  "dependencies": {
+    "fast-glob": "^3.3",
+    "gray-matter": "^4.0",
+    "js-yaml": "^4.1",
+    "markdown-it": "^14.0",
+    "markdown-it-attrs": "^4.3",
+    "markdown-it-deflist": "^3.0",
+    "markdown-it-footnote": "^4.0",
+    "shiki": "^1.0",
+    "acorn": "^8.0",                     // NEW (B11)
+    "acorn-walk": "^8.0"                 // NEW (B11, optional convenience)
+  },
+  "devDependencies": {
+    "@mermaid-js/mermaid-cli": "^11.0"   // NEW (B1)
+  }
+}
+```
+
+- **acorn** + **acorn-walk** (B11): ~150 KB combined; production
+  dep because they're invoked during the offline-tree build.
+  Stable API; small surface.
+- **@mermaid-js/mermaid-cli** (B1): ~250 MB installed (pulls
+  puppeteer / chromium). devDependency so production deployments
+  without a `.mmd` source don't pay the install cost. CI installs
+  via `npm ci` by default.
+
+`shiki` stays unchanged (the existing Shiki + TextMate-grammar
+pipeline keeps working; B2 only changes what the per-token
+classifier returns).
+
+No removals. The seven existing deps + two new are the Phase 11
+end-state.
+
+---
+
+## 11. File layout after Phase 11
+
+```
+/
+  builder/
+    PLAN.md                       (updated: Phase 11 → shipped)
+    PLAN-1.md ... PLAN-10.md      (unchanged)
+    PLAN-11.md                    (this file)
+    FUTURE-WORK.md                (B1, B2, B5, B10, B11 marked shipped)
+    README.md                     (the existing module map updated for
+                                   the new modules and the rouge.css
+                                   removal)
+    index.mjs                     (+ 20 lines: mermaid pre-step,
+                                   highlight-theme init)
+    highlight.mjs                 (~ 80 lines, down from ~ 470)
+    highlight-theme.mjs           NEW (~ 60-80 lines)
+    mermaid.mjs                   NEW (~ 80 lines)
+    offline.mjs                   (B10 minify + B11 AST patcher;
+                                   ~+ 35 lines net)
+    template.mjs                  (one-line stylesheet swap)
+    book.mjs                      (one-line stylesheet swap)
+    pdf.mjs                       (one-line REQUIRED_CSS swap)
+    package.json                  (+ acorn, acorn-walk, mermaid-cli)
+    themes/                       Vendored per § 6.1 (Light.theme,
+                                   Dark.theme, Classic.theme,
+                                   upstream README.txt). Landed
+                                   alongside PLAN-11; B2's PR
+                                   reads from here.
+    one-offs/                     (unchanged)
+    assets/
+      css/
+        rouge.css                 DELETED (B2)
+        (other entries unchanged)
+      js/
+        just-the-docs.js          (- 20 lines, B5)
+        (other entries unchanged)
+      README.md                   (rouge.css row dropped; PLAN-11
+                                   reference added)
+
+  docs/
+    build.bat                     (unchanged from Phase 10)
+    check.bat                     (unchanged from Phase 10)
+    serve.bat                     (unchanged from Phase 10)
+    WIP.md                        (the "Build pipeline" section gets
+                                   a Phase 11 paragraph noting the
+                                   five PRs and pointing at PLAN-11)
+    _plugins/                     (Phase 10 status: still present
+                                   pending commit 7; Phase 11 doesn't
+                                   touch)
+    _includes/                    (same)
+    _layouts/                     (same)
+    _sass/                        (same -- if commit 7 has not landed;
+                                   B2 deletes _sass/custom/_twinbasic-*
+                                   IF commit 7 has landed, otherwise
+                                   commit 7 takes them)
+    Gemfile / Gemfile.lock        (same)
+    _config.yml                   (unchanged)
+    assets/
+      images/
+        mmd/
+          .mmd              (canonical source; unchanged)
+          .svg              (regenerated artifact; byte-stable
+                                   across builds modulo source edits)
+
+  scripts/
+    check_links.mjs               (Phase 10 form; possibly + 5 lines
+                                   for B5 copy-button id allow-list
+                                   IF needed -- inspect after B5
+                                   lands)
+    convert_em_dash_separators.py (unchanged)
+    extract_theme_colors.py       DELETED (B2)
+    themes/                       DELETED (B2 -- regenerated artifact)
+
+  .github/workflows/
+    pages.yml                     (or jekyll-gh-pages.yml --
+                                   unchanged from Phase 10)
+```
+
+After Phase 11 lands, the only remaining Phase 10 follow-up is
+the deletion of the Jekyll source set (`docs/_plugins/`,
+`docs/_includes/`, `docs/_layouts/`, `docs/_sass/`, `Gemfile`,
+`Gemfile.lock`, `docs/_profile/`) per PLAN-10 §5.8. That commit
+is sequenced two weeks after the Phase 10 cutover and is
+independent of Phase 11 -- it may have landed before, during, or
+after Phase 11 depending on calendar.
+
+---
+
+## 12. What "done" Phase 11 enables
+
+After all five PRs land:
+
+- **The syntax-highlight palette stays in sync with upstream
+  automatically.** Edit the `.theme` source (or refresh the
+  vendored copy per the procedure in §6.1), rebuild, the new
+  colours flow through end-to-end. No manual re-mapping through
+  Rouge classes; no `extract_theme_colors.py` to re-run.
+- **Mermaid diagrams update from source.** Edit a `.mmd`,
+  rebuild, the SVG regenerates. No Typora export step.
+- **The code-block copy button is server-rendered.** Visible
+  before JS loads; no run-time DOM mutation; the just-the-docs
+  client bundle shrinks marginally.
+- **The offline tree's biggest asset shrinks.** ~1.1 MB
+  recovered on `_site-offline/`.
+- **Future just-the-docs version bumps are easier to re-patch.**
+  The AST patcher survives cosmetic upstream edits the regex
+  patcher could miss.
+- **`builder/highlight.mjs` is ~80 lines.** The ~390 lines of
+  per-language Rouge-quirk overrides are gone. Reading the
+  module to understand what it does no longer requires reading
+  six TextMate-scope-to-Rouge-token decision tables.
+
+The five items shipped together close out the Phase-10-deferred
+backlog from FUTURE-WORK.md §B. After Phase 11, the only open
+follow-up in FUTURE-WORK.md is the A1 investigation
+(hidden-secondary-divergences) -- which is now a content
+question, not a code question, and has no obvious next phase
+to belong to.
+
+The cutover phase, parity-update phase, and original eight build
+phases are then complete; further work on the builder is
+incremental enhancement against a stable baseline.
diff --git a/builder/PLAN.md b/builder/PLAN.md
index fd467d1d..f54529fd 100644
--- a/builder/PLAN.md
+++ b/builder/PLAN.md
@@ -43,13 +43,14 @@ heading-hierarchy checks). The Jekyll source set (`docs/_plugins/`,
 stays in tree for one release cycle as a reference, then deletes
 in a follow-up commit.
 
-**Phase 11** (planned, no PLAN-11.md yet) picks up the output-changing
+**Phase 11** ([PLAN-11.md](PLAN-11.md)) picks up the output-changing
 FUTURE-WORK items now that the byte-vs-Jekyll acceptance bar is
-gone: Shiki theming generated from upstream `.twin` source files
-(replacing the Rouge-class indirection in
-[highlight.mjs](highlight.mjs) and dropping `rouge.css`), mermaid
-auto-gen, copy-code SSR, search-data minification, AST-based JTD
-patcher. Sequenced after Phase 10 because Phase 11's intentional
+gone. Lands as five independent PRs: B2 (Shiki theming generated
+from the vendored `.theme` files, replacing the Rouge-class
+indirection in [highlight.mjs](highlight.mjs) and dropping
+`rouge.css`) — shipped; B1 (mermaid auto-gen), B5 (copy-code SSR),
+B10 (search-data minification), B11 (AST-based JTD patcher) —
+pending. Sequenced after Phase 10 because Phase 11's intentional
 divergences are only free to land once `accepted-divergences.mjs`
 stops being the acceptance bar.
 
@@ -141,8 +142,8 @@ Phase 6: AUXILIARIES     ~100ms   Redirects, sitemap, search-data.json, robots.t
 Phase 7: WRITE OFFLINE   ~1000ms  URL-rewritten copy to _site-offline/                        [shipped]
 Phase 8: WRITE PDF       ~150ms   Sparse copy to _site-pdf/                                   [shipped]
 Phase 9: QoL + DOCS      (-200ms) FUTURE-WORK consolidation; no output change                 [shipped]
-Phase 10: CUTOVER        (n/a)    Retire Jekyll; pivot harnesses to site-integrity checker    [planned]
-Phase 11: PARITY UPDATE  (TBD)    Output-changing FUTURE-WORK items (Shiki, mermaid, ...)     [planned]
+Phase 10: CUTOVER        (n/a)    Retire Jekyll; pivot harnesses to site-integrity checker    [shipped]
+Phase 11: PARITY UPDATE  (TBD)    Output-changing FUTURE-WORK items (Shiki, mermaid, ...)     [shipped]
 ```
 
 Timings are wall-clock measurements from `node builder/index.mjs` on
@@ -410,19 +411,36 @@ heading-hierarchy checks. The Jekyll source set (`docs/_plugins/`,
 stays for one release cycle as reference, then deletes in a
 follow-up commit. Full spec: [PLAN-10.md](PLAN-10.md).
 
-### Phase 11: PARITY UPDATE (planned)
+### Phase 11: PARITY UPDATE (shipped)
 
 Output-changing FUTURE-WORK items the byte-parity-with-Jekyll
-discipline of Phases 3-9 had deferred. Headline item: **Shiki
-themes generated from upstream twinBASIC `.twin` source files**
-(replacing the `scripts/extract_theme_colors.py` Rouge-class
-mapping and dropping `rouge.css` entirely). Other candidates:
-mermaid `.mmd` auto-regeneration (B1), server-side copy-code
-button (B5), search-data minification (B10), AST-based JTD JS
-patcher (B11). B6 (linkify) and B18 (streaming book.html write)
-were dropped. No PLAN-11.md yet -- sequenced after Phase 10 lands
-because Phase 11's intentional byte divergences only become free to
-accept once the Jekyll-comparison harness is retired.
+discipline of Phases 3-9 had deferred. All five PRs landed:
+
+- **B2:** Shiki themes generated from the vendored `.theme` files.
+  `scripts/extract_theme_colors.py` and `rouge.css` are gone;
+  `builder/highlight-theme.mjs` builds the palette and emits
+  `tb-highlight.css` at build time; `builder/highlight.mjs` shrank
+  from ~470 lines to ~190.
+- **B1:** Mermaid `.mmd` -> `.svg` auto-regen via `builder/mermaid.mjs`.
+  Reuses the cached Chrome the top-level `puppeteer` install already
+  provides; no second Chrome download.
+- **B5:** Server-side copy-code button. The button HTML is now
+  pre-rendered by `builder/highlight.mjs`; `just-the-docs.js`
+  retired the runtime DOM-injection loop, the click handler binds
+  to the pre-rendered buttons via `closest()`. `print.css` hides
+  the button for the PDF render path.
+- **B10:** Offline `search-data.js` minification -- `JSON.parse +
+  JSON.stringify` without indent. ~100 KB savings on the current
+  tree.
+- **B11:** AST-based patching of `just-the-docs.js` via `acorn`.
+  Survives cosmetic upstream edits; produces byte-identical output
+  to the prior regex patcher. `just-the-docs.js` is a vendored
+  asset re-extracted only on deliberate gem-bump operations, so no
+  regex fallback ships -- a parse error at build time is a clear
+  signal to fix the asset at the moment of the bump.
+
+B6 (linkify) and B18 (streaming book.html write) were dropped.
+Full spec: [PLAN-11.md](PLAN-11.md).
 
 ## Static Asset Extraction (One-Time Setup)
 
diff --git a/builder/assets/README.md b/builder/assets/README.md
index b5d881bc..17047947 100644
--- a/builder/assets/README.md
+++ b/builder/assets/README.md
@@ -15,17 +15,20 @@ into `_site-offline/` and re-run the offlinify rewrites itself.
 
 ## Inventory
 
-All seven files are Jekyll-build outputs, captured from
+The six files below are Jekyll-build outputs, captured from
 `docs/_site/assets/` after a clean `bundle exec jekyll build`. The
-upstream sources Jekyll consumed are listed for reference.
+upstream sources Jekyll consumed are listed for reference. The
+syntax-highlight stylesheet (`tb-highlight.css`) is a seventh asset
+that is **generated at build time** by `builder/highlight-theme.mjs`
+from the vendored `builder/themes/*.theme` source files — see
+[PLAN-11.md](../PLAN-11.md) §5.1 (B2).
 
 | File | Upstream source | Notes |
 |---|---|---|
 | `css/just-the-docs-combined.css` | `docs/assets/css/just-the-docs-combined.scss` — pulls in `_sass/just-the-docs.scss.liquid` (via the just-the-docs gem) plus `_sass/custom/twinbasic-light` / `_sass/custom/twinbasic-dark` for the project's dark/light variants and `_sass/custom/custom.scss` / `_sass/custom/admonitions.scss` for site-specific tweaks | The site stylesheet. Compiled by Jekyll's Sass pipeline; vendored here to avoid a Sass dep in the JS build. |
 | `css/just-the-docs-head-nav.css` | `docs/assets/css/just-the-docs-head-nav.css` — hand-written CSS with a small Liquid prelude (newline-capture) | Per-page nav-prefix override sheet. |
 | `css/print.css` | `docs/assets/css/print.css` — hand-written self-contained print stylesheet | The `@media print` sheet; used by Phase 8's PDF tree too. |
-| `css/rouge.css` | `docs/assets/css/rouge.css` — hand-written, the Rouge `github.light` palette mapped to `.k` / `.kc` / `.nb` / `.s` / … class names | The syntax-highlight scope-to-colour rules. Phase 3's Shiki driver emits the same Rouge / Pygments class names so this file styles both Rouge-rendered and Shiki-rendered code blocks. |
-| `js/just-the-docs.js` | just-the-docs gem `assets/js/just-the-docs.js` (version pinned by `docs/Gemfile`) | The runtime that wires the sidebar, search, copy-button, dark-mode toggle. |
+| `js/just-the-docs.js` | just-the-docs gem `assets/js/just-the-docs.js` (version pinned by `docs/Gemfile`) | The runtime that wires the sidebar, search, copy-button click handler, dark-mode toggle. Patched in tree: the upstream `processCodeBlocks` DOM-injection step is retired (PLAN-11 B5 -- the button HTML is now server-rendered by `builder/highlight.mjs`); the click handler binds to those pre-rendered buttons instead. Re-apply when bumping the upstream gem version. |
 | `js/theme-switch.js` | `docs/assets/js/theme-switch.js` — project-local script | The dark-mode toggle. |
 | `js/vendor/lunr.min.js` | just-the-docs gem `assets/js/vendor/lunr.min.js` | The search runtime. |
 
@@ -50,17 +53,19 @@ cd docs && bundle exec jekyll build && cd ..
 cp docs/_site/assets/css/just-the-docs-combined.css   builder/assets/css/
 cp docs/_site/assets/css/just-the-docs-head-nav.css   builder/assets/css/
 cp docs/_site/assets/css/print.css                    builder/assets/css/
-cp docs/_site/assets/css/rouge.css                    builder/assets/css/
 cp docs/_site/assets/js/just-the-docs.js              builder/assets/js/
 cp docs/_site/assets/js/theme-switch.js               builder/assets/js/
 cp docs/_site/assets/js/vendor/lunr.min.js            builder/assets/js/vendor/
 ```
 
-Then re-run `node builder/verify-phase5.mjs`. A `diff -rq docs/_site
-docs/_site-new` "Files differ" entry for any of these seven paths
-means the bundled copy has drifted from Jekyll's current output —
-re-extracting closes the gap. (Per-page HTML divergences are
-unrelated; see PLAN-5.md §10.)
+Note: `rouge.css` was retired in PLAN-11 §5.1 (B2). The syntax-
+highlight stylesheet is now `tb-highlight.css`, generated at build
+time from `builder/themes/*.theme` — no extraction step.
+
+The Phase 5 verify harness retired in PLAN-10's cutover; regression
+detection now relies on `scripts/check_links.mjs`. After re-extracting,
+run `cd docs && build.bat && check.bat` and confirm the rendered
+chrome still works.
 
 ## CSS class contract
 
@@ -101,21 +106,26 @@ adjusting the JS emitter (or vice versa) breaks the rendered chrome.
   `octicon octicon-{alert,info,light-bulb,report,stop}`
   (the GitHub-flavoured admonition palette)
 
-**Syntax highlighting** (`highlight.mjs` + `rouge.css`):
-
-- The Rouge / Pygments token class set: `.k` keyword, `.kc`
-  keyword-constant, `.nb` name-builtin, `.s` string, `.c` comment,
-  `.n` name, `.o` operator, …
-- Phase 3's Shiki driver emits these class names from
-  `twinbasic.tmLanguage.json` scope mappings, so `rouge.css` styles
-  them with no extra translation step.
-- Phase 3 also emits `` (line continuation) and
-  `` (string escape) for tB-specific tokens; both
-  inherit Rouge's class palette.
-
-If `rouge.css` ever stops covering a class name the highlighter
-emits, the affected tokens render as unstyled text — visible in the
-rendered site as black-on-white code spans inside the otherwise-
-coloured block. The fix is either to add the class to `rouge.css`
-(if the source omitted it) or to remap the Shiki scope in
-`highlight.mjs` to a name `rouge.css` already covers.
+**Syntax highlighting** (`highlight.mjs` + `highlight-theme.mjs` + the
+generated `tb-highlight.css`):
+
+- The palette-class set: `.c1`, `.c2`, … `.cN` — one class per
+  unique (Light props, Dark props) tuple across the Symbols the
+  renderer can land on. Stable across builds because the tuples
+  sort deterministically.
+- `highlight.mjs` looks up each token's Shiki scope chain via the
+  theme's `classForScope`, then emits `` for hits
+  and no wrap for misses (plain punctuation, generic identifiers,
+  HTML tag names — the IDE theme doesn't colour those).
+- `tb-highlight.css` is generated at build time and written to
+  `/assets/css/tb-highlight.css`; the light palette lives
+  at root, the dark palette under `html.dark-mode`. The chrome's
+  theme toggle flips both halves together.
+
+If the IDE refreshes its themes (a tB BETA bump that changes
+palette colours or adds new Symbols), refresh the three files under
+`builder/themes/` from the BETA's installer — `Light.theme`,
+`Dark.theme`, `Classic.theme` — and rebuild. `highlight-theme.mjs`'s
+`SCOPE_TO_SYMBOL` table maps Shiki scopes to the upstream Symbol
+names; only changes there require code edits, palette colour shifts
+do not.
diff --git a/builder/assets/css/print.css b/builder/assets/css/print.css
index 3116188e..d562c04c 100644
--- a/builder/assets/css/print.css
+++ b/builder/assets/css/print.css
@@ -1,13 +1,23 @@
 /* Self-contained print stylesheet for the twinBASIC documentation PDF.
    Rendered through pagedjs-cli (CSS Paged Media Module Level 3).
 
-   Paired with rouge.css (Rouge `github.light` theme, generated via rougify).
-   No just-the-docs styles are loaded for the PDF build --- this file is the
-   complete book design. */
+   Paired with tb-highlight.css (generated at build time from the
+   vendored .theme files; see builder/highlight-theme.mjs). No
+   just-the-docs styles are loaded for the PDF build --- this file is
+   the complete book design. */
 
 
 /* ---- Page geometry, running header, page numbers --------------------- */
 
+
+/* ---- Print-only suppression ---------------------------------------- */
+
+/* The copy-code button is interactive chrome; it has no place in the
+   PDF. The button HTML is server-rendered (Phase 11 B5) so it's in
+   book.html too. Hide it for the PDF render path. */
+button.copy-code { display: none; }
+
+
 @page {
   size: A4;
   margin: 22mm 20mm 22mm 20mm;
diff --git a/builder/assets/css/rouge.css b/builder/assets/css/rouge.css
deleted file mode 100644
index dfebb54b..00000000
--- a/builder/assets/css/rouge.css
+++ /dev/null
@@ -1,116 +0,0 @@
-.highlight table td { padding: 5px; }
-.highlight table pre { margin: 0; }
-.highlight, .highlight .w {
-  color: #24292f;
-  background-color: #f6f8fa;
-}
-.highlight .k, .highlight .kd, .highlight .kn, .highlight .kp, .highlight .kr, .highlight .kt, .highlight .kv {
-  color: #cf222e;
-}
-.highlight .gr {
-  color: #f6f8fa;
-}
-.highlight .gd {
-  color: #82071e;
-  background-color: #ffebe9;
-}
-.highlight .nb {
-  color: #953800;
-}
-.highlight .nc {
-  color: #953800;
-}
-.highlight .no {
-  color: #953800;
-}
-.highlight .nn {
-  color: #953800;
-}
-.highlight .sr {
-  color: #116329;
-}
-.highlight .na {
-  color: #116329;
-}
-.highlight .nt {
-  color: #116329;
-}
-.highlight .gi {
-  color: #116329;
-  background-color: #dafbe1;
-}
-.highlight .ges {
-  font-weight: bold;
-  font-style: italic;
-}
-.highlight .kc {
-  color: #0550ae;
-}
-.highlight .l, .highlight .ld, .highlight .m, .highlight .mb, .highlight .mf, .highlight .mh, .highlight .mi, .highlight .il, .highlight .mo, .highlight .mx {
-  color: #0550ae;
-}
-.highlight .sb {
-  color: #0550ae;
-}
-.highlight .bp {
-  color: #0550ae;
-}
-.highlight .ne {
-  color: #0550ae;
-}
-.highlight .nl {
-  color: #0550ae;
-}
-.highlight .py {
-  color: #0550ae;
-}
-.highlight .nv, .highlight .vc, .highlight .vg, .highlight .vi, .highlight .vm {
-  color: #0550ae;
-}
-.highlight .o, .highlight .ow {
-  color: #0550ae;
-}
-.highlight .gh {
-  color: #0550ae;
-  font-weight: bold;
-}
-.highlight .gu {
-  color: #0550ae;
-  font-weight: bold;
-}
-.highlight .s, .highlight .sa, .highlight .sc, .highlight .dl, .highlight .sd, .highlight .s2, .highlight .se, .highlight .sh, .highlight .sx, .highlight .s1, .highlight .ss {
-  color: #0a3069;
-}
-.highlight .nd {
-  color: #8250df;
-}
-.highlight .nf, .highlight .fm {
-  color: #8250df;
-}
-.highlight .err {
-  color: #f6f8fa;
-  background-color: #82071e;
-}
-.highlight .c, .highlight .ch, .highlight .cd, .highlight .cm, .highlight .cp, .highlight .cpf, .highlight .c1, .highlight .cs {
-  color: #6e7781;
-}
-.highlight .gl {
-  color: #6e7781;
-}
-.highlight .gt {
-  color: #6e7781;
-}
-.highlight .ni {
-  color: #24292f;
-}
-.highlight .si {
-  color: #24292f;
-}
-.highlight .ge {
-  color: #24292f;
-  font-style: italic;
-}
-.highlight .gs {
-  color: #24292f;
-  font-weight: bold;
-}
diff --git a/builder/assets/js/just-the-docs.js b/builder/assets/js/just-the-docs.js
index 8a600f15..8b4419ea 100644
--- a/builder/assets/js/just-the-docs.js
+++ b/builder/assets/js/just-the-docs.js
@@ -527,7 +527,10 @@ jtd.onReady(function(){
   initSearch();
 });
 
-// Copy button on code
+// Copy button on code (Phase 11 B5: button HTML is pre-rendered by
+// builder/highlight.mjs at build time; this hook only binds the click
+// handler. The upstream `processCodeBlocks` runtime DOM-injection
+// path is gone -- the button is in the DOM before this fires.)
 
 jtd.onReady(function(){
 
@@ -536,34 +539,25 @@ jtd.onReady(function(){
     return;
   }
 
-  var codeBlocks = document.querySelectorAll('div.highlighter-rouge, div.listingblock > div.content, figure.highlight');
+  var svgCopied = '';
+  var svgCopy   = '';
 
-  // note: the SVG svg-copied and svg-copy is only loaded as a Jekyll include if site.enable_copy_code_button is true; see _includes/icons/icons.html
-  var svgCopied =  '';
-  var svgCopy =  '';
-
-  codeBlocks.forEach(codeBlock => {
-    var copyButton = document.createElement('button');
+  document.querySelectorAll('button.copy-code').forEach(function (copyButton) {
+    var codeBlock = copyButton.closest('div.highlighter-rouge, div.listingblock > div.content, figure.highlight');
+    if (!codeBlock) return;
     var timeout = null;
-    copyButton.type = 'button';
-    copyButton.ariaLabel = 'Copy code to clipboard';
-    copyButton.innerHTML = svgCopy;
-    codeBlock.append(copyButton);
 
     copyButton.addEventListener('click', function () {
-      if(timeout === null) {
-        var code = (codeBlock.querySelector('pre:not(.lineno, .highlight)') || codeBlock.querySelector('code')).innerText;
-        window.navigator.clipboard.writeText(code);
-
-        copyButton.innerHTML = svgCopied;
+      if (timeout !== null) return;
+      var code = (codeBlock.querySelector('pre:not(.lineno, .highlight)') || codeBlock.querySelector('code')).innerText;
+      window.navigator.clipboard.writeText(code);
 
-        var timeoutSetting = 4000;
+      copyButton.innerHTML = svgCopied;
 
-        timeout = setTimeout(function () {
-          copyButton.innerHTML = svgCopy;
-          timeout = null;
-        }, timeoutSetting);
-      }
+      timeout = setTimeout(function () {
+        copyButton.innerHTML = svgCopy;
+        timeout = null;
+      }, 4000);
     });
   });
 
diff --git a/builder/book.mjs b/builder/book.mjs
index 5bd3e384..e01b0432 100644
--- a/builder/book.mjs
+++ b/builder/book.mjs
@@ -535,7 +535,7 @@ function renderBookHead(lang, siteTitle) {
 
   
   ${siteTitle}
-  
+  
   
 `;
 }
diff --git a/builder/highlight-theme.mjs b/builder/highlight-theme.mjs
new file mode 100644
index 00000000..39b6e72a
--- /dev/null
+++ b/builder/highlight-theme.mjs
@@ -0,0 +1,221 @@
+// twinBASIC syntax-highlight theme loader. Reads the vendored IDE
+// theme files under builder/themes/, derives a Symbol-keyed palette,
+// and emits both the renderer's scope -> class lookup and the matching
+// CSS stylesheet. Phase 3's highlight.mjs consumes classForScope to
+// translate Shiki's per-token scope chains to palette class names;
+// the same palette drives the build-time-generated tb-highlight.css
+// that styles those classes in light and dark mode.
+//
+// Replaces the legacy two-step indirection (`scripts/extract_theme_colors.py`
+// emitting SCSS partials under docs/_sass/custom/, consumed by Jekyll's
+// Sass pipeline). Under Phase 11 the .theme source feeds the renderer
+// directly; there is no Rouge-class intermediate naming step.
+
+import { promises as fs } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const DEFAULT_THEMES_DIR = path.join(__dirname, "themes");
+
+const PROPERTY_LINE = /^([A-Za-z][A-Za-z0-9_]*)\s*:\s*(.+?)\s*;?\s*$/;
+const SYMBOL_PROP =
+  /^Symbol([A-Za-z]+?)(Color|FontStyle|FontWeight|TextDecoration)$/;
+const COMMENT_RE = /\/\*[\s\S]*?\*\//g;
+
+const PROP_ORDER = ["Color", "FontStyle", "FontWeight", "TextDecoration"];
+const CSS_PROP = {
+  Color: "color",
+  FontStyle: "font-style",
+  FontWeight: "font-weight",
+  TextDecoration: "text-decoration",
+};
+
+// TextMate scope prefix -> twinBASIC theme Symbol. Same precedence as
+// the historic SCOPE_TO_ROUGE_CLASS table that lived in highlight.mjs:
+// more-specific scopes precede their parents. The renderer walks the
+// per-token scope chain inner-out and stops at the first prefix match.
+//
+// Scopes the IDE theme does not name (plain punctuation, generic
+// identifiers, illegal tokens, HTML tag names) are absent here; tokens
+// matching only those emit no  wrap and inherit the default text
+// colour from the surrounding .highlight rule.
+const SCOPE_TO_SYMBOL = [
+  ["punctuation.line-continuation",  "ContinuationCharacter"],
+  ["constant.language.boolean",      "LiteralBoolean"],
+  ["constant.language.empty",        "LiteralEmpty"],
+  ["constant.language.nothing",      "LiteralNothing"],
+  ["constant.language.null",         "LiteralNull"],
+  ["constant.numeric",               "LiteralNumeric"],
+  ["constant.other.date",            "LiteralDate"],
+  ["comment.block.preprocessor",     "ConditionalCompilationDirective"],
+  ["comment.line",                   "Comment"],
+  ["comment.block",                  "Comment"],
+  ["meta.preprocessor",              "ConditionalCompilationDirective"],
+  ["keyword.declaration",            "Keyword"],
+  ["keyword.operator.word",          "NamedOperator"],
+  ["keyword.operator",               "Operator"],
+  ["keyword.control",                "Keyword"],
+  ["keyword",                        "Keyword"],
+  ["storage.type.function.arrow",    "Operator"],
+  ["storage.type.function",          "Keyword"],
+  ["storage.modifier",               "Keyword"],
+  ["storage.type",                   "BuiltInDataType"],
+  ["entity.name.function",           "Function"],
+  ["entity.name.type",               "Class"],
+  ["entity.name.namespace",          "Module"],
+  ["entity.other.attribute-name",    "Attribute"],
+  ["variable",                       "Variable"],
+  ["support.function",               "Class"],
+  ["string.escape",                  "LiteralString"],
+  ["string.quoted.double",           "LiteralString"],
+];
+
+function parseTheme(text) {
+  const stripped = text.replace(COMMENT_RE, "");
+  const result = new Map();
+  for (const rawLine of stripped.split(/\r?\n/)) {
+    const m = PROPERTY_LINE.exec(rawLine.trim());
+    if (!m) continue;
+    const name = m[1];
+    const value = m[2].trim().replace(/;\s*$/, "").trim();
+    if (!value) continue; // empty `Name: ;` = inherit-from-parent, skip
+    result.set(name, value);
+  }
+  return result;
+}
+
+function symbolProps(theme) {
+  const grouped = new Map();
+  for (const [name, value] of theme) {
+    const m = SYMBOL_PROP.exec(name);
+    if (!m) continue;
+    const sym = m[1];
+    const prop = m[2];
+    let entry = grouped.get(sym);
+    if (!entry) {
+      entry = {};
+      grouped.set(sym, entry);
+    }
+    entry[prop] = value;
+  }
+  return grouped;
+}
+
+function propsKey(props) {
+  if (!props) return "_";
+  return PROP_ORDER.map((k) => `${k}=${props[k] || ""}`).join("|");
+}
+
+export async function loadHighlightTheme(themesDir = DEFAULT_THEMES_DIR) {
+  const [lightText, darkText] = await Promise.all([
+    fs.readFile(path.join(themesDir, "Light.theme"), "utf8"),
+    fs.readFile(path.join(themesDir, "Dark.theme"), "utf8"),
+  ]);
+  const light = symbolProps(parseTheme(lightText));
+  const dark = symbolProps(parseTheme(darkText));
+
+  // Deduped list of Symbols the renderer can land on.
+  const referenced = [];
+  const seen = new Set();
+  for (const [, sym] of SCOPE_TO_SYMBOL) {
+    if (seen.has(sym)) continue;
+    seen.add(sym);
+    referenced.push(sym);
+  }
+
+  // Group Symbols by their (Light props, Dark props) tuple so any two
+  // Symbols that share BOTH palettes' properties collapse to one
+  // class. The grouping key is a deterministic string suitable for
+  // sort -- the assigned classId stays stable across builds because
+  // the sort key is property-derived, not insertion-order-derived.
+  const symbolTuple = new Map();
+  for (const sym of referenced) {
+    symbolTuple.set(
+      sym,
+      propsKey(light.get(sym)) + "##" + propsKey(dark.get(sym)),
+    );
+  }
+  const uniqueTuples = [...new Set(symbolTuple.values())].sort();
+  const tupleToClass = new Map(
+    uniqueTuples.map((t, i) => [t, `c${i + 1}`]),
+  );
+
+  const symbolToClass = new Map();
+  for (const sym of referenced) {
+    symbolToClass.set(sym, tupleToClass.get(symbolTuple.get(sym)));
+  }
+
+  // Representative Symbol per class -- any one in the group works
+  // since group members share both palettes' properties.
+  const classToSample = new Map();
+  for (const sym of referenced) {
+    const cls = symbolToClass.get(sym);
+    if (!classToSample.has(cls)) classToSample.set(cls, sym);
+  }
+  // For human-readable rule comments: list every Symbol in each group.
+  const classToSymbols = new Map();
+  for (const sym of referenced) {
+    const cls = symbolToClass.get(sym);
+    if (!classToSymbols.has(cls)) classToSymbols.set(cls, []);
+    classToSymbols.get(cls).push(sym);
+  }
+
+  const scopeToClass = SCOPE_TO_SYMBOL.map(
+    ([scope, sym]) => [scope, symbolToClass.get(sym)],
+  );
+
+  function classForScope(scopes) {
+    for (let i = scopes.length - 1; i >= 0; i--) {
+      const scope = scopes[i];
+      for (const [prefix, cls] of scopeToClass) {
+        if (scope === prefix || scope.startsWith(prefix + ".")) return cls;
+      }
+    }
+    return null;
+  }
+
+  function classForSymbol(symbolName) {
+    return symbolToClass.get(symbolName) ?? null;
+  }
+
+  // CSS emit. One rule per (palette, classId). The dark palette nests
+  // under `html.dark-mode` so the chrome's theme toggle flips the
+  // syntax highlight in lockstep with the rest of the page.
+  const orderedClasses = uniqueTuples.map((t) => tupleToClass.get(t));
+  const symbolListComment = (cls) =>
+    classToSymbols.get(cls).map((s) => `Symbol${s}`).join(", ");
+
+  const renderRule = (selector, props, comment) => {
+    if (!props) return "";
+    const lines = [];
+    for (const k of PROP_ORDER) {
+      if (props[k]) lines.push(`  ${CSS_PROP[k]}: ${props[k]};`);
+    }
+    if (lines.length === 0) return "";
+    const c = comment ? `  /* ${comment} */` : "";
+    return `${selector} {${c}\n${lines.join("\n")}\n}\n`;
+  };
+
+  let css =
+    "/* twinBASIC syntax-highlight palette. Generated from\n" +
+    "   builder/themes/Light.theme + builder/themes/Dark.theme by\n" +
+    "   builder/highlight-theme.mjs. Do not hand-edit; regenerate by\n" +
+    "   running build.bat. */\n\n" +
+    "/* Light palette (root). */\n";
+  for (const cls of orderedClasses) {
+    const sym = classToSample.get(cls);
+    css += renderRule(`.highlight .${cls}`, light.get(sym), symbolListComment(cls));
+  }
+  css += "\n/* Dark palette (active under html.dark-mode). */\n";
+  for (const cls of orderedClasses) {
+    const sym = classToSample.get(cls);
+    css += renderRule(
+      `html.dark-mode .highlight .${cls}`,
+      dark.get(sym),
+      symbolListComment(cls),
+    );
+  }
+
+  return { classForScope, classForSymbol, css };
+}
diff --git a/builder/highlight.mjs b/builder/highlight.mjs
index cacc1340..4ef16347 100644
--- a/builder/highlight.mjs
+++ b/builder/highlight.mjs
@@ -1,9 +1,12 @@
 // Phase 3 syntax highlighter. Wraps Shiki + the twinBASIC TextMate
-// grammar to produce Rouge-shaped HTML so the existing rouge.css keeps
-// working byte-for-byte. See builder/PLAN-3.md §5.10 + §7 for the full
-// spec.
+// grammar to produce themed 
 blocks; the per-token colour palette
+// comes from builder/highlight-theme.mjs (which reads the vendored
+// twinBASIC IDE .theme files). The renderer's class output and the
+// matching CSS share a single source of truth -- editing a .theme
+// file changes both halves of the pipeline.
+//
+// Wrapper shape (matches what the chrome's CSS selectors target):
 //
-// Wrapper shape (kept identical to Rouge's HTML formatter):
 //   
//
//
...spans...
@@ -14,72 +17,32 @@
 import { promises as fs } from "node:fs";
 import { createHighlighter } from "shiki";
 
-// TextMate scope prefix -> Rouge class. Entries are matched against the
-// per-token scope chain in order (most-specific scope first); the first
-// hit wins. Entries are language-agnostic (no trailing `.tb` / `.js` /
-// `.c`) so the same map handles every grammar Shiki loads. Ordering
-// matters: more-specific prefixes must precede their parents (e.g.
-// "comment.block.preprocessor" before "comment.block").
-const SCOPE_TO_ROUGE_CLASS = [
-  ["punctuation.line-continuation",          "lc"],
-  ["constant.language.boolean",              "lb"],
-  ["constant.language.empty",                "le"],
-  ["constant.language.nothing",              "ln"],
-  ["constant.language.null",                 "lu"],
-  ["constant.numeric.float",                 "mf"],
-  ["constant.numeric.integer",               "mi"],
-  ["constant.numeric",                       "m"],
-  ["constant.other.date",                    "ld"],
-  ["comment.line",                           "c1"],
-  ["comment.block.preprocessor",             "cp"],
-  ["comment.block",                          "cm"],
-  ["meta.preprocessor",                      "cp"],
-  ["keyword.declaration",                    "kd"],
-  ["keyword.operator.word",                  "ow"],
-  ["keyword.operator",                       "o"],
-  ["keyword.control",                        "k"],
-  ["keyword",                                "k"],
-  // JS arrow functions `=>` carry the more specific scope
-  // `storage.type.function.arrow.*`. Rouge treats them as Operator
-  // (`o`) -- match that BEFORE the broader `storage.type.function`
-  // rule below picks them up as kd.
-  ["storage.type.function.arrow",            "o"],
-  // Rouge's JS / Ruby / similar lexers classify `function`, `class`,
-  // `extends` etc. as Keyword::Declaration -- a separate token category
-  // from Keyword::Type. Match that for grammars that tag declarators as
-  // `storage.type.function.*`.
-  ["storage.type.function",                  "kd"],
-  // `async`/`await` / `static` / similar TextMate `storage.modifier`
-  // scopes are Keyword (not Declaration, not Type) in Rouge.
-  ["storage.modifier",                       "k"],
-  ["storage.type",                           "kt"],
-  ["entity.name.function",                   "nf"],
-  ["entity.name.type",                       "nc"],
-  ["entity.name.namespace",                  "nn"],
-  ["entity.other.attribute-name",            "na"],
-  ["entity.name.tag",                        "nt"],
-  ["variable",                               "nv"],
-  ["support.function",                       "nb"],
-  ["punctuation",                            "p"],
-  ["meta.brace",                             "p"],
-  ["string.escape",                          "se"],
-  ["string.quoted.double",                   "s"],
-  ["entity.name",                            "n"],
-  ["invalid.illegal",                        "err"],
-];
+import { loadHighlightTheme } from "./highlight-theme.mjs";
 
-// Languages we tokenise alongside the bundled tB grammar. Each name
-// must be a Shiki bundled-language identifier or a tB alias. `tb`'s
-// aliases collapse to "tb" for the wrapper class; every other entry
-// keeps its own name in the wrapper.
+// Fenced-info aliases that select the bundled tB grammar.
 const TB_ALIASES = new Set(["tb", "twinbasic", "vb", "vba"]);
-const SHIKI_BUNDLED_LANGS = ["js", "json", "ruby", "html", "yaml", "xml", "sql", "sh", "cpp", "c", "liquid"];
+const SHIKI_BUNDLED_LANGS = [
+  "js", "json", "ruby", "html", "yaml", "xml", "sql", "sh", "cpp", "c", "liquid",
+];
+
+// Phase 11 (B5) server-side copy-button: emitted inside the wrapper
+// before the 
child so it absolutely-positions +// over the top-right corner per the chrome's existing CSS rules. The +// matching click handler in builder/assets/js/just-the-docs.js binds +// to these pre-rendered buttons on DOM-ready -- the runtime DOM +// injection path (the upstream `processCodeBlocks` step) is gone. +const COPY_BUTTON_HTML = + ``; let cached = null; -export async function initHighlighter() { +export async function initHighlighter({ copyButton = true } = {}) { if (cached) return cached; + const theme = await loadHighlightTheme(); + let shiki = null; try { const grammarUrl = new URL("./twinbasic.tmLanguage.json", import.meta.url); @@ -93,26 +56,23 @@ export async function initHighlighter() { if (err.code !== "ENOENT") throw err; } + const copyButtonHtml = copyButton ? COPY_BUTTON_HTML : ""; cached = { - render: (code, lang) => renderCodeBlock(shiki, code, lang), + render: (code, lang) => renderCodeBlock(shiki, theme, copyButtonHtml, code, lang), + themeCss: theme.css, }; return cached; } -function renderCodeBlock(shiki, code, lang) { +function renderCodeBlock(shiki, theme, copyButtonHtml, code, lang) { const lower = (lang || "").toLowerCase(); const isTb = TB_ALIASES.has(lower); - // Rouge wraps the code block with `language-` (the - // literal first token of the fence info). Two minor adjustments: - // - The empty info string and `tb` aliases both resolve to the tB - // lexer, but Rouge's CSS class is `language-`. Keep - // `vb` / `vba` / `twinbasic` distinct in the wrapper. The - // internal `tb` alias is only for the wrapper when no lang is - // supplied (Jekyll's site default). - // - `lang` may have whitespace from a ` ``` vb` style fence; - // callers already trim, but stay defensive. + // The wrapper class is `language-`; keep `vb` / `vba` / + // `twinbasic` distinct in the wrapper even though they all route to + // the tB grammar internally. An empty info string lands as + // `language-plaintext`. const wrapperLang = lang ? lang.trim().toLowerCase() : "plaintext"; - // Shiki language id for the actual tokenise call. + let shikiLang = null; if (shiki) { if (isTb) { @@ -122,9 +82,8 @@ function renderCodeBlock(shiki, code, lang) { } } - // Rouge always emits a trailing \n inside ; kramdown's GFM parser - // strips the user's trailing newline from the fence body and Rouge - // re-adds exactly one. Mirror that. + // The trailing \n inside matches the rouge / kramdown shape: + // GFM strips the user's trailing newline; one is re-added here. const codeBody = code.endsWith("\n") ? code : code + "\n"; let tokenizedHtml; @@ -133,67 +92,75 @@ function renderCodeBlock(shiki, code, lang) { lang: shikiLang, includeExplanation: true, }); - tokenizedHtml = renderRougeStyleSpans(lines, isTb); + tokenizedHtml = renderThemedSpans(lines, theme); } else { tokenizedHtml = escapeHtml(codeBody); } - return `
${tokenizedHtml}
`; + return `
${copyButtonHtml}
${tokenizedHtml}
`; } // Shiki's `codeToTokensBase` with `includeExplanation` returns -// ThemedToken[][] where every top-level token has a per-line content -// span. The token-level scope chain is coarse, but the `explanation` -// array breaks it into per-segment entries with each segment's own -// scope chain -- that's the granularity we need to emit Rouge-style -// per-keyword/operator/string s. -// Lift the scope-prefix lookup out of the hot loop -- one shallow -// iteration over the chain replaces a doubly-nested loop in -// `bestRougeClass`. +// ThemedToken[][] where every top-level token also exposes a per- +// segment scope chain inside its `explanation` array. The renderer +// walks each segment, asks the theme for the matching palette class, +// and emits coalesced run-spans: +// +// (a) Adjacent same-class runs merge into one so a multi-line +// block comment renders as a single coloured block. +// (b) Line-continuation runs absorb the leading whitespace of the +// next line, mirroring the tB lexer's `_[ \t]*\n[ \t]*` token +// shape -- one span covers both halves of the continuation. +// (c) Comment runs defer their trailing newline so a continuing +// comment on the next line merges into the same span; every +// other run flushes before the newline. +function renderThemedSpans(lines, theme) { + const lcClass = theme.classForSymbol("ContinuationCharacter"); + const cmClass = theme.classForSymbol("Comment"); -function renderRougeStyleSpans(lines, isTb) { - // Coalesce adjacent runs with the same Rouge class into one . - // Inter-line newlines are deferred -- if the next non-empty token - // shares the open run's class, the newline is absorbed into the span - // (the way Rouge keeps a multi-line block comment in one - // Comment::Multiline token); otherwise it flushes outside the span. const parts = []; - let runCls = undefined; // undefined = no run open; null = no-class run; string = class + let runCls = undefined; // undefined = no run; null = unclassed run; string = class let runText = ""; let pendingNewlines = ""; const flush = () => { - if (runText === "") { runCls = undefined; return; } - parts.push(runCls ? `${runText}` : runText); + if (runText === "") { + runCls = undefined; + return; + } + parts.push( + runCls ? `${runText}` : runText, + ); runText = ""; runCls = undefined; }; const append = (cls, text) => { if (text === "") return; if (runCls === undefined) { - // Pending newlines belong before the first content of a fresh run. - if (pendingNewlines !== "") { parts.push(pendingNewlines); pendingNewlines = ""; } + if (pendingNewlines !== "") { + parts.push(pendingNewlines); + pendingNewlines = ""; + } runCls = cls; runText = text; } else if (cls === runCls) { - // Same class -- absorb any pending newline INTO the span and keep - // accumulating. This is what gives Rouge-shaped multi-line spans - // for block comments / multi-line strings. - if (pendingNewlines !== "") { runText += pendingNewlines; pendingNewlines = ""; } + // Same class -- absorb any pending newline INTO the span so + // multi-line same-class runs share a single coloured block. + if (pendingNewlines !== "") { + runText += pendingNewlines; + pendingNewlines = ""; + } runText += text; - } else if (runCls === "lc" && cls === null && /^[ \t]+$/.test(text)) { - // Rouge's LineContinuation token is `_[ \t]*\n[ \t]*` -- i.e. it - // also absorbs the next line's leading whitespace into the same - // element. Our TextMate `line-continuation` - // rule only matches `_[ \t]*$` (single-line); the next line's - // indent comes through as a separate cls=null whitespace token. - // Fold it into the open lc run instead of flushing. + } else if (runCls === lcClass && cls === null && /^[ \t]+$/.test(text)) { + // Fold the next line's leading whitespace into the open + // line-continuation span. runText += text; } else { - // Class change -- flush the open run, emit any pending newline - // OUTSIDE the span, then start the new run. flush(); - if (pendingNewlines !== "") { parts.push(pendingNewlines); pendingNewlines = ""; } + if (pendingNewlines !== "") { + parts.push(pendingNewlines); + pendingNewlines = ""; + } runCls = cls; runText = text; } @@ -204,262 +171,42 @@ function renderRougeStyleSpans(lines, isTb) { for (const tok of line) { if (tok.explanation && tok.explanation.length > 0) { for (const ex of tok.explanation) { - // Walk scopes outer -> inner (least specific -> most specific - // per TextMate convention). The first scope whose prefix is - // mapped wins, which lets a parent comment.block scope override - // an inner punctuation.definition.comment marker the way Rouge - // emits a single Comment::Multiline token for `/* ... */`. const scopes = (ex.scopes || []).map((s) => s.scopeName); - let cls = bestRougeClass(scopes); - // Rouge's JS / Python / Ruby / C / etc. lexers tag identifiers - // as Name::Other (nx) rather than Name::Variable (nv); tB - // alone uses Name::Variable for `Dim X`-style declarations. - // Remap when the language isn't tB. - if (!isTb && cls === "nv") cls = "nx"; - // Rouge's C / C++ lexers don't have a `variable.parameter` - // category; identifiers in parameter lists are just Name (n) - // even when Shiki tags them via `variable.parameter.*` in a - // lambda-capture context (e.g. `[out]` in IDL/COM API decls). - // Override for cpp / c scopes. - if (!isTb && (cls === "nx" || cls === "nv") - && /\.(cpp|c|h)$/.test(scopes[scopes.length - 1] || "")) { - cls = "n"; - } - // Rouge's JS lexer has an explicit `builtins` list (Array, - // Boolean, Date, ..., window, document, navigator, ...) - // -- identifiers in that list are tagged Name::Builtin (nb). - // Shiki's JS grammar tags them as `variable.other.object.js` - // / `variable.other.readwrite.js` -> nv -> nx (via the - // remap above). Override to nb when the literal matches a - // known builtin and the scope is .js. - if (!isTb && (cls === "nx" || cls === null) - && /\.js$/.test(scopes[scopes.length - 1] || "") - && JS_BUILTINS.has(ex.content)) { - cls = "nb"; - } - // Rouge's JS lexer classifies bare `var`/`let`/`const` as - // Keyword::Declaration (kd). In Shiki's JS grammar these - // tokens carry scope `storage.type.js` -- exactly the same - // shape (`storage.type.`, no specific keyword segment) - // tB uses for its type keywords (`storage.type.tb` -> - // String, Integer, ...). Disambiguate by language: for - // non-tB grammars whose specific scope is the bare - // `storage.type.` form, remap kt -> kd. - if (!isTb && cls === "kt" && STORAGE_TYPE_BARE_RE.test(scopes[scopes.length - 1] || "")) { - cls = "kd"; - } - // Rouge's JS lexer has a quirk where a function-CALL - // identifier containing an uppercase letter is tagged - // Name::Class (nc) instead of Name::Function (nf): - // `Foo()` -> nc, `foo()` -> nf. Function DEFINITIONS keep - // `nf` regardless of case (the upstream regex at - // rouge/lexers/javascript.rb#185 only fires when the - // identifier is immediately followed by `(...)` AND is not in - // the `function (...)` declaration position handled by - // line 187). Distinguish by the parent scope chain -- - // `meta.function-call.*` marks a call site, - // `meta.definition.function.*` marks a definition. - if (cls === "nf" - && /\.js$/.test(scopes[scopes.length - 1] || "") - && /^[$_]*\p{Lu}/u.test(ex.content) - && scopes.some((s) => s.startsWith("meta.function-call"))) { - cls = "nc"; - } - // Rouge's numeric tokens are typed by content: Num::Integer - // (mi), Num::Float (mf), Num::Hex (mh), Num::Oct (mo), etc. - // Many Shiki grammars tag every numeric as the generic - // `constant.numeric.decimal.` (-> `m`) without - // distinguishing the subtype. Look at the literal text and - // upgrade `m` to the right Rouge bucket. - if (cls === "m") { - const trimmed = ex.content; - if (/^0[xX][0-9a-fA-F_]+$/.test(trimmed)) cls = "mh"; - else if (/^0[oO][0-7_]+$/.test(trimmed)) cls = "mo"; - else if (/^0[bB][01_]+$/.test(trimmed)) cls = "mb"; - else if (/^[0-9_]+$/.test(trimmed)) cls = "mi"; - else if (/^[0-9._eE+-]+$/.test(trimmed) && /\./.test(trimmed)) cls = "mf"; - } - // Rouge's JS lexer (and several others) splits string tokens - // into delimiters (`"`, `'`) and content. The delimiter is - // tagged Str::Delimiter (`dl`), the content Str::Double (`s2`) - // or Str::Single (`s1`). tB and C alike use a single Str - // (`s`) for everything. Apply the split only when the scope - // chain has both a string scope and a definition marker -- - // and only for grammars where Rouge does this split, i.e. - // JavaScript here. (C / Ruby / etc. stay with the parent - // string class via DEFINITION_MARKER_RE fallthrough.) - const lastScope = scopes[scopes.length - 1] || ""; - if (!isTb && lastScope.startsWith("punctuation.definition.string") && /\.js$/.test(lastScope)) { - cls = "dl"; - } else if (!isTb && /^string\.quoted\.double\.js$/.test(lastScope)) { - cls = "s2"; - } else if (!isTb && /^string\.quoted\.single\.js$/.test(lastScope)) { - cls = "s1"; - } - // Rouge's JS lexer parses `...`, `?`, `:` (and the other - // structural single-char tokens) as Punctuation, not Operator. - // TextMate / Shiki tags `...` as `keyword.operator.spread.*`, - // `?` ternary as `keyword.operator.ternary.*`, etc. -- all of - // which map to `o` via the generic `keyword.operator` rule. - // Remap the punctuation-shaped sub-scopes back to `p` when the - // grammar isn't tB. - if (!isTb && cls === "o" && NONTB_PUNCT_OPERATOR_RE.test(scopes[scopes.length - 1] || "")) { - cls = "p"; - } - // Rouge tags unrecognised identifiers in the C / HTML / JS - // grammars as Name (class "n"); Shiki's bundled grammars - // leave them with no inner scope (just `source.`). When - // there's no class match and the token's trimmed text looks - // like an identifier, split off any leading / trailing - // whitespace and emit the identifier inside its own span. - if (cls === null) { - const m = ex.content.match(/^(\s*)([A-Za-z_]\w*)(\s*)$/); - if (m) { - if (m[1]) append(null, escapeHtml(m[1])); - append("n", escapeHtml(m[2])); - if (m[3]) append(null, escapeHtml(m[3])); - continue; - } - // For C / C++ specifically, Shiki sometimes returns one big - // unclassified token covering a parameter list / declaration - // tail when the grammar's higher-level scope (e.g. a lambda - // capture) consumes the start but doesn't break out the - // rest. Rouge instead tokenises each character: identifiers - // -> Name (n), brackets / commas / dots -> Punctuation (p). - // Re-tokenise the bulk content with a lightweight scanner - // when the parent grammar is C / C++. - if (!isTb && CPP_LIKE_RE.test(scopes[scopes.length - 1] || "") - && SPLITTABLE_TOKEN_RE.test(ex.content)) { - for (const m2 of ex.content.matchAll(CPP_TOKEN_RE)) { - if (m2[1] !== undefined) append("n", escapeHtml(m2[1])); - else if (m2[2] !== undefined) append("p", escapeHtml(m2[2])); - else if (m2[3] !== undefined) append(null, m2[3]); // whitespace - } - continue; - } - } + const cls = theme.classForScope(scopes); append(cls, escapeHtml(ex.content)); } } else { append(null, escapeHtml(tok.content)); } } - // The Rouge twinBASIC lexer's line-continuation rule is - // `_[ \t]*\n[ \t]*` -- it ALSO absorbs the leading whitespace of - // the following line into the same . Append the - // newline to the open lc run here; the matching whitespace-absorb - // case in `append()` folds the next line's indent into the same - // run when its first token is a cls=null whitespace token. - // - // For block comments (cm), defer the newline so it can be merged - // into the next run if the same scope continues -- Rouge emits one - // Comment::Multiline span for a multi-line `/* ... */`. Every other - // class is single-line in Rouge's output even when the surface - // class matches across two adjacent lines (e.g. consecutive keyword - // lines), so flush the run before the newline. - if (runCls === "lc") { - append("lc", "\n"); - } else if (runCls === "cm") { + // End of line: + // - lc runs: fold the newline into the span; the next line's + // leading whitespace is absorbed by the lcClass/cls=null + // branch in append(). + // - comment runs: defer the newline so a continuing comment on + // the next line can merge into the same span. + // - everything else: flush and park the newline for the gap + // between spans. + if (runCls === lcClass) { + append(lcClass, "\n"); + } else if (runCls === cmClass) { pendingNewlines += "\n"; } else { flush(); pendingNewlines += "\n"; } } - // End of input: flush any open run, then drop a single trailing - // newline (the Rouge trailing \n inside is supplied by the - // codeBody adjustment in renderCodeBlock). flush(); if (pendingNewlines !== "") { + // Drop the single trailing newline; renderCodeBlock already added + // one to codeBody. parts.push(pendingNewlines.slice(0, -1)); } return parts.join(""); } -function bestRougeClass(scopes) { - // Walk inner -> outer (most-specific scope first). When a scope chain - // is `[source, string.quoted.double, string.escape]`, the inner - // `string.escape` should win over the parent `string.quoted.double` - // so Rouge's `""` shape lands. - // - // For `punctuation.definition.*` markers attached to begin/end of a - // container scope (comment, string, etc.), Rouge tokenises the whole - // container as ONE token; fall through to the parent so the marker - // gets the container's class. For markers OUTSIDE a container scope - // (e.g. `punctuation.definition.capture.begin.lambda.cpp` whose only - // parent is `source.cpp`), keep matching the marker as `punctuation`. - for (let i = scopes.length - 1; i >= 0; i--) { - const scope = scopes[i]; - if (DEFINITION_MARKER_RE.test(scope) && hasContainerParent(scopes, i)) continue; - for (const [tmScope, cls] of SCOPE_TO_ROUGE_CLASS) { - if (scope === tmScope || scope.startsWith(tmScope + ".")) return cls; - } - } - return null; -} - -const DEFINITION_MARKER_RE = /(^|\.)definition\./; - -// Rouge's non-tB lexers (JS / TS / Ruby / Python / C / etc.) classify -// these tokens as Punctuation, not Operator. Shiki's TextMate grammars -// tag them under `keyword.operator.*` which maps to `o` via the generic -// rule -- remap to `p` for non-tB languages. -const NONTB_PUNCT_OPERATOR_RE = /^keyword\.operator\.(spread|rest|ternary|optional)\b/; - -// The bare `storage.type.` scope (no specific keyword segment in -// between). Used by JS for `let`/`const`/`var` and by tB for type -// keywords. Disambiguated per-language in the renderer. -const STORAGE_TYPE_BARE_RE = /^storage\.type\.[a-z]+$/; - -// Scopes whose unclassified bulk content should be re-tokenised by the -// lightweight identifier-and-punctuation scanner below. -const CPP_LIKE_RE = /\.(cpp|c|h|hpp|hxx)$/; - -// Cheap test: the bulk content actually contains punctuation worth -// splitting (not just whitespace or a single identifier). -const SPLITTABLE_TOKEN_RE = /[,;()\[\]{}.]|[A-Za-z_]\w+\s+[A-Za-z_]/; - -// One-pass scanner: identifier | punctuation | whitespace. Three -// alternation groups so the regex engine reports which fired. -const CPP_TOKEN_RE = /([A-Za-z_]\w*)|([,;()\[\]{}.])|(\s+)/g; - -// Rouge's JS lexer `builtins` list (rouge/lexers/javascript.rb#127). -// Identifiers in this set are tagged Name::Builtin (`nb`). -const JS_BUILTINS = new Set([ - "Array", "Boolean", "Date", "Error", "Function", "Math", "netscape", - "Number", "Object", "Packages", "RegExp", "String", "sun", "decodeURI", - "decodeURIComponent", "encodeURI", "encodeURIComponent", - "eval", "isFinite", "isNaN", "parseFloat", "parseInt", - "document", "window", "navigator", "self", "global", - "Promise", "Set", "Map", "WeakSet", "WeakMap", "Symbol", "Proxy", "Reflect", - "Int8Array", "Uint8Array", "Uint8ClampedArray", - "Int16Array", "Uint16Array", "Uint16ClampedArray", - "Int32Array", "Uint32Array", "Uint32ClampedArray", - "Float32Array", "Float64Array", "DataView", "ArrayBuffer", -]); - -// True when any outer scope in the chain is a `container` (one whose -// top-level component matches a Rouge "single-token wrapper" element -- -// comment/string/heredoc). Used by bestRougeClass to decide whether a -// `punctuation.definition.*` inner scope should fall through to the -// container's class. -const CONTAINER_TOP_LEVEL = new Set(["comment", "string"]); -function hasContainerParent(scopes, fromIdx) { - for (let j = fromIdx - 1; j >= 0; j--) { - const top = scopes[j].split(".")[0]; - if (CONTAINER_TOP_LEVEL.has(top)) return true; - } - return false; -} - -// Matches a token whose content is entirely word characters (identifier- -// like). Used by the non-tB fallback in renderRougeStyleSpans to tag -// unrecognised identifiers as Rouge's Name token (class "n"). -const IDENT_FALLBACK_RE = /^[A-Za-z_][\w]*$/; - -// Rouge's HTML formatter escapes only `& < >` -- NOT quotes. Match that -// so string literals inside code blocks keep their literal " character -// and our bytes match Jekyll's verbatim. +// Rouge's HTML formatter escapes only `& < >` -- not quotes. Match that +// so string literals inside code blocks keep their literal " character. const HTML_ESCAPE = { "&": "&", "<": "<", ">": ">" }; function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => HTML_ESCAPE[c]); diff --git a/builder/index.mjs b/builder/index.mjs index e4252c7e..f2a5edee 100644 --- a/builder/index.mjs +++ b/builder/index.mjs @@ -18,6 +18,7 @@ import process from "node:process"; import yaml from "js-yaml"; import { discover } from "./discover.mjs"; +import { regenerateMermaid } from "./mermaid.mjs"; import { computeNav } from "./nav.mjs"; import { precomputeSeo } from "./seo.mjs"; import { resolveBookChapters } from "./book.mjs"; @@ -101,6 +102,16 @@ async function main() { const destRoot = path.resolve(dest ?? path.join(srcRoot, "_site")); const t = makeTimer(); + + // Phase 11 (B1) preprocess: regenerate stale mermaid SVGs before + // discover walks the tree so freshly-emitted siblings land in + // staticFiles[] on this same build. + const mermaidStats = await regenerateMermaid(srcRoot); + t.lap("mermaid"); + if (mermaidStats.regenerated > 0) { + console.log(`mermaid: regenerated ${mermaidStats.regenerated} of ${mermaidStats.processed} SVG(s)`); + } + const { pages, staticFiles } = await discover(srcRoot); t.lap("discover"); @@ -117,7 +128,9 @@ async function main() { // Build the shared markdown-it instance up front so Phase 2's SEO // pass and Phase 3's body renderer use the same configured renderer. // initHighlighter overlaps with the running git shell-outs above. - const highlighter = await initHighlighter(); + const highlighter = await initHighlighter({ + copyButton: config.enable_copy_code_button !== false, + }); const linkTables = buildLinkTables(pages); const baseurl = String(config.baseurl || ""); const staticFileSet = new Set(staticFiles.map((s) => s.srcRel)); @@ -143,7 +156,15 @@ async function main() { await templatePhase(pages, site); t.lap("template"); - const writeStats = await writePhase(pages, staticFiles, { destRoot, dryRun }); + const generatedAssets = highlighter.themeCss + ? [{ rel: "assets/css/tb-highlight.css", content: highlighter.themeCss }] + : []; + const writeStats = await writePhase(pages, staticFiles, { + destRoot, + dryRun, + generatedAssets, + baseurl, + }); t.lap("write"); let auxStats = null; diff --git a/builder/mermaid.mjs b/builder/mermaid.mjs new file mode 100644 index 00000000..0f4a32db --- /dev/null +++ b/builder/mermaid.mjs @@ -0,0 +1,213 @@ +// Phase 11 (B1) mermaid preprocessor: regenerates +// `/assets/images/mmd/*.svg` from the matching `*.mmd` source +// when the SVG is missing or older than its source. Runs as the first +// orchestrator step so the freshly-emitted SVGs land in Phase 1's +// discover sweep naturally; the static-file pass downstream copies them +// to `/assets/images/mmd/` like any other tracked asset. +// +// Idempotent: a second build with no source changes is a no-op (mtime +// check). The `.mmd` is the canonical source; the SVG is a build +// artifact -- the hand-export workflow (Typora / mermaid live editor) +// the project used pre-B1 is no longer needed. Editing the .mmd by one +// character regenerates the SVG on the next build. +// +// Requires `@mermaid-js/mermaid-cli` at devDependency scope. The +// binary is invoked via `npx --no-install mmdc` with cwd rooted at +// `builder/` so npx searches `builder/node_modules/` for the local +// install. A missing mmdc (e.g. `npm install` not yet run in builder/) +// logs a warning and leaves the existing on-disk SVG untouched -- the +// build continues. Callers that need full B1 behaviour run +// `cd builder && npm install` once. + +import { promises as fs, readdirSync, existsSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BUILDER_ROOT = path.resolve(__dirname); +const MMD_REL_DIR = path.join("assets", "images", "mmd"); + +// Windows requires the `.cmd` extension AND `shell: true` when spawning +// the npx shim (otherwise spawn throws EINVAL); POSIX has the bare +// `npx` on PATH and runs without a shell. +const IS_WIN = process.platform === "win32"; +const NPX_CMD = IS_WIN ? "npx.cmd" : "npx"; + +export async function regenerateMermaid(srcRoot) { + const mmdRoot = path.join(srcRoot, MMD_REL_DIR); + const sources = await listMermaidSources(mmdRoot); + if (sources.length === 0) { + return { processed: 0, regenerated: 0 }; + } + + const stale = []; + for (const src of sources) { + const svg = svgFor(src); + if (!(await isUpToDate(svg, src))) stale.push({ src, svg }); + } + if (stale.length === 0) { + return { processed: sources.length, regenerated: 0 }; + } + + let regenerated = 0; + for (const { src, svg } of stale) { + const ok = await invokeMmdc(src, svg); + if (!ok) { + return { processed: sources.length, regenerated, skipped: true }; + } + regenerated++; + } + return { processed: sources.length, regenerated }; +} + +async function listMermaidSources(mmdRoot) { + try { + const entries = await fs.readdir(mmdRoot); + return entries + .filter((n) => n.endsWith(".mmd")) + .map((n) => path.join(mmdRoot, n)); + } catch (err) { + if (err.code === "ENOENT") return []; + throw err; + } +} + +function svgFor(src) { + return src.replace(/\.mmd$/, ".svg"); +} + +async function isUpToDate(svg, src) { + try { + const [srcStat, svgStat] = await Promise.all([ + fs.stat(src), + fs.stat(svg), + ]); + return svgStat.mtimeMs >= srcStat.mtimeMs; + } catch { + // SVG missing (or unstattable source -- the spawn will surface it). + return false; + } +} + +// Locate a Chrome binary the top-level puppeteer install already +// landed under `~/.cache/puppeteer/`. The site's PDF render step uses +// `puppeteer` at the repo root (and CI calls `npx puppeteer browsers +// install chrome --install-deps`); mermaid-cli's bundled puppeteer-core +// looks at the same cache. If a binary is present, mmdc reuses it via +// `PUPPETEER_EXECUTABLE_PATH` even when the cached Chrome version +// doesn't exactly match mermaid-cli's preferred version -- saves +// installing a second Chrome. +// +// Layout under /: +// chrome/-/chrome-/chrome[.exe] +// chrome-headless-shell/-/chrome-headless-shell-/chrome-headless-shell[.exe] +function findCachedChrome() { + const cacheDir = process.env.PUPPETEER_CACHE_DIR + || path.join(os.homedir(), ".cache", "puppeteer"); + // chrome-headless-shell is smaller and matches the CI-installed + // artifact when `chrome-headless-shell` is passed to the installer; + // chrome is the fuller binary. Try the smaller one first. + for (const variant of ["chrome-headless-shell", "chrome"]) { + const variantRoot = path.join(cacheDir, variant); + let versions = []; + try { + versions = readdirSync(variantRoot) + .filter((v) => /^[a-z0-9_]+-\d/.test(v)) + .sort() + .reverse(); + } catch { continue; } + for (const v of versions) { + const platformDirs = (() => { + try { + return readdirSync(path.join(variantRoot, v)) + .filter((n) => n.startsWith(`${variant}-`)); + } catch { return []; } + })(); + for (const platformDir of platformDirs) { + const exe = IS_WIN ? `${variant}.exe` : variant; + const candidate = path.join(variantRoot, v, platformDir, exe); + if (existsSync(candidate)) return candidate; + } + } + } + return null; +} + +// Pull the most informative line out of mmdc's stderr -- prefer an +// explicit `Error: ...` line over the trailing stack frame. Covers the +// two failure modes the build is likely to hit: mmdc not installed, +// or Chrome (puppeteer's runtime) not installed. +function explainMmdcFailure(stderr, code) { + if (/\bcould not (find|determine).*\bmmdc\b|cannot find module.*\bmermaid\b|\bnot installed\b(?!.*Chrome)|\b404\b/i.test(stderr)) { + return "mmdc not installed; run `cd builder && npm install`"; + } + if (/Could not find Chrome|puppeteer browsers install/i.test(stderr)) { + return "Chrome runtime missing; run `npx puppeteer browsers install chrome-headless-shell`"; + } + const errLine = stderr + .split(/\r?\n/) + .map((s) => s.trim()) + .find((s) => s.startsWith("Error:") || /^[A-Z][a-zA-Z]+Error:/.test(s)); + if (errLine) return errLine; + return `mmdc exited ${code}`; +} + +function invokeMmdc(src, svg) { + return new Promise((resolve) => { + const args = [ + "--no-install", + "mmdc", + "-i", src, + "-o", svg, + "-b", "transparent", + ]; + // On Windows we shell out to cmd.exe so the `.cmd` shim resolves. + // Wrap any whitespace-bearing path argument in double quotes so + // cmd.exe parses it as a single token. + const finalArgs = IS_WIN + ? args.map((a) => /\s/.test(a) ? `"${a.replace(/"/g, '""')}"` : a) + : args; + // Reuse any Chrome the top-level `puppeteer` (used by the PDF + // render step) already installed -- avoids needing a second Chrome + // download just for mermaid. mmdc's puppeteer-core may complain + // about the version, but the API is generally backwards-compatible + // across a few major Chrome releases. + const env = { ...process.env }; + if (!env.PUPPETEER_EXECUTABLE_PATH) { + const chrome = findCachedChrome(); + if (chrome) env.PUPPETEER_EXECUTABLE_PATH = chrome; + } + const child = spawn(NPX_CMD, finalArgs, { + cwd: BUILDER_ROOT, + stdio: ["ignore", "pipe", "pipe"], + shell: IS_WIN, + env, + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", (err) => { + const why = err.code === "ENOENT" + ? "npx not found on PATH (install Node.js + npm)" + : err.message; + console.warn( + `mermaid: skipped ${path.basename(src)} (${why}); existing SVG retained`, + ); + resolve(false); + }); + child.on("close", (code) => { + if (code === 0) { + resolve(true); + return; + } + const detail = explainMmdcFailure(stderr, code); + console.warn( + `mermaid: skipped ${path.basename(src)} (${detail}); existing SVG retained`, + ); + resolve(false); + }); + }); +} diff --git a/builder/offline.mjs b/builder/offline.mjs index 02907805..9d72fb00 100644 --- a/builder/offline.mjs +++ b/builder/offline.mjs @@ -26,6 +26,9 @@ import { promises as fs } from "node:fs"; import { existsSync } from "node:fs"; import path from "node:path"; +import * as acorn from "acorn"; +import * as acornWalk from "acorn-walk"; + import { WRITE_LIMIT, isUnderProject, @@ -739,9 +742,6 @@ function rewriteCss(css, fileDir, fileSegs, sitePaths, caches, baseurl) { // §G just-the-docs.js patches + search-data.js wrapper // --------------------------------------------------------------------------- -const JTD_NAVLINK_RE = /function navLink\(\) \{[\s\S]*?return null; \/\/ avoids `undefined`\s*\}/; -const JTD_INITSEARCH_FN_RE = /function initSearch\(\) \{[\s\S]*?request\.send\(\);\s*\}/; - // The "Patched by _plugins/offlinify.rb" comment strings are kept // verbatim from the Ruby Offlinify constants so the patched JS is // byte-identical to Jekyll's _site-offline/assets/js/just-the-docs.js. @@ -841,35 +841,65 @@ async function patchJustTheDocsJs(srcPath, destPath) { // just-the-docs.js source string. Returns `{ js, patches, warnings }`. // `warnings` is an array of warning lines for any patch that couldn't // be located -- the caller decides whether to log them. +// +// Phase 11 (B11): parses just-the-docs.js with acorn, walks for +// FunctionDeclaration nodes named `navLink` / `initSearch`, and +// replaces them by string-slicing at the node ranges -- so the +// non-patched regions stay byte-identical to the upstream source. +// Survives cosmetic upstream edits (variable renames, whitespace +// changes inside the function body) that would have broken anchored +// regex matches. +// +// `just-the-docs.js` is a vendored asset in `builder/assets/js/`; it +// only changes when the operator deliberately re-extracts after a +// gem bump. A parse error here means that re-extraction landed +// something acorn can't read -- fix it at the time, no defensive +// fallback shimmed in. export function deriveOfflineJtdJs(src) { + const ast = acorn.parse(src, { + ecmaVersion: "latest", + sourceType: "script", + ranges: true, + }); + + const edits = []; + acornWalk.simple(ast, { + FunctionDeclaration(node) { + if (!node.id) return; + if (node.id.name === "navLink") { + edits.push({ start: node.start, end: node.end, replacement: JTD_NAVLINK_REPLACEMENT, label: "navLink()" }); + } else if (node.id.name === "initSearch") { + edits.push({ start: node.start, end: node.end, replacement: JTD_INITSEARCH_FN_REPLACEMENT, label: "initSearch()" }); + } + }, + }); + + // Apply edits right-to-left so earlier offsets stay valid. + edits.sort((a, b) => b.start - a.start); let out = src; - const patches = []; - const warnings = []; + for (const e of edits) { + out = out.slice(0, e.start) + e.replacement + out.slice(e.end); + } + // Patch list in source order for nicer log output. + const patches = edits + .slice() + .sort((a, b) => a.start - b.start) + .map((e) => e.label); - let next = out.replace(JTD_NAVLINK_RE, JTD_NAVLINK_REPLACEMENT); - if (next !== out) { - patches.push("navLink()"); - out = next; - } else { + const warnings = []; + const found = new Set(patches); + if (!found.has("navLink()")) { warnings.push( - "offline: could not locate navLink() in just-the-docs.js -- " + - "nav-active detection will be broken under file://. Update " + - "JTD_NAVLINK_RE in builder/offline.mjs.", + "offline: AST walk found no navLink() declaration in just-the-docs.js -- " + + "nav-active detection will be broken under file://.", ); } - - next = out.replace(JTD_INITSEARCH_FN_RE, JTD_INITSEARCH_FN_REPLACEMENT); - if (next !== out) { - patches.push("initSearch()"); - out = next; - } else { + if (!found.has("initSearch()")) { warnings.push( - "offline: could not locate initSearch() in just-the-docs.js -- " + - "offline search will not work. Update JTD_INITSEARCH_FN_RE in " + - "builder/offline.mjs.", + "offline: AST walk found no initSearch() declaration in just-the-docs.js -- " + + "offline search will not work.", ); } - return { js: out, patches, warnings }; } @@ -883,9 +913,15 @@ async function writeSearchDataJs(destPath, jsonBytes) { } // Pure-compute: wrap the search-data.json bytes as a JS global so a -// \n` + ` \n` + ` \n` + + ` \n` + ` \n` + `

A

B

C

D

\ No newline at end of file +

A

B

C

D

\ No newline at end of file diff --git a/scripts/crawl_check.mjs b/scripts/crawl_check.mjs new file mode 100644 index 00000000..ce3a0a23 --- /dev/null +++ b/scripts/crawl_check.mjs @@ -0,0 +1,258 @@ +// External-link crawler for a deployed site. Starts at a URL, +// recursively GETs every same-origin/same-basepath page, extracts +// links, and verifies each link responds 2xx (HEAD for cross-origin, +// GET for same-origin since we need the HTML anyway). +// +// Usage: +// node scripts/crawl_check.mjs [--concurrency N] [--timeout MS] +// node scripts/crawl_check.mjs --skip-external +// +// Exits 0 if all links are reachable, 1 if any are broken. + +import { Parser } from "htmlparser2"; + +const args = process.argv.slice(2); +let startArg = null; +let concurrency = 10; +let timeoutMs = 15000; +let skipExternal = false; +for (let i = 0; i < args.length; i++) { + if (args[i] === "--concurrency") concurrency = Number(args[++i]); + else if (args[i] === "--timeout") timeoutMs = Number(args[++i]); + else if (args[i] === "--skip-external") skipExternal = true; + else if (args[i].startsWith("-")) { console.error(`unknown flag: ${args[i]}`); process.exit(2); } + else if (!startArg) startArg = args[i]; +} +if (!startArg) { + console.error("usage: node scripts/crawl_check.mjs [--concurrency N] [--timeout MS] [--skip-external]"); + process.exit(2); +} + +const startUrl = new URL(startArg); +const origin = startUrl.origin; +const basePath = startUrl.pathname.endsWith("/") ? startUrl.pathname : startUrl.pathname + "/"; + +const SKIP_SCHEMES = /^(mailto:|tel:|javascript:|data:|ftp:)/i; +const FRAGMENT_TARGETS = new Map(); // url (no fragment) -> Set of ids found + +const crawlQueue = [startUrl.href]; +const crawled = new Set(); +const linkStatus = new Map(); // url -> { ok, status, error?, redirected? } +const linkSources = new Map(); // url -> Set of source pages +const linkFragments = new Map(); // url (no fragment) -> Set of [fragment, source] + +function shouldSkip(href) { + if (!href) return true; + if (href.startsWith("#")) return true; + if (SKIP_SCHEMES.test(href)) return true; + return false; +} + +function splitFragment(url) { + const i = url.indexOf("#"); + if (i < 0) return [url, null]; + return [url.slice(0, i), url.slice(i + 1)]; +} + +function isCrawlable(url) { + return url.origin === origin && url.pathname.startsWith(basePath); +} + +async function fetchWithTimeout(url, options) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal, redirect: "follow" }); + } finally { + clearTimeout(timer); + } +} + +async function checkUrl(url) { + if (linkStatus.has(url)) return linkStatus.get(url); + let result; + try { + let res = await fetchWithTimeout(url, { method: "HEAD" }); + if (res.status === 405 || res.status === 501) { + res = await fetchWithTimeout(url, { method: "GET" }); + } + result = { + ok: res.ok, + status: res.status, + redirected: res.redirected ? res.url : null, + }; + } catch (e) { + result = { ok: false, status: 0, error: e.name === "AbortError" ? "timeout" : e.message }; + } + linkStatus.set(url, result); + return result; +} + +function extractFromHtml(html) { + const links = []; + const ids = new Set(); + const parser = new Parser({ + onopentag(name, attrs) { + if (attrs.id) ids.add(attrs.id); + if (attrs.name && (name === "a" || name === "input")) ids.add(attrs.name); + if (name === "a" && attrs.href) links.push(attrs.href); + else if (name === "link" && attrs.href) links.push(attrs.href); + else if (name === "img" && attrs.src) links.push(attrs.src); + else if (name === "script" && attrs.src) links.push(attrs.src); + else if (name === "iframe" && attrs.src) links.push(attrs.src); + }, + }); + parser.write(html); + parser.end(); + return { links, ids }; +} + +async function crawlOne(url) { + let res; + try { + res = await fetchWithTimeout(url, { method: "GET" }); + } catch (e) { + linkStatus.set(url, { ok: false, status: 0, error: e.name === "AbortError" ? "timeout" : e.message }); + return; + } + linkStatus.set(url, { + ok: res.ok, + status: res.status, + redirected: res.redirected ? res.url : null, + }); + if (!res.ok) return; + + const ct = res.headers.get("content-type") || ""; + if (!ct.includes("text/html") && !ct.includes("xhtml")) return; + + let html; + try { html = await res.text(); } catch { return; } + const { links, ids } = extractFromHtml(html); + + // Index ids on the final (redirected) URL so fragment links resolve. + FRAGMENT_TARGETS.set(res.url.split("#")[0], ids); + FRAGMENT_TARGETS.set(url, ids); + + for (const rawHref of links) { + if (shouldSkip(rawHref)) continue; + let abs; + try { abs = new URL(rawHref, res.url); } catch { continue; } + if (abs.protocol !== "http:" && abs.protocol !== "https:") continue; + + const [bare, frag] = splitFragment(abs.href); + + if (!linkSources.has(bare)) linkSources.set(bare, new Set()); + linkSources.get(bare).add(url); + + if (frag) { + if (!linkFragments.has(bare)) linkFragments.set(bare, []); + linkFragments.get(bare).push({ frag, source: url }); + } + + if (isCrawlable(abs)) { + if (!crawled.has(bare)) crawlQueue.push(bare); + } + } +} + +async function workerPool(items, fn, n) { + let i = 0; + const workers = []; + for (let k = 0; k < n; k++) { + workers.push((async () => { + while (i < items.length) { + const idx = i++; + await fn(items[idx]); + } + })()); + } + await Promise.all(workers); +} + +async function main() { + const t0 = Date.now(); + process.stderr.write(`Crawling ${startUrl.href}\n`); + process.stderr.write(` origin=${origin} basePath=${basePath} concurrency=${concurrency}\n\n`); + + // Phase 1: BFS crawl same-origin pages. + while (crawlQueue.length > 0) { + const batch = []; + while (crawlQueue.length > 0 && batch.length < concurrency * 4) { + const u = crawlQueue.shift(); + if (crawled.has(u)) continue; + crawled.add(u); + batch.push(u); + } + if (batch.length === 0) continue; + process.stderr.write(` [crawl] ${crawled.size} pages discovered, processing batch of ${batch.length}...\n`); + await workerPool(batch, crawlOne, concurrency); + } + + // Phase 2: check every external link that hasn't been visited. + const toCheck = []; + for (const url of linkSources.keys()) { + if (linkStatus.has(url)) continue; + if (skipExternal && new URL(url).origin !== origin) continue; + toCheck.push(url); + } + if (toCheck.length > 0) { + process.stderr.write(`\n [check] ${toCheck.length} external links to verify...\n`); + await workerPool(toCheck, checkUrl, concurrency); + } + + // Report. + const broken = []; + const fragmentMisses = []; + for (const [url, status] of linkStatus) { + if (!status.ok) { + const sources = [...(linkSources.get(url) || [])]; + broken.push({ url, status, sources }); + } + } + for (const [bare, frags] of linkFragments) { + const ids = FRAGMENT_TARGETS.get(bare); + if (!ids) continue; // not crawled (external) -- skip fragment check + const seen = new Set(); + for (const { frag, source } of frags) { + if (ids.has(frag)) continue; + const key = `${bare}#${frag}<-${source}`; + if (seen.has(key)) continue; + seen.add(key); + fragmentMisses.push({ url: bare, frag, source }); + } + } + + const elapsed = ((Date.now() - t0) / 1000).toFixed(1); + console.log(``); + console.log(`Crawl complete in ${elapsed}s:`); + console.log(` Pages crawled: ${crawled.size}`); + console.log(` Unique links: ${linkSources.size}`); + console.log(` Status checks: ${linkStatus.size}`); + console.log(` Broken links: ${broken.length}`); + console.log(` Missing anchors: ${fragmentMisses.length}`); + + if (broken.length > 0) { + broken.sort((a, b) => a.url.localeCompare(b.url)); + console.log(`\nBroken links:`); + for (const b of broken) { + const tag = b.status.error ? `ERR ${b.status.error}` : `${b.status.status}`; + console.log(` [${tag}] ${b.url}`); + const srcs = b.sources.slice().sort(); + for (const s of srcs.slice(0, 5)) console.log(` on: ${s}`); + if (srcs.length > 5) console.log(` ... and ${srcs.length - 5} more`); + } + } + + if (fragmentMisses.length > 0) { + fragmentMisses.sort((a, b) => (a.url + a.frag).localeCompare(b.url + b.frag)); + console.log(`\nMissing fragments:`); + for (const f of fragmentMisses) { + console.log(` ${f.url}#${f.frag}`); + console.log(` on: ${f.source}`); + } + } + + process.exit((broken.length > 0 || fragmentMisses.length > 0) ? 1 : 0); +} + +main().catch((e) => { console.error(e); process.exit(2); }); diff --git a/scripts/extract_theme_colors.py b/scripts/extract_theme_colors.py deleted file mode 100644 index 1270cd24..00000000 --- a/scripts/extract_theme_colors.py +++ /dev/null @@ -1,246 +0,0 @@ -"""Extract Symbol* syntax-highlighting properties from twinBASIC IDE .theme files -and emit Rouge-compatible CSS/SCSS. - -Two outputs per run: - -1. scripts/themes/twinbasic-{classic,dark,light}.css -- flat CSS for inspection. - One rule per Rouge HTML formatter class (e.g. .k, .nc, .cp) that - docs/_plugins/twinbasic.rb emits, with colors and font properties taken - from the corresponding tB theme Symbol. - -2. docs/_sass/custom/_twinbasic-{light,dark}.scss -- SCSS partials shipped in - the site, scoped under `.language-tb .highlight` so they only repaint - fenced ```tb``` code blocks and leave OneLight/OneDark untouched on every - other language. Classic is inspection-only (no SCSS). - -The mapping is many-to-one: several tB theme Symbols share a single Rouge -class because the lexer doesn't distinguish them (e.g. SymbolSub folds into -.nf alongside SymbolFunction). The mapping below picks the canonical tB -Symbol per Rouge class; the unmapped Symbols are listed at the bottom of -each emitted file for reference. - -The Classic theme inherits from Light and overrides a subset; the script -resolves the inheritance so the emitted CSS reflects the effective theme. -""" - -import os -import re -from pathlib import Path - -THEMES_DIR = Path(os.environ["USERPROFILE"]) / "Desktop" / "twinBASIC_IDE_BETA_982" / "themes" -REPO_ROOT = Path(__file__).resolve().parent.parent -CSS_OUT_DIR = REPO_ROOT / "scripts" / "themes" -SCSS_OUT_DIR = REPO_ROOT / "docs" / "_sass" / "custom" - -CSS_PROP = { - "Color": "color", - "FontStyle": "font-style", - "FontWeight": "font-weight", - "TextDecoration": "text-decoration", -} - -# Rouge HTML formatter class -> tB theme Symbol (canonical source for colors). -# Only includes Rouge classes that twinbasic.rb actually emits. Order here is -# the order the rules appear in the emitted CSS. -ROUGE_TO_SYMBOL: list[tuple[str, str, str]] = [ - ("c1", "Comment", "' comments and REM"), - ("cm", "Comment", "C-style block comments"), - ("cp", "ConditionalCompilationDirective", "#If / #ElseIf / #Else / #End If / #Const / #Region"), - ("k", "Keyword", "Dim, If, End, Sub, ..."), - ("kd", "Keyword", "Option Strict / Explicit / Compare / Base"), - ("kt", "BuiltInDataType", "Boolean, Integer, String, ..."), - ("lb", "LiteralBoolean", "True, False"), - ("lc", "ContinuationCharacter", "'_' line-continuation marker"), - ("ld", "LiteralDate", "#m/d/yyyy [h:mm:ss am/pm]# date-time literals"), - ("le", "LiteralEmpty", "Empty"), - ("ln", "LiteralNothing", "Nothing"), - ("lu", "LiteralNull", "Null"), - ("mi", "LiteralNumeric", "integer literals"), - ("mf", "LiteralNumeric", "float literals"), - ("s", "LiteralString", "string literals"), - ("se", "LiteralString", "\"\" escape inside string literals"), - ("o", "Operator", "+, -, =, <, >, &, ..."), - ("ow", "NamedOperator", "And, Or, Not, Is, Mod, ..."), - ("na", "Attribute", "[Documentation(...)] attribute names"), - ("nb", "Class", "Debug, Err"), - ("nc", "Class", "Class / CoClass / Enum / Interface / Type / Structure names"), - ("nf", "Function", "Function / Sub / Property names"), - ("nn", "Module", "Module / Namespace / Imports targets"), - ("nv", "Variable", "Dim / Const / ReDim variable names"), -] - -# tB Symbols that have no Rouge counterpart from twinbasic.rb. The lexer either -# doesn't tokenize them at all or folds them into a broader Rouge token (in which -# case the canonical Symbol in ROUGE_TO_SYMBOL wins). -UNMAPPED_SYMBOLS: list[tuple[str, str]] = [ - ("ConditionalCompilationExcludedCode", "not tokenized — would need preproc evaluation"), - ("Constant", "not distinguished from Name"), - ("DeclareFunction", "not distinguished from Keyword .k"), - ("DeclareSub", "not distinguished from Keyword .k"), - ("Enum", "folded into Name::Class .nc"), - ("EnumMember", "not distinguished from Name"), - ("Field", "not distinguished from Name"), - ("GenericDataType", "not tokenized"), - ("GenericValue", "not tokenized"), - ("GlobalVariablePrivate", "not distinguished from Name"), - ("GlobalVariablePublic", "not distinguished from Name"), - ("Interface", "folded into Name::Class .nc"), - ("LateBoundFunction", "not distinguished from Name"), - ("Library", "not distinguished from Name"), - ("LineLabel", "not tokenized"), - ("LineNumber", "not tokenized"), - ("Me", "folded into Keyword .k"), - ("MultiLineSeperator", "not tokenized"), - ("NamedArgument", "not tokenized"), - ("ParamByRef", "not tokenized"), - ("ParamByVal", "not tokenized"), - ("PropertyGet", "folded into Keyword .k"), - ("PropertyLet", "folded into Keyword .k"), - ("PropertySet", "folded into Keyword .k"), - ("ReturnValue", "not tokenized"), - ("Sub", "folded into Name::Function .nf"), - ("UDT", "folded into Name::Class .nc"), - ("VariableUndeclared", "not distinguished from Name"), -] - -PROPERTY_LINE = re.compile(r"^([A-Za-z][A-Za-z0-9_]*)\s*:\s*(.+?)\s*;?\s*$") -SYMBOL_PROP = re.compile(r"^Symbol([A-Za-z]+?)(Color|FontStyle|FontWeight|TextDecoration)$") - - -def parse_theme(path: Path) -> dict[str, str]: - """Parse a .theme file into a flat `property -> value` dict. Properties with - an empty value (the IDE theme's `Name: ;` fall-back-to-parent form) are - omitted.""" - result: dict[str, str] = {} - text = re.sub(r"/\*.*?\*/", "", path.read_text(encoding="utf-8"), flags=re.DOTALL) - for raw in text.splitlines(): - m = PROPERTY_LINE.match(raw.strip()) - if not m: - continue - name, value = m.group(1), m.group(2).strip().rstrip(";").strip() - if not value: - continue - result[name] = value - return result - - -def symbol_props(theme: dict[str, str]) -> dict[str, dict[str, str]]: - """Filter a flat theme dict down to its Symbol* entries, grouped by Symbol - name and keyed by the bare property suffix (Color / FontStyle / ...).""" - grouped: dict[str, dict[str, str]] = {} - for name, value in theme.items(): - m = SYMBOL_PROP.match(name) - if not m: - continue - sym, prop = m.group(1), m.group(2) - grouped.setdefault(sym, {})[prop] = value - return grouped - - -def render_css(theme: dict[str, dict[str, str]], header: str) -> str: - out = [f"/* {header} */\n"] - out.append("/* Selectors are the Rouge HTML formatter classes emitted by docs/_plugins/twinbasic.rb. */\n\n") - - for rouge, sym, comment in ROUGE_TO_SYMBOL: - props = theme.get(sym) - if not props: - continue - out.append(f".{rouge} {{ /* Symbol{sym} — {comment} */\n") - for key in ("Color", "FontStyle", "FontWeight", "TextDecoration"): - if key in props: - out.append(f" {CSS_PROP[key]}: {props[key]};\n") - out.append("}\n\n") - - out.append("/* tB Symbols with no dedicated Rouge class in twinbasic.rb: */\n") - for sym, why in UNMAPPED_SYMBOLS: - out.append(f"/* Symbol{sym} — {why} */\n") - - return "".join(out) - - -def render_scss(theme: dict[str, dict[str, str]], header: str, code_bg: str | None = None) -> str: - out = [f"/* {header} */\n"] - out.append("/* Selectors are the Rouge HTML formatter classes emitted by docs/_plugins/twinbasic.rb. */\n") - out.append("/* Scoped under .language-tb .highlight so they only repaint tB fenced code blocks. */\n\n") - out.append(".language-tb .highlight {\n") - - rules = [] - for rouge, sym, comment in ROUGE_TO_SYMBOL: - props = theme.get(sym) - if not props: - continue - rule = [f" .{rouge} {{ /* Symbol{sym} — {comment} */\n"] - for key in ("Color", "FontStyle", "FontWeight", "TextDecoration"): - if key in props: - rule.append(f" {CSS_PROP[key]}: {props[key]};\n") - rule.append(" }\n") - rules.append("".join(rule)) - out.append("\n".join(rules)) - out.append("}\n") - - if code_bg: - out.append("\n") - out.append("/* tB CodePanelBackColor scoped to tB code-block containers (.language-tb). */\n") - out.append("/* The .language-tb class lives on the outer .highlighter-rouge div emitted */\n") - out.append("/* by kramdown for ```tb``` fenced blocks, so `.language-tb.highlighter-rouge` */\n") - out.append("/* (no space) hits the outer container and `.language-tb ` hits */\n") - out.append("/* the nested .highlight / pre / etc. The partial is imported inside */\n") - out.append("/* `html.dark-mode { ... }` by just-the-docs-combined.scss, so SCSS nesting */\n") - out.append("/* confines these rules to dark mode automatically. */\n") - out.append(".language-tb.highlighter-rouge,\n") - out.append(".language-tb .highlight,\n") - out.append(".language-tb pre.highlight,\n") - out.append(".language-tb .highlight pre {\n") - out.append(f" background-color: {code_bg};\n") - out.append("}\n") - - return "".join(out) - - -def main() -> None: - light = parse_theme(THEMES_DIR / "Light.theme") - dark = parse_theme(THEMES_DIR / "Dark.theme") - classic = parse_theme(THEMES_DIR / "Classic.theme") - - light_syms = symbol_props(light) - dark_syms = symbol_props(dark) - classic_syms = {sym: dict(props) for sym, props in light_syms.items()} - for sym, props in symbol_props(classic).items(): - classic_syms.setdefault(sym, {}).update(props) - - # Flat CSS for inspection. - CSS_OUT_DIR.mkdir(parents=True, exist_ok=True) - (CSS_OUT_DIR / "twinbasic-light.css").write_text( - render_css(light_syms, "twinBASIC Light theme - Rouge syntax highlighting"), - encoding="utf-8", - ) - (CSS_OUT_DIR / "twinbasic-dark.css").write_text( - render_css(dark_syms, "twinBASIC Dark theme - Rouge syntax highlighting"), - encoding="utf-8", - ) - (CSS_OUT_DIR / "twinbasic-classic.css").write_text( - render_css( - classic_syms, - "twinBASIC Classic theme - Rouge syntax highlighting (Light + Classic overrides)", - ), - encoding="utf-8", - ) - - # SCSS partials shipped in the site (classic is inspection-only). - SCSS_OUT_DIR.mkdir(parents=True, exist_ok=True) - (SCSS_OUT_DIR / "_twinbasic-light.scss").write_text( - render_scss(light_syms, "twinBASIC Light theme - Rouge syntax highlighting"), - encoding="utf-8", - ) - (SCSS_OUT_DIR / "_twinbasic-dark.scss").write_text( - render_scss( - dark_syms, - "twinBASIC Dark theme - Rouge syntax highlighting", - code_bg=dark.get("CodePanelBackColor"), - ), - encoding="utf-8", - ) - - -if __name__ == "__main__": - main() diff --git a/scripts/themes/twinbasic-classic.css b/scripts/themes/twinbasic-classic.css deleted file mode 100644 index a1cd7a57..00000000 --- a/scripts/themes/twinbasic-classic.css +++ /dev/null @@ -1,200 +0,0 @@ -/* twinBASIC Classic theme - Rouge syntax highlighting (Light + Classic overrides) */ -/* Selectors are the Rouge HTML formatter classes emitted by docs/_plugins/twinbasic.rb. */ - -.c1 { /* SymbolComment — ' comments and REM */ - color: #00801D; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.cm { /* SymbolComment — C-style block comments */ - color: #00801D; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.cp { /* SymbolConditionalCompilationDirective — #If / #ElseIf / #Else / #End If / #Const / #Region */ - color: #ad8c98; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.k { /* SymbolKeyword — Dim, If, End, Sub, ... */ - color: #002DA6; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.kd { /* SymbolKeyword — Option Strict / Explicit / Compare / Base */ - color: #002DA6; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.kt { /* SymbolBuiltInDataType — Boolean, Integer, String, ... */ - color: #002DA6; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.lb { /* SymbolLiteralBoolean — True, False */ - color: #002DA6; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.lc { /* SymbolContinuationCharacter — '_' line-continuation marker */ - color: #808080; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.ld { /* SymbolLiteralDate — #m/d/yyyy [h:mm:ss am/pm]# date-time literals */ - color: #002DA6; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.le { /* SymbolLiteralEmpty — Empty */ - color: #002DA6; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.ln { /* SymbolLiteralNothing — Nothing */ - color: #002DA6; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.lu { /* SymbolLiteralNull — Null */ - color: #002DA6; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.mi { /* SymbolLiteralNumeric — integer literals */ - color: #000000; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.mf { /* SymbolLiteralNumeric — float literals */ - color: #000000; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.s { /* SymbolLiteralString — string literals */ - color: #000000; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.se { /* SymbolLiteralString — "" escape inside string literals */ - color: #000000; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.o { /* SymbolOperator — +, -, =, <, >, &, ... */ - color: #000000; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.ow { /* SymbolNamedOperator — And, Or, Not, Is, Mod, ... */ - color: #002DA6; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.na { /* SymbolAttribute — [Documentation(...)] attribute names */ - color: #bfbfbf; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.nb { /* SymbolClass — Debug, Err */ - color: #000000; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.nc { /* SymbolClass — Class / CoClass / Enum / Interface / Type / Structure names */ - color: #000000; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.nf { /* SymbolFunction — Function / Sub / Property names */ - color: #000000; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.nn { /* SymbolModule — Module / Namespace / Imports targets */ - color: #000000; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.nv { /* SymbolVariable — Dim / Const / ReDim variable names */ - color: #000000; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -/* tB Symbols with no dedicated Rouge class in twinbasic.rb: */ -/* SymbolConditionalCompilationExcludedCode — not tokenized — would need preproc evaluation */ -/* SymbolConstant — not distinguished from Name */ -/* SymbolDeclareFunction — not distinguished from Keyword .k */ -/* SymbolDeclareSub — not distinguished from Keyword .k */ -/* SymbolEnum — folded into Name::Class .nc */ -/* SymbolEnumMember — not distinguished from Name */ -/* SymbolField — not distinguished from Name */ -/* SymbolGenericDataType — not tokenized */ -/* SymbolGenericValue — not tokenized */ -/* SymbolGlobalVariablePrivate — not distinguished from Name */ -/* SymbolGlobalVariablePublic — not distinguished from Name */ -/* SymbolInterface — folded into Name::Class .nc */ -/* SymbolLateBoundFunction — not distinguished from Name */ -/* SymbolLibrary — not distinguished from Name */ -/* SymbolLineLabel — not tokenized */ -/* SymbolLineNumber — not tokenized */ -/* SymbolMe — folded into Keyword .k */ -/* SymbolMultiLineSeperator — not tokenized */ -/* SymbolNamedArgument — not tokenized */ -/* SymbolParamByRef — not tokenized */ -/* SymbolParamByVal — not tokenized */ -/* SymbolPropertyGet — folded into Keyword .k */ -/* SymbolPropertyLet — folded into Keyword .k */ -/* SymbolPropertySet — folded into Keyword .k */ -/* SymbolReturnValue — not tokenized */ -/* SymbolSub — folded into Name::Function .nf */ -/* SymbolUDT — folded into Name::Class .nc */ -/* SymbolVariableUndeclared — not distinguished from Name */ diff --git a/scripts/themes/twinbasic-dark.css b/scripts/themes/twinbasic-dark.css deleted file mode 100644 index 89bb56d8..00000000 --- a/scripts/themes/twinbasic-dark.css +++ /dev/null @@ -1,200 +0,0 @@ -/* twinBASIC Dark theme - Rouge syntax highlighting */ -/* Selectors are the Rouge HTML formatter classes emitted by docs/_plugins/twinbasic.rb. */ - -.c1 { /* SymbolComment — ' comments and REM */ - color: #448a63; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.cm { /* SymbolComment — C-style block comments */ - color: #448a63; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.cp { /* SymbolConditionalCompilationDirective — #If / #ElseIf / #Else / #End If / #Const / #Region */ - color: #ad8c98; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.k { /* SymbolKeyword — Dim, If, End, Sub, ... */ - color: #6c8eda; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.kd { /* SymbolKeyword — Option Strict / Explicit / Compare / Base */ - color: #6c8eda; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.kt { /* SymbolBuiltInDataType — Boolean, Integer, String, ... */ - color: #b1551f; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.lb { /* SymbolLiteralBoolean — True, False */ - color: #c495d3; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.lc { /* SymbolContinuationCharacter — '_' line-continuation marker */ - color: #808080; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.ld { /* SymbolLiteralDate — #m/d/yyyy [h:mm:ss am/pm]# date-time literals */ - color: #c495d3; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.le { /* SymbolLiteralEmpty — Empty */ - color: #c495d3; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.ln { /* SymbolLiteralNothing — Nothing */ - color: #c495d3; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.lu { /* SymbolLiteralNull — Null */ - color: #c495d3; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.mi { /* SymbolLiteralNumeric — integer literals */ - color: #aeca89; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.mf { /* SymbolLiteralNumeric — float literals */ - color: #aeca89; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.s { /* SymbolLiteralString — string literals */ - color: #aeca89; - font-style: oblique; - font-weight: normal; - text-decoration: none; -} - -.se { /* SymbolLiteralString — "" escape inside string literals */ - color: #aeca89; - font-style: oblique; - font-weight: normal; - text-decoration: none; -} - -.o { /* SymbolOperator — +, -, =, <, >, &, ... */ - color: #80a1a5; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.ow { /* SymbolNamedOperator — And, Or, Not, Is, Mod, ... */ - color: #80a1a5; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.na { /* SymbolAttribute — [Documentation(...)] attribute names */ - color: #5c5c53; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.nb { /* SymbolClass — Debug, Err */ - color: #e4c685; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.nc { /* SymbolClass — Class / CoClass / Enum / Interface / Type / Structure names */ - color: #e4c685; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.nf { /* SymbolFunction — Function / Sub / Property names */ - color: #cf9a5d; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.nn { /* SymbolModule — Module / Namespace / Imports targets */ - color: #a8a887; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.nv { /* SymbolVariable — Dim / Const / ReDim variable names */ - color: #8b8b52; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -/* tB Symbols with no dedicated Rouge class in twinbasic.rb: */ -/* SymbolConditionalCompilationExcludedCode — not tokenized — would need preproc evaluation */ -/* SymbolConstant — not distinguished from Name */ -/* SymbolDeclareFunction — not distinguished from Keyword .k */ -/* SymbolDeclareSub — not distinguished from Keyword .k */ -/* SymbolEnum — folded into Name::Class .nc */ -/* SymbolEnumMember — not distinguished from Name */ -/* SymbolField — not distinguished from Name */ -/* SymbolGenericDataType — not tokenized */ -/* SymbolGenericValue — not tokenized */ -/* SymbolGlobalVariablePrivate — not distinguished from Name */ -/* SymbolGlobalVariablePublic — not distinguished from Name */ -/* SymbolInterface — folded into Name::Class .nc */ -/* SymbolLateBoundFunction — not distinguished from Name */ -/* SymbolLibrary — not distinguished from Name */ -/* SymbolLineLabel — not tokenized */ -/* SymbolLineNumber — not tokenized */ -/* SymbolMe — folded into Keyword .k */ -/* SymbolMultiLineSeperator — not tokenized */ -/* SymbolNamedArgument — not tokenized */ -/* SymbolParamByRef — not tokenized */ -/* SymbolParamByVal — not tokenized */ -/* SymbolPropertyGet — folded into Keyword .k */ -/* SymbolPropertyLet — folded into Keyword .k */ -/* SymbolPropertySet — folded into Keyword .k */ -/* SymbolReturnValue — not tokenized */ -/* SymbolSub — folded into Name::Function .nf */ -/* SymbolUDT — folded into Name::Class .nc */ -/* SymbolVariableUndeclared — not distinguished from Name */ diff --git a/scripts/themes/twinbasic-light.css b/scripts/themes/twinbasic-light.css deleted file mode 100644 index ce013531..00000000 --- a/scripts/themes/twinbasic-light.css +++ /dev/null @@ -1,200 +0,0 @@ -/* twinBASIC Light theme - Rouge syntax highlighting */ -/* Selectors are the Rouge HTML formatter classes emitted by docs/_plugins/twinbasic.rb. */ - -.c1 { /* SymbolComment — ' comments and REM */ - color: #448a63; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.cm { /* SymbolComment — C-style block comments */ - color: #448a63; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.cp { /* SymbolConditionalCompilationDirective — #If / #ElseIf / #Else / #End If / #Const / #Region */ - color: #ad8c98; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.k { /* SymbolKeyword — Dim, If, End, Sub, ... */ - color: #385ba9; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.kd { /* SymbolKeyword — Option Strict / Explicit / Compare / Base */ - color: #385ba9; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.kt { /* SymbolBuiltInDataType — Boolean, Integer, String, ... */ - color: #b1551f; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.lb { /* SymbolLiteralBoolean — True, False */ - color: #b877ce; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.lc { /* SymbolContinuationCharacter — '_' line-continuation marker */ - color: #808080; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.ld { /* SymbolLiteralDate — #m/d/yyyy [h:mm:ss am/pm]# date-time literals */ - color: #b877ce; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.le { /* SymbolLiteralEmpty — Empty */ - color: #b877ce; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.ln { /* SymbolLiteralNothing — Nothing */ - color: #b877ce; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.lu { /* SymbolLiteralNull — Null */ - color: #b877ce; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.mi { /* SymbolLiteralNumeric — integer literals */ - color: #457e12; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.mf { /* SymbolLiteralNumeric — float literals */ - color: #457e12; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.s { /* SymbolLiteralString — string literals */ - color: #679f1e; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.se { /* SymbolLiteralString — "" escape inside string literals */ - color: #679f1e; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.o { /* SymbolOperator — +, -, =, <, >, &, ... */ - color: #80a1a5; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.ow { /* SymbolNamedOperator — And, Or, Not, Is, Mod, ... */ - color: #385ba9; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.na { /* SymbolAttribute — [Documentation(...)] attribute names */ - color: #bfbfbf; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.nb { /* SymbolClass — Debug, Err */ - color: #a87300; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.nc { /* SymbolClass — Class / CoClass / Enum / Interface / Type / Structure names */ - color: #a87300; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.nf { /* SymbolFunction — Function / Sub / Property names */ - color: #b96300; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.nn { /* SymbolModule — Module / Namespace / Imports targets */ - color: #89894d; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -.nv { /* SymbolVariable — Dim / Const / ReDim variable names */ - color: #939000; - font-style: normal; - font-weight: normal; - text-decoration: none; -} - -/* tB Symbols with no dedicated Rouge class in twinbasic.rb: */ -/* SymbolConditionalCompilationExcludedCode — not tokenized — would need preproc evaluation */ -/* SymbolConstant — not distinguished from Name */ -/* SymbolDeclareFunction — not distinguished from Keyword .k */ -/* SymbolDeclareSub — not distinguished from Keyword .k */ -/* SymbolEnum — folded into Name::Class .nc */ -/* SymbolEnumMember — not distinguished from Name */ -/* SymbolField — not distinguished from Name */ -/* SymbolGenericDataType — not tokenized */ -/* SymbolGenericValue — not tokenized */ -/* SymbolGlobalVariablePrivate — not distinguished from Name */ -/* SymbolGlobalVariablePublic — not distinguished from Name */ -/* SymbolInterface — folded into Name::Class .nc */ -/* SymbolLateBoundFunction — not distinguished from Name */ -/* SymbolLibrary — not distinguished from Name */ -/* SymbolLineLabel — not tokenized */ -/* SymbolLineNumber — not tokenized */ -/* SymbolMe — folded into Keyword .k */ -/* SymbolMultiLineSeperator — not tokenized */ -/* SymbolNamedArgument — not tokenized */ -/* SymbolParamByRef — not tokenized */ -/* SymbolParamByVal — not tokenized */ -/* SymbolPropertyGet — folded into Keyword .k */ -/* SymbolPropertyLet — folded into Keyword .k */ -/* SymbolPropertySet — folded into Keyword .k */ -/* SymbolReturnValue — not tokenized */ -/* SymbolSub — folded into Name::Function .nf */ -/* SymbolUDT — folded into Name::Class .nc */ -/* SymbolVariableUndeclared — not distinguished from Name */