diff --git a/WIP.md b/WIP.md index 35ecd07e..9b4c9c0e 100644 --- a/WIP.md +++ b/WIP.md @@ -424,6 +424,43 @@ WIP.md itself (and other files outside `docs/`) is not part of the Jekyll site a Python scripts are reserved for non-render concerns: one-off content conversion (e.g. `scripts/convert_em_dash_separators.py`), repo audits, dev tooling, link checks beyond `check.bat`, anything that runs *outside* a Jekyll build. They should never be a prerequisite for the render pipeline. +## JS builder port (shipped, Phase 9 cleanup) + +The Jekyll + Ruby build pipeline has been ported to a custom single-purpose Node.js tool that lives at the repo root in [builder/](builder/) (sibling of `docs/`, not inside it). All eight build phases land on the production tree and produce byte-equivalent output to Jekyll modulo the entries in [builder/accepted-divergences.mjs](builder/accepted-divergences.mjs). Phase 9 is the QoL / documentation / cleanup consolidation pass that adds CLI flags (`--no-offline`, `--no-pdf`, `--serving`, `--profile-offline`), a Phase 7 nav-block cache, a generic `_data/*.yml` loader (`data.mjs`), a multi-divergence audit tool (`_audit_accepted.mjs`), `_diff.mjs`'s `--against-disk` and `--multi` modes, a PDF cross-reference completeness check in `verify-phase8.mjs`, and [builder/README.md](builder/README.md). Phase 10 (planned) picks up the output-changing FUTURE-WORK items (Shiki theming generated from upstream `.twin` source files, mermaid auto-gen, copy-code SSR, linkify, search-data minification, AST-based JTD patcher). The Jekyll-to-tbdocs cutover is tracked in [builder/FUTURE-WORK.md §C1](builder/FUTURE-WORK.md). + +See [builder/README.md](builder/README.md) for the quickstart, [builder/PLAN.md](builder/PLAN.md) for the architecture overview, and [builder/PLAN-1.md](builder/PLAN-1.md) .. [builder/PLAN-9.md](builder/PLAN-9.md) for the per-phase specs. + +Until the cutover runs, the Jekyll pipeline below remains the canonical build path. + +### Builder diff / triage / verify tools + +When iterating on a phase or chasing a divergence vs Jekyll, **reach for one of these tools before reading source by hand**. They drive the in-memory tbdocs pipeline through whatever phase the requested target needs, then byte-diff against the matching file in Jekyll's `docs/_site/`, `docs/_site-offline/`, or `docs/_site-pdf/`. Listed in order of "what do I run first?": + +- [builder/_triage.mjs](builder/_triage.mjs) — bulk classifier covering Phases 3-8 in one run. Walks every page, finds the first divergence from Jekyll, classifies it into a coarse bucket so remaining work can be ranked by pattern frequency × visual severity. After the per-page bucket table prints, an **auxiliary audit** section covers sitemap, redirect stubs, robots.txt, search index, the **offline tree** (offline pages, offline redirects, offline CSS, offline JTD JS, offline search-data.js), and the **PDF tree** (book.html with per-article accepted-divergence handling, the two stylesheets, the image inventory). Each auxiliary line reports MATCH or a one-line DIFFER summary with counts; a clean run prints MATCH at every line. Flags: `--all` (print every example per bucket; default caps at 3), `--help`. **Start here** when something looks off across the board. +- [builder/_diff.mjs](builder/_diff.mjs) — single-target diff. Pinpoints one file's first divergence with ~200 chars of context. Modes — online site: default page (``, `--full` keeps the sidebar), `--redirect=`, `--robots`, `--search=`. Offline mirror: `--offline=`, `--offline-redirect=`, `--offline-css=`, `--offline-jtd`, `--offline-search`. PDF book: `--book` (build-info normalised), `--book=full` (no normalisation), `--pdf-image=` (MATCH / MISS / MISSING-IN-INVENTORY), `--pdf-css=`. Page-diff modifiers: `--against-disk[=]` (diff the on-disk write rather than the in-memory render — catches write-time encoding bugs), `--multi` (continue past the first divergence and report every distinct region). Always available: `--help`. **Run after triage** to drill into one representative file from the largest bucket. +- [builder/_diff_all.mjs](builder/_diff_all.mjs) — per-bucket divergence audit. Aggregates Phase 3 / Phase 4 divergences across all pages. +- [builder/_audit_accepted.mjs](builder/_audit_accepted.mjs) — accepted-divergence audit. Diffs every page in `ACCEPTED_DIVERGENCE_PATHS` against Jekyll's `_site/` and reports EVERY divergence region (not just the first), so a hidden secondary divergence behind an existing accepted entry doesn't stay masked. Flags: `--all` (print every region per page; default caps at 5), `--help`. +- [builder/_sitemap_diff.mjs](builder/_sitemap_diff.mjs) — sitemap URL-set diff against Jekyll's `sitemap.xml`. +- [builder/_spot.mjs](builder/_spot.mjs) — single-page output dump (useful for piping into a paginator when investigating a specific page). +- [builder/verify-phase{1..8}.mjs](builder/) — per-phase acceptance harness. Each Phase N has a matching `verify-phaseN.mjs` that drives Phases 1..N into a scratch destination and asserts the acceptance checks from `PLAN-N.md §10`. Phase 8's harness does per-article byte-diff vs `_site-pdf/book.html` with accepted-divergence skipping plus structural checks (file count, image presence, CSS parity). Run before merging phase changes; treat failures as blockers. + +Common workflows: + +| Question | Tool | +|---|---| +| "Is everything still byte-perfect vs Jekyll?" | `cd builder && node _triage.mjs` | +| "Did Phase N regress?" | `cd builder && node verify-phaseN.mjs` | +| "Why does this one page differ?" | `cd builder && node _diff.mjs ` | +| "Why does this offline page differ?" | `cd builder && node _diff.mjs --offline=` | +| "Are the redirect stubs right?" | `cd builder && node _diff.mjs --redirect=` | +| "Are the offline redirect stubs right?" | `cd builder && node _diff.mjs --offline-redirect=` | +| "Is the lunr search index byte-equal?" | `cd builder && node _diff.mjs --search=` | +| "Does book.html match Jekyll?" | `cd builder && node _diff.mjs --book` | +| "Does the PDF tree have a given image?" | `cd builder && node _diff.mjs --pdf-image=` | +| "Is a PDF-tree CSS byte-equal?" | `cd builder && node _diff.mjs --pdf-css=` | + +All tools live in `builder/` and expect to be run from inside it (`cd builder && node .mjs ...`). They read `../docs/_site/`, `../docs/_site-offline/`, and `../docs/_site-pdf/` as the Jekyll references; all three trees must be up-to-date (run `build.bat` once if not). The tools never write to `docs/`; scratch destinations are under `docs/_site-verify*` and `docs/_site-diff-scratch` and are cleaned up on exit. + ## Build / preview From `docs/`: diff --git a/_ktest.rb b/_ktest.rb new file mode 100644 index 00000000..5ab26e18 --- /dev/null +++ b/_ktest.rb @@ -0,0 +1,12 @@ +require 'rouge' +load "D:/OCP/wc/twinBASIC-documentation/docs/_plugins/twinbasic.rb" + +def show(label, code) + puts "=== #{label} ===" + puts Rouge::Formatters::HTML.new.format(Rouge::Lexers::TwinBasic.new.lex(code)) + puts +end + +show ". . . (current source)", " . . .\n Exit Sub\n" +show "... (contiguous dots)", " ...\n Exit Sub\n" +show "' comment", " ' ...\n Exit Sub\n" diff --git a/_mtest.mjs b/_mtest.mjs new file mode 100644 index 00000000..ff902321 --- /dev/null +++ b/_mtest.mjs @@ -0,0 +1,21 @@ +import MarkdownIt from 'markdown-it'; +const md = new MarkdownIt({html:true, typographer:true}); + +// Our renderer's html_block strip rule -- extend to cover markdown=span/block +md.core.ruler.push("strip-markdown-attr", (state) => { + for (const t of state.tokens) { + if (t.type !== "html_block") continue; + t.content = t.content.replace(/\s+markdown=(?:["'][^"']*["']|[a-zA-Z0-9]+)/g, ""); + } +}); + +const src = `## Test + +
+Q? + +body paragraph + +
+`; +console.log(md.render(src)); diff --git a/builder/FUTURE-WORK.md b/builder/FUTURE-WORK.md new file mode 100644 index 00000000..c3df662c --- /dev/null +++ b/builder/FUTURE-WORK.md @@ -0,0 +1,428 @@ +# Future Work + +Open follow-up tasks for the tbdocs builder. Phases 1-9 are shipped; +**Phase 10** will pick up the items that intentionally change output +and so couldn't fit Phase 9's no-regression criterion. + +Per-item phase routing is annotated inline below — items routed to +**→ Phase 9** (now landed) are marked **shipped**; items routed to +**→ Phase 10** stay open; **→ drop** items are out of scope. Items +without an explicit routing either pre-date the Phase 9 plan (the +§A1 investigation, now also shipped) or are sequenced outside the +phase pipeline (§C cutover). + +When picking up a divergence-investigation entry: re-run the discovery +step listed under "Reproduce" before assuming the symptom is still +current -- code on either side of the divergence may have changed +since the entry was written. + +--- + +## A. Divergence investigations + +### A1. Hidden secondary divergences on accepted-divergence pages + +**Routing**: investigation paths #1 (multi-divergence audit tool) and +#3 (`_diff.mjs` / `_triage.mjs` `--multi` mode) → **shipped in Phase 9** +([PLAN-9.md §5.12](PLAN-9.md)). Path #2 (decide on the TestFixture +line specifically) is a content / parser call that can wait. + +**Discovered**: Phase 6 verify (search-data byte comparison vs Jekyll's +`docs/_site/assets/js/search-data.json`). + +**Reproduce**: +``` +cd builder +node index.mjs # builds _site-new/ +node verify-phase6.mjs # surfaces non-accepted content diffs +``` + +**Symptom**: After the NBSP-handling fix in `search.mjs`'s +`sanitiseContent` (Phase 6 was treating ` ` as whitespace; Ruby +doesn't), exactly one search-content entry remained divergent vs +Jekyll's output -- `Reference/Attributes.md`'s `#testfixture` +section. + +**Root cause**: kramdown and markdown-it parse this line differently +(line 629 of `docs/Reference/Attributes.md`): + +``` +Syntax: **[TestFixture **[ **( True** \| **False )** ] **]** +``` + +kramdown opens `` at the leading `**[TestFixture` marker and +closes at the third `**`: + +```html +[TestFixture **[ **( True | False ) ] ] +``` + +markdown-it leaves the leading `**[TestFixture **[ ` as literal text +and only opens `` at `**( True**`: + +```html +**[TestFixture **[ ( True | False ) ] ] +``` + +The source pattern is unusual -- five `**` markers in a row with +mismatched bracket/paren grouping -- and kramdown's asymmetric +greedy-opener behaviour is arguably a bug it happens to commit +consistently. The page author may have meant a different markup; worth +asking before chasing parser parity. + +**Why Phase 3 / Phase 4 didn't surface this**: +`Reference/Attributes.md` was already listed in +`accepted-divergences.mjs` for a different reason (a JSON syntax- +highlighting tokenisation difference inside an earlier code fence). +`_diff.mjs` prints only the **first** divergence offset, so it stops +at the JSON block and never reaches line 629. `_triage.mjs` buckets +pages by first-divergence pattern and silently passes any page whose +`srcRel` is in `ACCEPTED_DIVERGENCE_PATHS`. Once a page is fully +accepted, every subsequent divergence on that page is masked. + +Phase 6's search-content verify happens to expose this because it +diffs each section's sanitised content independently, so divergences +past the first one have their own slot in the result. + +**Current mitigation**: a second entry for `Reference/Attributes.md` +in `accepted-divergences.mjs` (category `markdown-parsing`) documenting +the strong-asterisk parsing difference and pointing at this section. + +**Investigation paths**: + +1. **Multi-divergence audit**. Add an `_audit_accepted.mjs` tool that + diffs the **whole** rendered HTML (Phase 4 output, sidebar stripped) + for every page in `ACCEPTED_DIVERGENCE_PATHS`, against Jekyll's + `_site/`, and reports any divergence regions whose + character span lies outside the documented accepted region. The + most expedient version of this just splits both sides on each + character offset where they diverge and prints all distinct + divergence regions with ~80 chars of context. Goal: find every + page where an accepted-divergence wrapper is masking a different + class of divergence. + +2. **Decide on the TestFixture line specifically**. Three options: + a. Patch the source -- if the intent was `[**TestFixture**` with a + paired closer, ask the author and rewrite. + b. Match kramdown's behaviour in markdown-it -- likely needs a + custom plugin or a fork; the pattern is rare enough that the + ROI is doubtful. + c. Leave the divergence accepted; the rendered text reads + identically in both cases (the asterisks vs `` shift is + a visual styling difference, not a content difference). + +3. **Make first-divergence tools multi-aware**. `_triage.mjs` and + `_diff.mjs` could optionally continue past the first divergence, + reporting each distinct region. The cost is moderate (the diff + algorithm has to re-sync after each region) and the value is the + ability to surface this kind of hidden secondary divergence + without spawning a separate auditor. + +**Owner**: unassigned. Pick this up if the next Phase 3/4/6 verify +ever surfaces a regression on this page, or as a one-off cleanup +when chasing parser parity becomes interesting. + +--- + +## B. Deferred enhancements + +These are out-of-scope follow-ups noted while implementing the +phases. Each is a clean addition; none block any current work. + +### B1. Mermaid `.mmd` -> `.svg` automation (PLAN-3 §15) + +**Routing**: → **Phase 10**. Auto-regenerated SVGs would differ +byte-for-byte from the hand-exported originals, regressing the +`_site/assets/images/mmd/*.svg` byte match. + +**Trigger**: a second mermaid diagram is added to the site, or the +single existing one needs a re-export. + +The site currently has one mermaid source under +`docs/assets/images/mmd/` with a hand-exported SVG sibling (produced +via Typora). A small `mermaid.mjs` preprocessor invoking `mmdc` would +close the loop so the source `.mmd` is the canonical input and the +SVG regenerates automatically. Independent addition; doesn't touch +any phase code. + +### B2. Switch to Shiki-themed inline-style output (PLAN-3 §15 / §D3) + +**Routing**: → **Phase 10** (headline item). Definitely regresses +HTML byte-match. + +**Approach update**: rather than the original "switch to Shiki's +default `` output", the Phase 10 plan +generates the Shiki theme **from the upstream twinBASIC `.twin` style +source files** during the build, replacing the current +`scripts/extract_theme_colors.py` mapping that produces Rouge classes. +The original styling information lives in the `.twin` files; the +current pipeline indirects through Rouge classes because Rouge's +class set is fixed. Reading the `.twin` source directly lets the +syntax colors stay in sync with upstream without manual remap. + +**Trigger**: Phase 10 lands. + +Phase 3 maps Shiki's TextMate scopes onto Rouge class names so the +existing `assets/css/rouge.css` keeps working byte-for-byte. The +Phase 10 change drops the mapper, generates Shiki styles directly +from the `.twin` source files, and accepts the HTML body diff for +every `
` block (single category in
+`accepted-divergences.mjs`).
+
+### B3. Move title rendering to `site.markdown` (PLAN-3 §15, PLAN-2 §D6)
+
+**Routing**: → **shipped in Phase 9** ([PLAN-9.md §5.1](PLAN-9.md)).
+
+**Trigger**: a refactor pass after the port settles.
+
+Phase 2's `seo.mjs` currently creates its own minimal markdown-it
+instance for SEO title rendering, while Phase 3 exposes a fully
+configured `site.markdown` for body rendering. Consolidating onto
+`site.markdown` would remove a few lines and one configuration site.
+Tiny code reduction, no behaviour change.
+
+### B4. Generic `site.data.*` loader (PLAN-3 §15)
+
+**Routing**: → **shipped in Phase 9** ([PLAN-9.md §5.2](PLAN-9.md)).
+Pulled in as a mechanical cleanup even without a trigger; sets up
+cleanly for any future `_data/*.yml`.
+
+**Trigger**: a new `_data/.yml` is added.
+
+Currently `book.mjs` loads `_data/book.yml` directly. A generic
+loader walking `_data/*.yml` into `site.data` would cover any future
+data file without per-file plumbing. Defer until a second data file
+exists.
+
+### B5. Inline copy-code button server-side rendering (PLAN-3 §15 / §D16)
+
+**Routing**: → **Phase 10**. Adds button HTML to every `
`;
+regresses HTML byte-match.
+
+**Trigger**: the just-the-docs copy-code JS needs to be retired
+(client-bundle shrink, accessibility audit, etc.).
+
+The copy-code button is currently injected at runtime by the
+just-the-docs theme JS. Server-side injection in `highlight.mjs`
+(adding the `
+  
+  
+  
${navFooter(site.config)}
+ +``` + +`siteTitle` is the project's `_includes/title.html` shadow output: + +- When `site.config.logo` is set: + - `` + - If `site.config.logo_with_title` is truthy, append `site.config.title` (as plain text). +- Else: just `site.config.title`. + +Currently `logo` is `favicon.png` and `logo_with_title` is `true`, so +the output is the logo div + the literal site title. + +The trailing `
...
` is the +upstream's fall-through when `nav_footer_custom.html` is empty +(template: "This site uses Just the Docs..."). The project does NOT +override `nav_footer_custom.html`, so the upstream fallback applies. +Phase 4 emits the same string verbatim. + +#### Recursive nav walker + +```js +function recursiveNav(nodes, ancestorTitles) { + if (!nodes.length) return ""; + let out = ``; + return out; +} +``` + +**Why the cycle defence by title.** Matches the upstream's behaviour +exactly. A page that has the same title as one of its ancestors +would otherwise recurse forever; the infinity-link (`∞`) is a +visual placeholder a maintainer can spot in QA. The +nav-integrity-check in Phase 2 catches the more common case +(`parent` references) earlier; this guard catches the rarer +"sibling-with-same-name" case the integrity check doesn't see. + +**Why `escText` on the title.** The title can contain +`<`, `>`, `&`, etc. (the `&, &=` operator pages). Render uses 5-char +HTML escape -- same as `escapeHtml` from PLAN-3.md §6.2. + +**Why `escAttr` on the URL.** Permalinks are computed by Phase 1 +and don't contain HTML-sensitive characters in practice, but +attribute values must be HTML-attribute-safe. The check is cheap and +covers a future URL that contains `&` in a query string. + +#### External nav links + +When `site.config.nav_external_links` is set, append after the main +nav UL: + +```html + +``` + +`targetAttrs` is `target="_blank" rel="noopener noreferrer"` when +`node.opens_in_new_tab === true`, OR when `node.opens_in_new_tab` is +absent AND `site.config.nav_external_links_new_tab` is truthy. +Currently absent on this site, so no target. + +**Currently this site has `nav_external_links: [{ title: twinBASIC Home, url: https://www.twinbasic.com }]`** and the rendered output DOES contain it (verified by grepping `nav-list-item external` in `_site/index.html` -- returns one match). The exact rendered shape, after compress: + +```html + +``` + +Things to notice about the rendered shape: + +- The single space inside `class="nav-list-link external" >` is the + Liquid template's source-side whitespace before `{% if site.aux_links_new_tab ... %}` collapsed to one space by compress. Match by emitting `` literally (with one trailing space inside the opening tag). +- The space between `twinBASIC Home` and the SVG is from a Liquid newline; compress collapses to one space. +- The space between `` and `` is similarly a compress artefact. +- The `absolute_url` filter is a no-op when the URL is already absolute (the rendered href is `https://www.twinbasic.com` verbatim, not re-normalised). +- `hide_icon` is unset on this entry, so the `` renders. When `hide_icon: true`, the SVG is omitted. + +Phase 4's emission must produce these exact spaces. Recommend +authoring the template literal with the spaces visible (don't tidy +them on the assumption "compress will fix it" -- compress only adds +spaces from collapsed runs, it doesn't insert spaces between +adjacent tags). + +### 5.5. `navActivationCss(page)` -- the per-page CSS + +Port of `_includes/css/activation.scss.liquid`. The CSS uses +positional `:nth-child()` selectors driven by `page.navLevels` to +bold the active link, unfold its ancestor collections, and rotate +the expander icons -- the no-JS fallback. + +**Three CSS-rule shapes the generator emits**, all of them inside a +single `` block: + +1. **Background-image-none rule for non-active links.** A composite + selector that names every nav link that is NOT the active one, + not on the active page's chain, and not a child of the active + page. The shape (active page at depth 4, levels = [1, 5, 2, 7]): + + ```css + .site-nav > ul.nav-list:first-child > li > a, + .site-nav > ul.nav-list:first-child > li > ul > li > a, + .site-nav > ul.nav-list:first-child > li > ul > li > ul > li:not(:nth-child(7)) > a, + .site-nav > ul.nav-list:first-child > li > ul > li > ul > li > ul > li a { + background-image: none; + } + ``` + + Plus a constant trailer for the "other collections" branch: + + ```css + .site-nav > ul.nav-list:not(:first-child) a, + .site-nav li.external a { + background-image: none; + } + ``` + +2. **Active-link bolding.** One selector with positional indices for + each ancestor: + + ```css + .site-nav > ul.nav-list:first-child > li:nth-child(5) > ul > li:nth-child(2) > ul > li:nth-child(7) > a { + font-weight: 600; + text-decoration: none; + } + ``` + +3. **Expander rotation + collection-display.** Two more composite + selectors, expander icons first: + + ```css + .site-nav > ul.nav-list:first-child > li:nth-child(5) > button svg, + .site-nav > ul.nav-list:first-child > li:nth-child(5) > ul > li:nth-child(2) > button svg, + .site-nav > ul.nav-list:first-child > li:nth-child(5) > ul > li:nth-child(2) > ul > li:nth-child(7) > button svg { + transform: rotate(-90deg); + } + .site-nav > ul.nav-list:first-child > li.nav-list-item:nth-child(5) > ul.nav-list, + .site-nav > ul.nav-list:first-child > li.nav-list-item:nth-child(5) > ul.nav-list > li.nav-list-item:nth-child(2) > ul.nav-list, + .site-nav > ul.nav-list:first-child > li.nav-list-item:nth-child(5) > ul.nav-list > li.nav-list-item:nth-child(2) > ul.nav-list > li.nav-list-item:nth-child(7) > ul.nav-list { + display: block; + } + ``` + +**Fallback for pages not in the nav.** When `page.navLevels` is +`undefined` (page has no title, is `nav_exclude`, or the parent +chain doesn't ground out -- see PLAN-2 §5.4), emit only the +`activation_no_nav_link` block: + +```css +.site-nav ul li a { + background-image: none; +} +``` + +Verified against `_site/404.html`'s `` comes from the +// included file's trailing newline (survives Liquid trim semantics -- +// see test in PLAN-4 §5.4 notes). Compress collapses to one space, +// producing ` ` rather than ``. +function renderNavTree(nodes, ancestorTitles, baseurl) { + if (!nodes || nodes.length === 0) return `\n`; + let out = `\n`; + return out; +} + +// Port of the site_nav.html shadow's nav_external_links branch. +function renderNavExternalLinks(config) { + const list = config.nav_external_links; + if (!list || list.length === 0) return ""; + const items = list.map(node => { + const opensNewTab = node.opens_in_new_tab === true + || (node.opens_in_new_tab == null && config.nav_external_links_new_tab); + // The Liquid source has a multi-line `` shape; with target absent, + // compress collapses the whitespace inside the open tag to a single + // space, leaving `class="nav-list-link external" >` (note the + // trailing space before `>`). Mirror the exact byte form. + const targetAttrs = opensNewTab ? `target="_blank" rel="noopener noreferrer" ` : ``; + const url = absoluteUrl(node.url, config); + const svg = node.hide_icon ? "" + : ` `; + return ``; + }).join(""); + // No trailing whitespace: the Liquid source's `{%- endif -%}` + // strips between the closing `` and the outer ``. + return ``; +} + +// ---------- §5.5 navActivationCss ---------------------------------------- + +const COLLECTION_PREFIX = ".site-nav > ul.nav-list:first-child"; +const OTHER_COLLECTION_PREFIX = ".site-nav > ul.nav-list:not(:first-child)"; + +export function navActivationCss(page) { + const levels = page.navLevels; + if (!levels) { + // Fallback for pages not in the nav (no title, nav_exclude, or + // unresolved parent chain). Matches _site/404.html exactly. + return ` .site-nav ul li a {\n background-image: none;\n }`; + } + + // levels[0] = 1 (collection-prefix, always 1 on this site). + // levels[1..depth] = 1-based child positions for each ancestor + leaf. + // depth = navLevels.length - 1. + const depth = levels.length - 1; + const active = levels[depth]; + + // ---- Rule 1: background-image: none for non-active links ---- + // Verbatim port of activation.scss.liquid lines 65-77. + // + // Three parts: + // (a) ancestor selectors (only when depth >= 2): + // prefix > li > a, + // prefix > li > ul > li > a, + // ... up to (depth - 1) levels deep. + // (b) current page's siblings (li at depth, excluding the active one): + // prefix > (li > ul > ){depth-1 times} li:not(:nth-child(N)) > a + // (c) current page's descendants: + // prefix > (li > ul > ){depth times} li a + const noBg = []; + if (depth >= 2) { + for (let i = 1; i <= depth - 1; i++) { + let s = COLLECTION_PREFIX + " >"; + for (let j = 2; j <= i; j++) s += ` li > ul >`; + s += ` li > a`; + noBg.push(s); + } + } + { + let s = COLLECTION_PREFIX + " >"; + for (let i = 1; i <= depth - 1; i++) s += ` li > ul >`; + s += ` li:not(:nth-child(${active})) > a`; + noBg.push(s); + } + { + let s = COLLECTION_PREFIX + " >"; + for (let i = 1; i <= depth; i++) s += ` li > ul >`; + s += ` li a`; + noBg.push(s); + } + + let css = + ` ${noBg.join(",\n ")} {\n` + + ` background-image: none;\n` + + ` }\n\n`; + + // ---- Rule 2: trailer for other collections + externals (constant) ---- + css += + ` .site-nav > ul.nav-list:not(:first-child) a,\n` + + ` .site-nav li.external a {\n` + + ` background-image: none;\n` + + ` }\n\n`; + + // ---- Rule 3: bolding the active leaf link ---- + // Liquid: `prefix > li:nth-child(N1)` then `for i in (2..depth)` + // appends ` > ul > li:nth-child(Ni)`, then ` > a`. + { + let s = ` ${COLLECTION_PREFIX} > li:nth-child(${levels[1]})`; + for (let i = 2; i <= depth; i++) { + s += ` > ul > li:nth-child(${levels[i]})`; + } + s += ` > a`; + css += `${s} {\n font-weight: 600;\n text-decoration: none;\n }`; + } + + // ---- Rule 4: expander icon rotation (button svg), depth selectors ---- + // Liquid: + // prefix > li:nth-child(N1) > button svg + // prefix > li:nth-child(N1) > ul > li:nth-child(N2) > button svg + // ... + // No leading newline before the first rule -- the upstream Liquid + // has `{%- if site.just_the_docs.collections %}...{% endif -%}` that + // strips the surrounding whitespace, so this rule abuts the previous + // `}` with no separator. + { + const sels = []; + for (let i = 1; i <= depth; i++) { + let s = `${COLLECTION_PREFIX} > li:nth-child(${levels[1]})`; + // Liquid: outer is at i=1 unconditional, inner loop for j in (2..i) + // appends ` > ul > li:nth-child(Nj)`, then ` button svg` (with + // single space before button when inner loop fires, else just one + // ` > button svg`). For i=1: `prefix > li:nth-child(N1) > button svg`. + // For i=2: `prefix > li:nth-child(N1) > ul > li:nth-child(N2) > button svg`. + for (let j = 2; j <= i; j++) { + s += ` > ul > li:nth-child(${levels[j]})`; + } + s += ` > button svg`; + sels.push(s); + } + css += sels.join(",\n "); + css += ` {\n transform: rotate(-90deg);\n }`; + } + + // ---- Rule 5: collection display (ul.nav-list display: block) ---- + // Same shape as Rule 4 but classed (`li.nav-list-item:nth-child(N)`) + // and ul.nav-list terminals. Like Rule 4, no separator from previous. + { + const sels = []; + for (let i = 1; i <= depth; i++) { + let s = `${COLLECTION_PREFIX} > li.nav-list-item:nth-child(${levels[1]})`; + for (let j = 2; j <= i; j++) { + s += ` > ul.nav-list > li.nav-list-item:nth-child(${levels[j]})`; + } + s += ` > ul.nav-list`; + sels.push(s); + } + css += sels.join(",\n "); + css += ` {\n display: block;\n }`; + } + + // Prepend a leading 4-space indent on the first non-blank line of + // each major rule above. The Liquid source has 4-space indentation; + // compress preserves all single spaces (collapsing runs to one). + return css; +} + +// ---------- §5.6 renderHeader + auxNav ----------------------------------- + +function renderHeader(site) { + const config = site.config; + const searchEnabled = config.search_enabled !== false; + const auxLinks = config.aux_links; + return `
\n` + + (searchEnabled + ? renderSearchInput(config) + : `
\n`) + + (auxLinks ? renderAuxNav(config) : "") + + `
`; +} + +function renderSearchInput(config) { + // Search placeholder (port of upstream search_placeholder_custom.html): + // `Search ${site.title}`, then strip_html + strip. The site title is + // plain text so strip_html is a no-op. + const placeholder = `Search ${escAttr(String(config.title ?? ""))}`; + return ` \n`; +} + +// Port of docs/_includes/components/aux_nav.html. The sun/moon SVG +// sprite lives INSIDE the aux-nav wrapper (D8: byte parity). +function renderAuxNav(config) { + const links = config.aux_links || {}; + // YAML hash → ordered [title, urls[]] pairs. `link.first` in Liquid + // is the key; `link.last` is the value (first url). + const items = Object.entries(links).map(([title, urls]) => { + const url = Array.isArray(urls) ? urls[0] : urls; + const targetAttrs = config.aux_links_new_tab ? ` target="_blank" rel="noopener noreferrer"` : ``; + // Liquid source has a multi-line `` + // shape; with new_tab absent, compress collapses the whitespace + // inside the open tag to a single space, leaving `class="site-button" >`. + // Liquid `{{ link.first }}` doesn't escape -- emit title verbatim. + return `
  • \n` + + ` \n` + + ` ${String(title)}\n` + + ` \n` + + `
  • `; + }).join("\n"); + return ` \n`; +} + +// No leading whitespace: docs/_includes/components/aux_nav.html has a +// `{%- comment -%}...{%- endcomment -%}` between `