Detailed implementation plan for the second phase of the tbdocs builder. Read this together with PLAN.md (the authoritative architecture overview) and PLAN-1.md (which produced the inputs this phase consumes).
The COMPUTE phase has one job: take the inventory Phase 1 produced and
derive every piece of structured data the template phase will iterate
(nav tree, breadcrumbs, per-page nav-activation coordinates, child
listings, SEO metadata, resolved book chapter lists, build provenance) —
without rendering markdown, expanding templates, or touching the
filesystem beyond reading _config.yml, _data/book.yml, and one
git shell-out.
Target: ~30 ms wall time after Phase 1 on the current 836-page corpus. The Ruby equivalents collectively cost ~1.5 s in Jekyll's GENERATE phase; sharing intermediate state across the precomputes (rather than each Generator rebuilding it) makes the JS version much cheaper.
Phase 2 landed across nav.mjs (318 lines), seo.mjs
(126 lines), book.mjs (175 lines), build-info.mjs
(28 lines), the extended tbdocs.mjs (93 lines), and the
verify-phase2.mjs (retired Phase 10) acceptance harness (302 lines).
Total ~1,040 lines of JS for ~4,800 lines of replaced Ruby across the
six nav plugins + seo-precompute + book-resolve-chapters + book-sort +
build-info.
Measured wall time on the current Windows dev machine: discover ~120 ms,
nav 28 ms, seo 21 ms, book 9 ms, buildInfo ≤2 ms — total Phase 1+2 ~180
ms. The Phase 2 substeps alone come in at ~60 ms, about 2× the ~30 ms
aspirational target this document opened with but comfortably under the
~200 ms soft cap §10 used as a regression guard. Roughly half of the
60 ms is the combined nav walk (path + integrity + tree + levels +
breadcrumbs + children) and a third is the markdown-it init + 838 title
renders inside seo; book and build-info are noise on this scale.
Verification: all 23 checks in verify-phase2.mjs pass on the production
tree. Cross-verified against Jekyll's rendered _site/: top-level
navTree order matches byte-for-byte (10 entries), and the homepage's
<title> / <link rel="canonical"> / <meta property="og:site_name">
plus the markdown-active &, &= title's escape (&, &=) all
match Jekyll's output character-for-character.
The { pages, staticFiles } object returned by discover(). Phase 2
only reads pages[]; staticFiles[] flows past untouched into Phase 5.
The relevant Page fields are:
| Field | Why Phase 2 reads it |
|---|---|
permalink |
The page's URL. Equivalent to Jekyll's page.url. |
frontmatter.title |
Required to be in the nav tree / titled set. |
frontmatter.parent |
Parent's title (string). |
frontmatter.grand_parent |
Disambiguation hint for non-unique parent titles. |
frontmatter.nav_order |
Sort key; may be number or string. |
frontmatter.child_nav_order |
"desc" / "reversed" reverses children order on this parent. |
frontmatter.nav_exclude |
Page is excluded from nav-tree / nav-levels (but still appears in breadcrumbs / children). |
frontmatter.summary |
Optional short blurb shown next to a child link in the auto-generated children list. |
frontmatter.layout |
Read indirectly via Phase 1's layoutDefault; not used in Phase 2 itself but kept around. |
The rawContent field is not read by Phase 2. The body stays opaque
until Phase 3.
Two files Phase 2 reads directly (Phase 1 excludes both of them on purpose — they aren't pages):
| File | Loader | Consumer |
|---|---|---|
docs/_config.yml |
YAML | nav, SEO (for nav_sort, title, logo, url, baseurl) |
docs/_data/book.yml |
YAML | book chapter resolution |
Plus one shell-out:
| Command | Consumer |
|---|---|
git rev-parse --short HEAD and git log -1 --format=%cs |
build-info, ultimately rendered on the PDF title page |
Phase 2 reads a fixed subset of _config.yml keys. The list:
title— string. Site title; used by SEO.logo— string. Asset path (no leading slash); used by SEO.url— string. Absolute origin (no trailing slash); used by SEO. Currently"https://docs.twinbasic.com".baseurl— string (optional). Path prefix on the origin; used by SEO when computing relative URLs. Empty on this site, but the rule has to handle a non-empty value.nav_sort— string (optional)."case_insensitive"lowercases the string-sort keys; absent or any other value means case-sensitive. Not currently set on this site.
If a future change adds an unfamiliar key Phase 2 cares about (a new
nav_* knob, say) the implementer has to extend the loader. Phase 2
should not warn on unknown keys — many other keys (callouts,
enable_copy_code_button, aux_links, ...) belong to later phases.
See the inline comment block in docs/_data/book.yml for the canonical
selector schema. Phase 2 reads the keys the resolver needs:
- Top-level:
front_matter[],parts[]. - Per front-matter entry:
title,page/pages/nav_page/nav_pages/no_descent. - Per part:
title,subtitle,intro,landing_page,foreword_page,no_outline_entry,no_heading_shift, plus the same selector keys as a front-matter entry, pluschapters[]. - Per chapter (inside a chaptered part): same keys as a part, minus
chapters[]and the part-onlyforeword_page.
Phase 2's resolver only touches the selector keys + landing_page +
foreword_page. Every other key (title, subtitle, intro,
no_outline_entry, no_heading_shift) flows past untouched and is
read by Phase 8's renderer.
Phase 2 returns one new object and mutates two pre-existing ones in place. The orchestrator's working state after the phase looks like:
{
pages, // Phase 1's array, with new per-page fields added.
staticFiles, // unchanged.
site: {
config, // parsed _config.yml (with the keys Phase 2 read).
navTree, // Array<NavNode> — the sorted, filtered nav tree.
seoSiteTitle, // String — rendered site title for SEO.
seoLogoUrl, // String | null — absolute logo URL for SEO.
buildInfo, // { commit, commitDate } — git provenance.
bookData, // parsed _data/book.yml, with _chapters resolved.
},
}Every titled page (i.e. frontmatter.title is a non-empty string):
page.navPath // String. Slash-joined "grand_parent/parent/title".
// The selector book.yml's `nav_page` / `nav_pages`
// match against. Equivalent to Jekyll's
// page.data["nav_path"].
page.breadcrumbs // Array<{ title, url }>. Root-first ancestor chain.
// Empty for top-level pages. Excludes the current
// page itself (the template adds that as a span).
// Equivalent to page.data["breadcrumb_chain"].
page.children // Array<{ title, url, summary }>. Direct nav children
// of this page, sorted in render order. Empty for
// leaves. Equivalent to page.data["children_in_nav"].For titled pages that successfully resolve into the nav tree (top-level
pages and any descendant whose parent chain grounds out at a top-level
page within MAX_DEPTH steps):
page.navLevels // Array<Integer> | undefined. Positional coordinates
// for the nav-activation CSS. See §5.4 for shape.Pages that are excluded from the nav (nav_exclude: true, missing
parent, broken parent chain, or filtered out everywhere by
grand_parent disambiguation) get navLevels === undefined. The
template falls through to the no-nav-link rules in that case.
For every page (titled or not):
page.seoTitle // String. Markdownified+stripped+escaped page title,
// or the site title when page.title is empty.
page.seoFullTitle // String. "<seoTitle> | <seoSiteTitle>", collapsed
// to just seoTitle when the two match.
page.seoCanonical // String. Absolute canonical URL.
page.seoIsHome // Boolean. True for the homepage / about page;
// toggles the JSON-LD @type.site.navTree // Array<NavNode>; see §5.3 shape.
site.seoSiteTitle // Constant across the build.
site.seoLogoUrl // Constant across the build; null when no logo.
site.buildInfo // { commit: "d4fa6fc", commitDate: "2026-05-16" },
// or { commit: "unknown", commitDate: "unknown" }
// when git is unavailable.
site.bookData // Parsed _data/book.yml. Each chapter-bearing
// entry gains an `_chapters` field (see §5.8).Two reasons:
- Phase 3 (RENDER) and Phase 4 (TEMPLATE) want to iterate the same
pages[]array. Adding fields in place keeps each page as a single growing record; downstream phases stay free of "did this come from Phase 1 or Phase 2?" disambiguation. - The Ruby plugins mutate
page.datain place for the same reason. Doing it identically in JS keeps the mental model portable — reviewers can compare PLAN-2 against the Ruby plugin source without translating in their head.
Phase 1 used camelCase for its computed fields (srcPath, srcRel,
destPath, layoutDefault, imageScope) and snake_case for fields
read verbatim from YAML (frontmatter.nav_order,
frontmatter.parent). Phase 2 follows the same split:
| Source | Convention |
|---|---|
| Verbatim YAML frontmatter value | frontmatter.nav_order, frontmatter.parent (snake_case from source) |
| Verbatim YAML site/book config | site.config.nav_sort, bookData.front_matter[0].nav_page |
| Computed by Phase 2 | page.navPath, page.breadcrumbs, site.navTree (camelCase) |
The Ruby plugins use snake_case throughout because that matches
Ruby/Liquid idioms. The JS port uses camelCase for computed fields
because the templates that will read them are JS string templates, not
Liquid. The two existing Liquid templates (_includes/components/*)
are not consumers — they get replaced by template.mjs in Phase 4.
Four new modules. The orchestrator wires them together.
builder/
nav.mjs navPath, integrity check, navTree, navLevels,
breadcrumbs, children — five outputs sharing one
parent/title/sort substrate.
seo.mjs per-page _seo_* + site-level _seo_*. Independent
of nav.
book.mjs book.yml loader + chapter resolution. The render
half (assembling book.html) stays in book.mjs but
only runs in Phase 8.
build-info.mjs git shell-out for { commit, commitDate }.
tbdocs.mjs existing orchestrator — extended to call the four
above between discover() and the (future) Phase 3.
The six nav-related Ruby plugins (nav-path, nav-integrity-check,
nav-tree-precompute, nav-levels-precompute, breadcrumbs-precompute,
children-precompute) all consume the same intermediate structures:
titled— the subset ofpageswith a non-emptyfrontmatter.title.byTitle—Map<title, Page[]>(for ancestor lookup in breadcrumbs).byParentTitle—Map<parentTitle, Page[]>(for everything else).- A page-comparison function (the four-bucket sort).
orderedChildren—Map<url, Page[]>, the sorted+filtered children per parent (shared by navTree and navLevels).
In Jekyll, each Generator runs in isolation and rebuilds the same structures. The cost is small relative to RENDER (the bottleneck), so nobody factored them out. In JS we should build them once.
Putting all six in one module is straightforward:
// nav.mjs
export function computeNav(pages, config) {
computeNavPaths(pages); // §5.1; mutates pages
validateNavIntegrity(pages); // §5.2; throws on issue
const state = buildSharedNavState(pages, config); // §6
const navTree = buildNavTree(state); // §5.3
computeNavLevels(pages, state); // §5.4; mutates pages
computeBreadcrumbs(pages, state); // §5.5; mutates pages
computeChildren(pages, state); // §5.6; mutates pages
return { navTree };
}If a future maintainer prefers six small files, splitting is mechanical
(each function moves out, state becomes a shared import). For now,
one file keeps the related logic in one place.
book.yml selectors (page / pages / nav_page / nav_pages /
no_descent / landing_page / foreword_page) appear in both
phases. Putting the loader and resolver here lets the renderer (Phase
8) read its own intermediate data structure rather than re-implementing
the resolution.
The Phase 2 half exports loadBookData() and resolveBookChapters().
The Phase 8 half (renderBook()) is out of scope for this plan; PLAN.md
describes it.
Most of the substeps are independent. The exceptions are:
-
nav-pathmust run before book resolution. The book.yml selectorsnav_page/nav_pagesmatch againstpage.navPath, so the field has to be populated first. The Ruby pluginnav-path.rbruns as aGeneratorwithpriority :low, thenbook-resolve-chapters.rbruns in the:site, :pre_renderhook. We replicate that order. -
nav-integrity-checkmust run before any nav consumer. It aborts the build when aparent:reference is ambiguous or orphaned. Running it first means subsequent steps don't waste work on a broken graph. The Ruby plugin usespriority :highfor the same reason. -
nav-treeandnav-levelsboth consume the sharedorderedChildrenmap. Build the map once in §6, then run both. -
build-infoandseoare independent of everything else. They can run in parallel with the nav block or after it. Recommended order: build-info first (fast, fires off thegitcalls), then nav, then SEO, then book. -
Book chapter resolution depends on
nav-path(point 1) and on the page list having complete URLs. Both conditions are met after §5.1 — book resolution can run anywhere after that, but conceptually belongs at the end of Phase 2 since it consumes the most.
The orchestrator order:
discover() // Phase 1
buildInfo = captureBuildInfo() // Phase 2 part A
computeNav(pages, config) // Phase 2 part B (§5.1–§5.6)
precomputeSeo(pages, config) // Phase 2 part C (§5.7)
bookData = loadBookData(srcRoot) // Phase 2 part D (§5.8 loader)
resolveBookChapters(bookData, pages) // Phase 2 part D (§5.8 resolver)
// Phase 3+ ...
captureBuildInfo() should be issued early as a Promise and awaited
right before assembling the site object — its two git shell-outs
are I/O-bound (~10 ms total) and can overlap with the CPU-bound nav
work for free.
Purpose. Slash-joined grand_parent / parent / title chain, stored
on each titled page as page.navPath. Selector key for book.yml's
nav_page / nav_pages entries — sweeps pages by their position in
the nav tree rather than by URL prefix.
Algorithm (port of _plugins/nav-path.rb):
for (const page of pages) {
const title = page.frontmatter.title;
if (!title) continue;
const parts = [];
if (page.frontmatter.grand_parent) parts.push(page.frontmatter.grand_parent);
if (page.frontmatter.parent) parts.push(page.frontmatter.parent);
parts.push(title);
page.navPath = parts.join("/");
}Edge cases:
- Page with no
title→ nonavPathfield added. Matches the Ruby plugin'snext unless title. - Page with
parentbut nogrand_parent→ two segments (e.g."Reference Section/Operators"). - Page with both → three segments.
- Page with
grand_parentbut noparent→ two segments ("grand_parent/title"). Unusual but valid; matches the Ruby plugin's behaviour.
Purpose. Abort the build when any nav-visible page declares a
parent: that doesn't uniquely identify exactly one other nav-visible
page. Catches two failure modes the upstream just-the-docs nav silently
papers over:
- Ambiguity — multiple pages share the title declared in
parent:, and the child has nogrand_parent:(or itsgrand_parent:still matches more than one candidate). The child would silently appear under every matching parent. - Orphan — no page has the title declared in
parent:, or thegrand_parent:filter eliminates every candidate. The child would silently disappear from the sidebar.
Algorithm (port of _plugins/nav-integrity-check.rb):
- Build
byTitle: Map<title, Page[]>over nav-visible pages (titled AND notnav_exclude). - For each nav-visible
Pwithfrontmatter.parent:- Look up
candidates = byTitle.get(P.frontmatter.parent). - 0 candidates → orphan; record
{ page: P, reason: "no page titled X exists" }. - 1 candidate → unambiguous, pass.
- 2+ candidates and no
P.frontmatter.grand_parent→ ambiguous. - 2+ candidates with
grand_parent:- Filter by
c.frontmatter.parent === grand_parent. - 0 remain → orphan.
- 1 remain → disambiguated, pass.
- 2+ remain → still ambiguous.
- Filter by
- Look up
- If any ambiguous or orphan entries collected, log all of them to
stderr and throw — same shape as the Ruby plugin's report. The error
message must include the source
srcRelof each affected page so the failure points at a fix-able file.
Throw, don't return. The orchestrator does not need to handle
this — an integrity failure should abort the build before any
downstream phase wastes work. PLAN.md verifies output by diff -rq
against Jekyll's output; an aborted build there is a clear signal.
Purpose. The deeply nested array the sidebar template iterates.
Each node is { title, url, children: Array<NavNode> } — already
filtered (no nav_exclude pages) and sorted (per the four-bucket sort
in §6.2), with child_nav_order: desc/reversed reversal applied. The
template (Phase 4) recurses this without doing any filter / group /
sort work itself.
Output shape:
type NavNode = {
title: string, // page.frontmatter.title verbatim
url: string, // page.permalink verbatim
children: NavNode[], // empty array for leaves
};
site.navTree: NavNode[]; // top-level pages in render orderAlgorithm (port of _plugins/nav-tree-precompute.rb):
- Compute
topLevel: pages with noparent, filtered to non-nav_exclude, sorted (§6.2). - From the shared state, get
orderedChildren: Map<url, Page[]>. - Walk top-down, building
NavNodehashes:chainaccumulates the URLs visited so far (cycle defence — drop any child whose URL is already inchain).- At each node, materialise its children recursively up to
MAX_DEPTH = 16.
- Return
navTreeas the array of top-level NavNodes.
Why the cycle defence by URL, not by reference. A page might
appear under multiple parents (the same child Page object can be in
two different parents' orderedChildren arrays — see §5.4's
"Constants Module" example). The same Page reaching itself via a
different parent chain is not a cycle in the source data — the
cycle is "A's child list contains B, B's child list contains A". URL
comparison catches that directly; reference comparison would too in
this case, but URL is the conceptually correct test.
Duplicates are preserved. When a child has parent: X and two
pages are titled "X" (and the child has no disambiguating
grand_parent), the same child appears in both parents' children
arrays. The nav-integrity-check (§5.2) catches this as ambiguity, so
in practice this only happens to pages the maintainers have
explicitly opted into duplication for. The walker doesn't need to
deduplicate.
Purpose. Per-page positional coordinates [1, i, j, k, ...] for the
nav-activation CSS. The CSS uses :nth-child() selectors driven by
these indices to bold the current page's link, unfold its ancestor
collections, and rotate their expander icons — the no-JS fallback.
Output shape:
page.navLevels: Array<Integer> | undefined;The array shape, mirroring the upstream just-the-docs algorithm byte-for-byte:
| Index | Meaning |
|---|---|
[0] |
Collection-prefix index. Always 1 on this site (no just_the_docs.collections configured). |
[i] for i >= 1 |
1-based position of the i-th ancestor in its parent's sorted, nav-filtered children list. |
.length |
pageDepth + 1 where pageDepth = 1 for top-level pages, 2 for their children, etc. |
undefined when the page is not in the nav (no title, nav_exclude: true, broken parent chain, or filtered out everywhere by
grand_parent disambiguation).
Algorithm (port of _plugins/nav-levels-precompute.rb):
- Compute
topLevel(same as §5.3). - Build
topIndex: Map<url, Integer>fromtopLevel's 1-based positions. - From the shared state, get
orderedChildrenand derivechildIndex: Map<parentUrl, Map<childUrl, Integer>>for O(1) child-position lookup. - Walk top-down in sorted order. The walker records
paths.get(url)the first time it reaches each page (subsequent visits don't overwrite). Bound atMAX_DEPTH = 16; skip any child already in the current chain. - For each titled page, derive
navLevelsfrom its recorded chain:[1, topIndex.get(chain[0].url), childIndex.get(chain[0].url).get(chain[1].url), ...].- If the top-level page's URL isn't in
topIndex, the page has no valid path — setnavLevels = undefined.
Why top-down + first-encounter. Bottom-up walking from a page to its
parent works for the unambiguous majority, but breaks when the page's
declared parent matches multiple titled pages and there's no
disambiguating grand_parent. The upstream just-the-docs nav renders
such children under every matching parent (per §5.3); its activation
CSS uses whichever copy comes first in render order to anchor the
positional path. Mimicking that "first occurrence in render order"
without rendering the nav requires walking the tree top-down in sort
order and recording each page's chain on first encounter.
The canonical example on this site: VbAppWinStyle (and other VBA
constants enum pages) declares only parent: Constants Module, and
two pages on the site have that title — one under VBA Package, one
under VBRUN Package. The VBA constants render twice in the nav; the
activation CSS uses the first occurrence (the VBA one in this case,
since VBA sorts before VBRUN alphabetically). Our top-down walker
visits the VBA branch first and records the chain there, so subsequent
visits to the same VbAppWinStyle Page (via the VBRUN branch) leave
the recorded chain alone.
Purpose. Per-page root-first ancestor chain [{ title, url }, ...].
The breadcrumb-strip template iterates this directly.
Output shape:
page.breadcrumbs: Array<{ title: string, url: string }>;Empty array for top-level pages (those without parent). The current
page itself is not in the chain — the template renders it
separately as a <span> after the loop.
Algorithm (port of _plugins/breadcrumbs-precompute.rb):
- From the shared state, get
byTitle: Map<title, Page[]>. - For each titled page, walk the parent chain upward up to
MAX_DEPTH = 8:- At each step, look up the next ancestor via
resolveParent(parentTitle, grandParentTitle, byTitle). - When candidates is a list of more than one and
grandParentTitleis set, narrow to those whose ownparent === grandParentTitle. Fall back tocandidates[0]if no narrowed match (the convention on this site is to declaregrand_parentonly when needed, so fall-through is safe). - Prepend
{ title: ancestor.frontmatter.title, url: ancestor.permalink }to the chain and step up.
- At each step, look up the next ancestor via
- Store the chain on
page.breadcrumbs.
Why MAX_DEPTH = 8, not 16. The deepest legitimate chain on this site is 5 (Reference Section → Packages → VBA Package → Strings Module → Len). 8 leaves headroom and guarantees termination on accidental cycles. The nav-tree/nav-levels MAX_DEPTH of 16 is more generous because those walkers go top-down through the whole tree (16 is the max nesting depth); breadcrumbs walks upward from a single page, so the bound is the max depth a single page can be at.
Note on byTitle over nav-visible vs. all titled pages. The Ruby
breadcrumbs plugin uses titled = site.pages.select { |p| p.data["title"] }
— it does NOT filter nav_exclude out. So an ancestor that's
nav_exclude: true still appears in breadcrumbs. This is intentional:
breadcrumbs aren't a navigation surface, they're a position marker. The
JS port matches this — use the byTitle map built over all titled
pages, not just nav-visible ones.
Purpose. Per-page list of immediate nav children with optional
summaries, for the auto-generated TOC at the bottom of any parent page
where frontmatter.has_toc !== false. The Ruby plugin produces an
array of plain hashes; we do the same.
Output shape:
page.children: Array<{ title: string, url: string, summary: string | undefined }>;Empty array for leaf pages (those that nothing declares as parent).
Algorithm (port of _plugins/children-precompute.rb):
- From the shared state, get
byParentTitle: Map<title, Page[]>. - For each titled page, find its candidate children
(
byParentTitle.get(page.frontmatter.title) || []). - Filter: drop children whose
grand_parentis set AND mismatchespage.frontmatter.parent. (Children with nogrand_parentpass unconditionally — the same disambiguation rule §5.3 / §5.4 apply.) - Sort with the four-bucket precedence (§6.2). Reverse when
page.frontmatter.child_nav_orderis"desc"or"reversed". - Map each survivor to
{ title, url, summary }.
No nav_exclude filter. Unlike nav-tree and nav-levels, the
children list does NOT filter nav_exclude: true pages. The auto-TOC
at the bottom of a parent should include every sub-page regardless of
sidebar visibility. The Ruby plugin matches the upstream behaviour
(children_nav.html iterates site.html_pages | group_by: "parent",
which doesn't filter nav_exclude).
Summary field handling. page.frontmatter.summary may be:
- A string → emitted as-is.
- Missing → JS
undefined. The template guards withif (summary). - Other types (number, boolean, list) → not used on this site;
pass through whatever the YAML parser produced. If a future
page sets
summary: 42, the template renders42.
Purpose. Per-page SEO metadata for the <head> block (canonical
URL, og:title, og:url, og:site_name, JSON-LD WebPage/WebSite) plus
site-level constants (site title, logo URL).
Output shape:
page.seoTitle: string;
page.seoFullTitle: string;
page.seoCanonical: string;
page.seoIsHome: boolean;
site.seoSiteTitle: string;
site.seoLogoUrl: string | null;Algorithm (port of _plugins/seo-precompute.rb):
- Compute
seoSiteTitle=renderTitle(config.title). - Compute
seoLogoUrl=config.logo ? uriEscape(absoluteUrl(config.logo, config)) : null. - For each page:
-
rawTitle = page.frontmatter.title. -
seoTitle = rawTitle ? renderTitle(rawTitle) : seoSiteTitle. -
seoFullTitle = (seoTitle === seoSiteTitle) ? seoTitle :${seoTitle} | ${seoSiteTitle}`. -
canonicalInput = page.permalink.replace(/\/index\.html$/, "/"). -
seoCanonical = absoluteUrl(canonicalInput, config). -
seoIsHome = HOMEPAGE_URLS.has(page.permalink).
-
Where:
renderTitle(text)=escapeOnce(normalizeWhitespace(stripHtml(markdownify(text)))), the same pipeline the Ruby plugin runs. See §6.3 for the helper details.absoluteUrl(input, config)= ifinputis already absolute, return it. Else computerelativeUrl(input, config)(=(baseurl || "") + ensureLeadingSlash(input)), prependconfig.url, then normalize via URL parsing.uriEscape(input)= percent-encode any character that needs it. Equivalent to Ruby'sAddressable::URI.normalize_component.HOMEPAGE_URLS=new Set(["/", "/index.html", "/index.htm", "/about/", "/about/index.html", "/about/index.htm"]).
Markdownification. The Ruby version calls Jekyll's kramdown converter. The JS version should use the same markdown-it instance Phase 3 will set up. If markdown-it isn't initialised yet when Phase 2 runs (it doesn't need to be — Phase 3 sets it up), Phase 2 instantiates its own minimal markdown-it for title rendering. Differences between the two parsers would only matter for the 2 of 836 page titles that contain markdown-active characters; for byte-exact parity with the Ruby output, the implementer should diff those 2 pages' rendered titles against Jekyll's output and adjust if needed.
URL absolutisation. The Ruby plugin uses Addressable::URI for both
parsing and normalisation. In JS, Node's built-in URL works for
absolute URLs but doesn't always match Addressable on edge cases (e.g.
trailing-slash handling on the origin). For this site, config.url
is "https://docs.twinbasic.com" (no trailing slash) and baseurl is
empty, so the concatenation is straightforward. The implementer should
keep the helper small (no regex normalisation), and verify byte-parity
via Phase 1's verify-phase1.mjs-style harness extended for Phase 2.
Purpose. Walk _data/book.yml once and resolve every entry's
chapter list to an Array<Page> plus pre-resolve landing_page /
foreword_page references. The Phase 8 renderer iterates the resolved
data without doing any selector matching.
Output (mutated on bookData in place):
For front-matter entries:
bookData.front_matter[i]._chapters: Page[];For flat parts:
bookData.parts[i]._chapters: Page[];
bookData.parts[i]._landing: Page | undefined; // optional pre-resolve
bookData.parts[i]._foreword: Page | undefined; // optional pre-resolveFor chaptered parts:
bookData.parts[i]._foreword: Page | undefined;
bookData.parts[i]._landing: Page | undefined;
bookData.parts[i].chapters[j]._chapters: Page[];
bookData.parts[i].chapters[j]._landing: Page | undefined;The Ruby plugin precomputes _chapters but leaves the landing_page /
foreword_page lookups to book.html (each is a where: "url" filter
call). Since where: "url" is O(n) over all pages and we're already
iterating the book here, pre-resolving them too has zero marginal cost
and saves Phase 8 from re-walking pages[]. Recommended.
Algorithm (port of _plugins/book-resolve-chapters.rb + book-sort.rb):
- Load
_data/book.ymlviajs-yaml(or any YAML parser). - For each
front_matter[]entry:_chapters = sortByNavOrder(collectMatches(entry, pages)).
- For each
parts[]entry:- If
entry.chaptersis set (chaptered part):- Pre-resolve
entry._forewordandentry._landingfromentry.foreword_page/entry.landing_pagevia URL lookup. - For each
chapterinentry.chapters:chapter._chapters = buildChapterList(chapter, pages).chapter._landingfromchapter.landing_page.
- Pre-resolve
- Else (flat part):
entry._chapters = buildChapterList(entry, pages).- Pre-resolve
entry._foreword/entry._landingfrom URLs.
- If
Where:
buildChapterList(entry, pages):- Start with
[entry._landing]if landing exists. - Add
collectMatches(entry, pages)minus the landing page. - Sort the second list via
sortByNavOrder, leave the landing at position 0.
- Start with
collectMatches(entry, pages):- For each URL prefix in
entry.page/entry.pages:- If
entry.no_descent→ exact equality (page.permalink === prefix). - Else → substring match (
page.permalink.includes(prefix)).
- If
- For each nav-path prefix in
entry.nav_page/entry.nav_pages:- If
entry.no_descent→ exact equality (page.navPath === np). - Else → substring match (
(page.navPath || "").includes(np)).
- If
- Concatenate all hits in declaration order; the sort step dedupes.
- For each URL prefix in
sortByNavOrder(pages): see §6.4.
Type carriers. The Ruby sort_by_nav_order filter handles three
input types (Jekyll::Page, Drops::PageDrop, Hash) because Liquid
passes pages through filters in any of those shapes. In JS we only
have one carrier (the Page object), so the helpers pageUrl(p) and
pageAttr(p, key) from the Ruby version collapse to direct field
accesses. The implementer should NOT port the three-way dispatch —
it's dead complexity in JS.
page vs nav_page semantics:
| Selector | Match against | Default | With no_descent |
|---|---|---|---|
page: "/Foo" |
permalink |
includes("/Foo") |
=== "/Foo" |
pages: ["/Foo", "/Bar"] |
permalink |
includes(prefix) per entry |
=== prefix per entry |
nav_page: "Reference Section/Operators" |
navPath |
includes(np) |
=== np |
nav_pages: [...] |
navPath |
includes(np) per entry |
=== np per entry |
A page with no navPath (untitled) is treated as having navPath === "" for the comparison — matches includes("") (always true, but no
untitled pages have selectors in book.yml today) but not === np for
any non-empty np.
Purpose. Stamp the PDF title page with Built <date> from commit <short-hash> (<commit-date>). Used only by Phase 8 (book.html), but
captured here because it's a one-time pre-render operation and natural
to bundle with the other site-level state.
Output:
site.buildInfo: { commit: string, commitDate: string };Values:
commit— output ofgit rev-parse --short HEAD, stripped, or"unknown"when the shell-out fails (nogiton PATH, not a repo).commitDate— output ofgit log -1 --format=%cs, stripped (ISO short date format, e.g."2026-05-16"), or"unknown".
Algorithm (port of _plugins/build-info.rb):
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const exec = promisify(execFile);
export async function captureBuildInfo() {
const [commit, commitDate] = await Promise.all([
git("rev-parse", "--short", "HEAD"),
git("log", "-1", "--format=%cs"),
]);
return { commit, commitDate };
}
async function git(...args) {
try {
const { stdout } = await exec("git", args);
return stdout.trim();
} catch {
return "unknown";
}
}Why execFile, not exec. The arguments are fixed (no
user-supplied paths or args); execFile avoids spawning a shell and
sidesteps quoting concerns on Windows. Both git calls run in
parallel via Promise.all — ~10 ms total wall-time on a warm OS.
Fallback policy. On error (git missing, not a repo, EPERM,
whatever), the field becomes "unknown" — no throw, no warn. The
Phase 8 template handles "unknown" with a degraded message
("Built ." instead of "Built from commit X (Y)."). The
Ruby plugin uses the same fallback for the same reason: an offline
tarball install shouldn't break the build.
Returns the structures the nav substeps all consume:
{
titled, // Page[] — pages with non-empty frontmatter.title
byTitle, // Map<title, Page[]>
byParentTitle, // Map<parentTitle, Page[]>; empty-string key holds
// top-level pages (parent missing or empty)
caseInsensitive, // boolean — config.nav_sort === "case_insensitive"
topLevel, // Page[] — sorted, nav_exclude-filtered top-level
orderedChildren, // Map<url, Page[]> — sorted, filtered children
// per parent.url (applies grand_parent
// disambiguation and child_nav_order reversal)
}The build:
const titled = pages.filter(p => isNonEmptyString(p.frontmatter.title));
const byTitle = groupBy(titled, p => p.frontmatter.title);
const byParentTitle = groupBy(titled, p =>
isNonEmptyString(p.frontmatter.parent) ? p.frontmatter.parent : ""
);
const caseInsensitive = config.nav_sort === "case_insensitive";
const topLevel = sortPages(
(byParentTitle.get("") || []).filter(p => !p.frontmatter.nav_exclude),
caseInsensitive,
);
const orderedChildren = new Map();
for (const parent of titled) {
orderedChildren.set(parent.permalink, orderedChildrenFor(parent, byParentTitle, caseInsensitive));
}The four-bucket sort that just-the-docs uses for nav items. Ports
_includes/components/nav/sorted.html and the matching sort_pages
methods in the Ruby plugins.
function sortPages(pages, caseInsensitive) {
const navNum = [], navStr = [], titleNum = [], titleStr = [];
for (const p of pages) {
if (p.frontmatter.nav_order != null) {
(typeof p.frontmatter.nav_order === "number" ? navNum : navStr).push(p);
} else {
(typeof p.frontmatter.title === "number" ? titleNum : titleStr).push(p);
}
}
navNum.sort((a, b) => a.frontmatter.nav_order - b.frontmatter.nav_order);
navStr.sort((a, b) => cmp(sortKey(a.frontmatter.nav_order, caseInsensitive),
sortKey(b.frontmatter.nav_order, caseInsensitive)));
titleNum.sort((a, b) => a.frontmatter.title - b.frontmatter.title);
titleStr.sort((a, b) => cmp(sortKey(a.frontmatter.title, caseInsensitive),
sortKey(b.frontmatter.title, caseInsensitive)));
return [...navNum, ...navStr, ...titleNum, ...titleStr];
}
function sortKey(value, caseInsensitive) {
const s = String(value);
return caseInsensitive ? s.toLowerCase() : s;
}
function cmp(a, b) { return a < b ? -1 : a > b ? 1 : 0; }Why the buckets. Pages with numeric nav_order come first, then
string nav_order, then pages without nav_order sorted by title
(numeric titles first). Mixing numbers and strings in one sort would
either coerce both to strings (so 10 sorts before 2) or both to
numbers (so non-numeric strings become NaN). The bucket scheme is
just-the-docs's convention; matching it is the only way to get
byte-equivalent rendered HTML.
Type detection. Ruby's value.is_a?(Numeric) matches Integer +
Float. The YAML loader (js-yaml) produces Number for both YAML int
and YAML float; typeof v === "number" is the JS equivalent.
const STRIP_HTML_BLOCKS = /<script.*?<\/script>|<!--.*?-->|<style.*?<\/style>/gms;
const STRIP_HTML_TAGS = /<\/?[^>]+>/g;
const HTML_ESCAPE_ONCE_REGEXP = /["><']|&(?!([a-zA-Z]+|(#\d+));)/g;
const HTML_ESCAPE = { "&": "&", ">": ">", "<": "<", '"': """, "'": "'" };
function renderTitle(text, markdown) {
if (text == null) return "";
const s = String(text);
if (s === "") return "";
const html = markdown.render(s); // markdown-it
const stripped = html.replace(STRIP_HTML_BLOCKS, "").replace(STRIP_HTML_TAGS, "");
const collapsed = stripped.replace(/\s+/g, " ").trim();
return collapsed.replace(HTML_ESCAPE_ONCE_REGEXP, m => HTML_ESCAPE[m]);
}Mirrors the Liquid filter chain text | markdownify | strip_html | normalize_whitespace | escape_once. Constants ported from
Liquid::StandardFilters (the Ruby plugin's source for them) — Liquid
applies them globally, so they're worth matching verbatim.
Trailing newline from markdown-it. markdown-it.render() returns
the rendered HTML with a trailing \n. normalize_whitespace's
/\s+/g → " " plus .trim() strips it back out. No special case
needed.
function sortByNavOrder(pages) {
pages = [...new Set(pages)]; // dedupe
const indexUrls = pages
.filter(p => p.permalink.endsWith("/"))
.map(p => p.permalink);
const groups = new Map();
for (const p of pages) {
const url = p.permalink;
let key;
if (url.endsWith("/")) {
key = url;
} else {
const owners = indexUrls.filter(iu => url.startsWith(iu));
key = owners.length ? owners.reduce((a, b) => a.length >= b.length ? a : b) : url;
}
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(p);
}
const sortedGroups = new Map();
for (const [k, members] of groups) sortedGroups.set(k, sortWithinGroup(members));
const orderedKeys = [...sortedGroups.keys()].sort((kA, kB) => {
const a = sortedGroups.get(kA)[0], b = sortedGroups.get(kB)[0];
const aOrder = a.frontmatter.nav_order ?? Infinity;
const bOrder = b.frontmatter.nav_order ?? Infinity;
if (aOrder !== bOrder) return aOrder - bOrder;
return (a.frontmatter.title || "").toLowerCase()
.localeCompare((b.frontmatter.title || "").toLowerCase());
});
return orderedKeys.flatMap(k => sortedGroups.get(k));
}
function sortWithinGroup(members) {
const indexes = members.filter(p => p.permalink.endsWith("/"))
.sort((a, b) => a.permalink < b.permalink ? -1 : a.permalink > b.permalink ? 1 : 0);
const leaves = members.filter(p => !p.permalink.endsWith("/"));
const withOrder = leaves.filter(p => p.frontmatter.nav_order != null);
const withoutOrder = leaves.filter(p => p.frontmatter.nav_order == null);
withOrder.sort((a, b) => {
const cmp = a.frontmatter.nav_order - b.frontmatter.nav_order;
if (cmp !== 0) return cmp;
return (a.frontmatter.title || "").toLowerCase()
.localeCompare((b.frontmatter.title || "").toLowerCase());
});
withoutOrder.sort((a, b) =>
(a.frontmatter.title || "").toLowerCase()
.localeCompare((b.frontmatter.title || "").toLowerCase()));
return [...indexes, ...withOrder, ...withoutOrder];
}Different sort from §6.2. sortByNavOrder (used by book chapter
resolution) clusters index pages with their leaves and uses mixed
numeric/title comparison; sortPages (used by nav) buckets by type.
They are NOT interchangeable. Keep them separate, with header
comments noting they exist for different purposes.
The Ruby book-sort.rb plugin has the canonical write-up of why
folder-style indexes group with their leaves — read its header
comment when porting.
Matches Phase 1's Page shape (each page is a record that accumulates
fields phase by phase) and the Ruby plugins (which mutate
page.data). Alternative: return a parallel Map<permalink, ComputedFields>.
Rejected because the template phase wants one record per page, not
two records joined by URL.
Phase 1 set the precedent: srcPath / destPath (camelCase) for
computed, frontmatter.permalink / frontmatter.nav_order (verbatim
YAML) for raw. Phase 2 extends the same: page.navTree,
page.breadcrumbs, page.seoTitle are computed → camelCase;
page.frontmatter.nav_order, bookData.parts[0].landing_page are
verbatim YAML → snake_case.
The Ruby plugins use snake_case throughout because Ruby and Liquid both standardise on it. Our JS templates standardise on camelCase, so that's what they expect to read.
The six nav-related Ruby plugins all consume the same shared state (titled set, byTitle / byParentTitle maps, four-bucket sort, ordered-children map). Splitting them across six JS modules would force each to rebuild the state or import it from a seventh "shared state" module. One module shares the state implicitly and keeps the related logic in one place. ~600 lines, manageable.
If a future maintainer wants to split, the substeps are mechanically
extractable — each is a function that takes (pages, state) and
returns or mutates.
The git shell-out is conceptually independent of the rest of Phase 2,
but it's a one-time pre-render compute that produces a site-level
field. Putting it anywhere else (Phase 1 / Phase 8 / its own phase)
splits the "what's on site.*?" surface across more modules without
benefit. It's a 30-line module; the cost of having it sit next to
nav.mjs is negligible.
The Ruby plugin precomputes _chapters but leaves the single-page
landing/foreword lookups to book.html's Liquid where: "url"
filter. We're already iterating pages[] for _chapters; adding two
more URL → Page lookups in the same pass is free. Phase 8 then has no
pages-walk at all — pure data assembly.
SEO precompute (§5.7) needs to markdownify page titles. Phase 3 needs
markdown-it for the body content. Sharing one instance is fine —
markdown-it is stateless across .render() calls (configuration is
fixed at construction).
If Phase 2 runs before Phase 3 has constructed its markdown-it, Phase 2 has to construct its own. Two acceptable resolutions:
- Construct the markdown-it instance in Phase 2 and pass it to Phase 3.
- Construct a minimal markdown-it (no syntax-highlighting, no extras) in Phase 2 just for title rendering. Phase 3 builds its own with the full plugin chain. The output is the same for the ~2 page titles that contain markdown.
Recommended: option 2. Phase 2 doesn't want a Shiki dependency. Title rendering doesn't need GFM tables, syntax highlighting, or the admonition plugin. A bare-bones markdown-it is ~100 KB and starts in <10 ms.
nav-integrity-check (§5.2) should throw new Error(...) with a
multi-line message listing every offending page. The orchestrator's
top-level .catch() in tbdocs.mjs already prints the error and exits
non-zero. Same end-user experience as Jekyll's Jekyll::Errors::FatalException.
Alternative: return an array of errors and let the orchestrator decide what to do. Rejected because there's nothing useful to do — every downstream phase assumes the nav graph is well-formed.
MAX_DEPTH = 16in nav-tree and nav-levels (top-down walk; max nesting depth).MAX_DEPTH = 8in breadcrumbs (bottom-up walk; max single-page depth).
The Ruby comments justify these numbers from the deepest legitimate chain on the site (5). The JS port keeps the same numbers. Bumping either would only matter on cycle-defence (where larger means more work before bailing out); the legitimate case never gets close.
The Ruby plugin uses Addressable::URI because Ruby's stdlib URI
has rough edges around normalisation. Node's URL is good enough for
our use: config.url is a clean absolute origin, baseurl is empty,
and the canonical inputs are always rooted paths. The implementer
should verify byte-parity for the SEO canonical and og:url fields
via diff against Jekyll's output during Phase 2 acceptance.
The Phase 1 array is sorted by srcRel. Phase 2 doesn't re-sort
pages[] itself; it sorts subsets (top-level, children of a parent,
etc.) for its own purposes. The pages[] order matters for
determinism of build output, not for the algorithms in Phase 2.
Jekyll exposes page.url (the canonical URL) and Liquid filters can
do page.url | relative_url. In our JS world, page.permalink is
the canonical URL (see PLAN-1.md §8). The template phase will call a
relativeUrl(page.permalink, fromPage) helper. Phase 2 does NOT
precompute page.url or page.relativeUrl — that's Phase 4's job.
book.html has permalink: /book.html, no title, sitemap: false.
navPath: not added (no title).breadcrumbs: not added (only titled pages get this).children: not added.navLevels: not added.seoTitle: falls back toseoSiteTitle(no title).seoFullTitle: collapses toseoSiteTitle.seoCanonical:<site.url>/book.html.seoIsHome: false (not in HOMEPAGE_URLS).
No special case needed in Phase 2. Phase 8 retrieves book.html by
URL when rendering the PDF.
Per PLAN-1.md D4, Phase 1 does not load _data/. Phase 2's
book.mjs is the only consumer of _data/book.yml, so it owns the
read. The orchestrator passes srcRoot; the module reads
<srcRoot>/_data/book.yml itself.
If a future generic data-loader module appears (covering
site.data.* more broadly), the book.yml load would move there. None
of the other data files are currently used; site.data.build (the
build-info plugin) is set programmatically, not from a file.
| Case | Handling |
|---|---|
Page with title but nav_exclude: true |
In titled, in byTitle, in byParentTitle. NOT in topLevel (filtered out). NOT in any parent's orderedChildren (filtered out). Gets navPath (only requires title). Gets breadcrumbs (titled-only filter). Gets children (titled-only filter). Does NOT get navLevels (the walker never reaches it). |
Page with no title |
Not in titled. No navPath, no breadcrumbs, no children, no navLevels. |
Page with parent matching a non-existent title |
nav-integrity-check raises orphan diagnostic and aborts the build. |
Page with parent matching multiple titles, no grand_parent |
nav-integrity-check raises ambiguity diagnostic and aborts the build (unless the duplication is intentional and the maintainers accept the dual nav placement — current site does have this for VBA/VBRUN Constants Module children, so the check does NOT abort on those; see §5.2 for the resolution logic). |
Page with parent matching multiple titles + grand_parent that disambiguates to exactly one |
Pass; uses the disambiguated parent. |
Page with parent matching multiple titles + grand_parent that doesn't match any |
nav-integrity-check raises orphan diagnostic (the grand_parent filter rejected all candidates). |
Page with child_nav_order: "desc" |
Its orderedChildren is reversed after sort. Affects navTree, navLevels, and children-precompute identically. |
Page declaring itself as its own parent (parent: <same as title>) |
Cycle defence kicks in: the page never appears as its own child in orderedChildren. The walker continues past it. |
| Deeper cycle (A → B → A) | Cycle defence (URL membership in current chain) catches it at the closing edge. The opening edge succeeds. Cycle never makes it to navTree. |
| Top-level page list is empty | navTree is []. Template renders an empty nav list. Should never happen on this site. |
| Case | Handling |
|---|---|
Top-level page (no parent) |
breadcrumbs = []. |
Page with parent that has no parent itself |
breadcrumbs = [{ title: ..., url: ... }] — one entry. |
| Parent chain that runs 9+ levels deep | Walker hits MAX_DEPTH = 8 and stops. The truncated chain is what gets stored. (Doesn't happen on this site; the deepest is 5.) |
Ancestor that has nav_exclude: true |
Still appears in the chain (breadcrumbs walks all titled pages, not just nav-visible ones — §5.5 rationale). |
| Case | Handling |
|---|---|
| Leaf page (no other page declares it as parent) | children = []. |
Parent page with mix of nav_exclude: true and not |
All children appear in the list — children-precompute does not filter nav_exclude (§5.6 rationale). |
Child without summary |
The summary field is undefined. Template uses if (summary) to decide whether to render the dash + text. |
Child with child_nav_order on the child (not the parent) |
Ignored; child_nav_order only applies on the parent for its own children list. |
| Case | Handling |
|---|---|
Page with no title |
seoTitle = seoSiteTitle. seoFullTitle = seoSiteTitle. |
Page with title containing markdown-active chars (e.g. &, &=) |
Render via markdown-it, strip HTML, escape-once. Output for &, &= is &, &=. |
Page with permalink: /index.html |
seoCanonical strips the /index.html to /. (No current page uses this; the rule covers the Jekyll defaulting behaviour the Ruby plugin replicates.) |
Site with no logo: configured |
seoLogoUrl = null. Template guards. |
Site with no url: configured |
absoluteUrl() falls through to just the relative URL. (Not the case here; url: is set.) |
| Case | Handling |
|---|---|
Entry with both page: and pages: set |
Both contribute; both apply the same no_descent rule. |
Entry with both page: and nav_page: set |
All four kinds contribute. Common case (no current entry uses this, but the schema supports it). |
| Page matched by multiple selectors on the same entry | Appears multiple times in collectMatches output. The sortByNavOrder's pages = [...new Set(pages)] dedupe collapses it back to one. |
Entry with landing_page: set but the URL doesn't match any page |
_landing = undefined. The _chapters list still includes whatever the selectors swept in. Probably indicates a typo in book.yml; consider logging a warning. |
Entry with landing_page: whose URL matches a page that ISN'T in collectMatches's output |
Landing still emitted first. Rest of _chapters is whatever the selectors found. |
Chaptered part where chapters[] is empty |
The part loop runs 0 times. The part divider still emits in Phase 8. |
no_descent: true on a selector that's a single full URL |
Equivalent to a == match; works the same as without no_descent when the URL exactly matches a single page. (Useful for page: / which would otherwise sweep everything.) |
These belong in later phases. Mentioned here so the implementer doesn't get tempted.
- Markdown → HTML rendering. Phase 3. SEO title rendering is the only markdown call Phase 2 makes, and it's a tiny per-page operation on short strings.
- Template rendering. Phase 4. The
<style id="jtd-nav-activation">block (which consumespage.navLevels) is emitted by the template, not Phase 2. - Heading anchor injection. Phase 4.
- HTML compression. Phase 4.
- Search index emission. Phase 6.
- Sitemap emission. Phase 6 (and respects
frontmatter.sitemap === falseonbook.html). - Redirect stub generation. Phase 6 (consumes
frontmatter.redirect_from). page.last_modified_datefor the footer. Phase 4 reads it fromfs.stat()orfrontmatter.last_modified_date. Phase 2 doesn't plumb it through.- PDF assembly. Phase 8 reads the
bookDataPhase 2 produces. - Offline URL rewriting. Phase 7.
- The
nav_pathselector is NOT extended to cover untitled pages. An untitled page has nonavPath. The book.ymlnav_page/nav_pagesselectors would never match it. (Currently no untitled page needs to be in the book.)
- After Phase 2 runs on the production tree:
site.navTreeis an array ofNavNodeobjects.site.navTree.lengthmatches the count of top-level nav pages in Jekyll's nav tree (currently 10 — Welcome, Frequently Asked Questions, Tutorials, Features, Reference Section, Packages, Documentation Development, Challenges, Videos, IDE — verify by parsing the first<ul class="nav-list">of_site/index.htmland listing the<a class="nav-list-link">text at depth 1).- Every node has a non-empty
title, a non-emptyurl, and achildrenarray (possibly empty).
- Every titled page has
navPath,breadcrumbs,children. The ones reachable from a top-level page also havenavLevels. navPathfor a known fixture matches the Ruby output:Reference/Operators.md→"Reference Section/Operators"Reference/Core/Const.md→"Reference Section/Core Language Reference/Const"(or whatever the parent chain dictates)
breadcrumbs[0].urlfor every titled non-top-level page equals the URL of an ancestor (root-first ordering).navLevels[0] === 1for every page that hasnavLevels(the collection-prefix index is always 1).nav-integrity-checkaborts the build when a deliberately ambiguous fixture is introduced (test: temporarily add a page withparent: Reference Sectionand a non-unique title alongside another such page).- Every page has
seoTitle,seoFullTitle,seoCanonical,seoIsHome.site.seoSiteTitleandsite.seoLogoUrlare set. - For
index.md:seoIsHome === true. The page hastitle: WelcomesoseoFullTitle === "Welcome | twinBASIC Documentation"— only theseoFullTitle === seoSiteTitlecollapse would fire if a page had no title (in which caseseoTitlefalls back toseoSiteTitleand the two compare equal). No current page on the site hits that branch, so the harness verifieshome.seoFullTitlecontains the site title rather than equals it. - For
Reference/Core/Concat.md(title"&, &="):seoTitle === "&, &=". site.buildInfo.commitmatchesgit rev-parse --short HEADin the working tree.bookData.front_matter[0]._chaptersis an array of one Page (the homepage, since the entry ispage: /withno_descent: true).bookData.parts[0]._chaptersis undefined (the Features part is chaptered, not flat), butbookData.parts[0].chapters[0]._chaptersis non-empty.- For a flat part (FAQ):
bookData.parts[1]._chaptershas the FAQ page. - Resolved
_chaptersare deduped — no page appears twice in a single entry's_chapters.
verify-phase2.mjs (retired Phase 10) extends the verify-phase1.mjs
pattern. It:
- Runs
discover()then every Phase 2 substep, capturing per-substep wall time. - Asserts the items above against the production tree, plus a few
structural sanity checks the original list left implicit
(
navNode.childrenis always an array, every breadcrumb URL points at a real page, every_chaptersentry is unique, the planted- ambiguity test actually throws with an/ambiguity/imessage). - Prints
OK <check>per pass,FAIL: <reason>per failure, and exits non-zero on any failure. - Prints per-substep timings up front, with a
WARNline if the total exceeds 1 s.
23 checks total in the shipped harness — the 14 items above plus the expanded sanity checks. All 23 pass on the current production tree.
The ultimate test is "render with Jekyll, render with tbdocs through
Phase 4, diff the _site/ outputs". This is impractical until Phase
4 lands. Until then, the verification harness above is the bar.
When Phase 4 ships, the targeted diff is:
_site/index.html(homepage; touches navTree, breadcrumbs, SEO)._site/tB/Core/Const.html(typical reference page; touches breadcrumbs, navLevels, children)._site/tB/Packages/index.html(parent page withchildren_in_nav)._site/book.html(touches all of bookData resolution).
A clean diff on these four implies Phase 2 is producing the right data.
The orchestrator and verify-phase2.mjs both print per-substep timings
already. From the repo root:
node builder/tbdocs.mjs # one-line per-phase timings
node builder/verify-phase2.mjs # full 23-check harness + timingsMeasured wall time on the current Windows dev machine (mean of several runs), with the soft regression target each substep is held to:
| Substep | Measured | Target |
|---|---|---|
| nav (path + integrity + tree + levels + breadcrumbs + children) | 28 ms | <40 ms |
| seo (markdown-it init + per-page render) | 21 ms | <100 ms |
| book chapter resolution | 9 ms | <10 ms |
build-info (parallel git calls) |
≤2 ms CPU, ~10 ms wall in parallel | <50 ms wall |
| Phase 2 total | ~60 ms | <200 ms |
| Phase 1 (discover) for reference | ~120 ms | per PLAN-1 |
| Phase 1+2 end-to-end | ~180 ms | comfortably under the 2-3 s build goal |
markdown-it init turned out to be cheap (the seo substep covers init
- markdownification of 838 page titles in 21 ms), so the "dominated by markdown-it init" caveat the original target carried didn't materialise. The Ruby equivalents this replaces sum to ~1.5 s in Jekyll's GENERATE phase — about 25× the measured Phase 2 cost.
Cumulative dependencies after Phase 2 (PLAN-1 already required the first two):
{
"dependencies": {
"gray-matter": "^4.0",
"fast-glob": "^3.3",
"js-yaml": "^4.1",
"markdown-it": "^14.0"
}
}New in Phase 2:
js-yaml—_config.ymland_data/book.ymlloader. gray-matter already pulls injs-yamlas a transitive dep, so this is a zero-byte add; declaring it explicitly makes the dependency visible inpackage.json.markdown-it— title rendering for SEO. Will also be the core of Phase 3.
No new dev dependencies. verify-phase2.mjs runs on the standard
Node runtime.
<repo root>/
builder/
PLAN.md — architecture overview
PLAN-1.md — Phase 1 spec
PLAN-2.md — this file
package.json — js-yaml ^4.1 + markdown-it ^14.0 added
discover.mjs — Phase 1
nav.mjs — §5.1–§5.6 + §6.1–§6.2
seo.mjs — §5.7 + §6.3
book.mjs — §5.8 loader + resolver (Phase 8 renderer
lands here later)
build-info.mjs — §5.9
tbdocs.mjs — orchestrator extended (see below)
verify-phase1.mjs — Phase 1 harness (retired Phase 10)
verify-phase2.mjs — §10 acceptance harness (23 checks) (retired Phase 10)
docs/ — unchanged
The shipped orchestrator (see tbdocs.mjs for the file):
import { promises as fs } from "node:fs";
import path from "node:path";
import process from "node:process";
import yaml from "js-yaml";
import { discover } from "./discover.mjs";
import { computeNav } from "./nav.mjs";
import { precomputeSeo } from "./seo.mjs";
import { loadBookData, resolveBookChapters } from "./book.mjs";
import { captureBuildInfo } from "./build-info.mjs";
async function main() {
const { src } = parseArgs(process.argv.slice(2));
const srcRoot = path.resolve(process.cwd(), src);
const t = makeTimer();
const { pages, staticFiles } = await discover(srcRoot);
t.lap("discover");
// Issue build-info immediately so the git shell-outs overlap with the
// CPU-bound nav work.
const buildInfoPromise = captureBuildInfo();
const config = yaml.load(await fs.readFile(path.join(srcRoot, "_config.yml"), "utf8"));
const { navTree } = computeNav(pages, config);
t.lap("nav");
const { seoSiteTitle, seoLogoUrl } = precomputeSeo(pages, config);
t.lap("seo");
const bookData = await loadBookData(srcRoot);
resolveBookChapters(bookData, pages);
t.lap("book");
const buildInfo = await buildInfoPromise;
t.lap("buildInfo");
const site = { config, navTree, seoSiteTitle, seoLogoUrl, buildInfo, bookData };
console.log(`Phase 1+2 done: ${pages.length} pages, ${staticFiles.length} static files`);
console.log(t.summary());
if (pages.length < 836) {
console.error(`WARN: page count ${pages.length} below baseline 836`);
process.exitCode = 1;
}
return { pages, staticFiles, site }; // Phase 3+ chains in here.
}makeTimer() is a tiny console.time-ish helper for per-substep
wall-time. The PLAN-1 drift guard (page count < 836) was kept and
moved out of the Phase 1 epilogue into the combined orchestrator.
After Phase 2 lands, every per-page field the template phase iterates
is populated. The next session can implement Phase 3 (render.mjs +
highlight.mjs) by walking pages[] and rendering each rawContent
into an HTML body — no nav, SEO, or book logic to think about.
Phase 4 (template.mjs) then assembles the final HTML by reading
both Phase 1 fields (destPath, layoutDefault) and Phase 2 fields
(navTree, breadcrumbs, navLevels, children, seo*,
bookData) — no further computation, just string concatenation.
That clean handoff is the whole point of having a compute phase as a standalone step.