Skip to content

Spike: static-site generator evaluation for the docs site

Status: Complete — recommendation below. Implemented in #689: the docs-site tooling is now engine-pluggable — VitePress (the recommendation) is the default and Astro Starlight (the runner-up) ships as a selectable alternative for a live A/B. Quartz and fix-links.js were removed. References to Quartz and fix-links.js below describe the pre-migration state this spike evaluated. Question (#487): Quartz 4.4.0 emits internal links that are off-by-one ../ under Cloudflare Pages’ flat (no-trailing-slash) serving, forcing a post-build workaround (scripts/docs-site/fix-links.js). Would a different SSG — or a newer Quartz — render BoB’s Diátaxis docs correctly without post-build hacks, while staying markdown-native? Origin: Surfaced by the #463/#464 documentation overhaul (flat docs/ → category subdirs), which made the link bug pervasive. Date: 2026-06-27.

  • Quartz v5 does not fix the problem. The current pin (v4.4.0) and the newest line (v5.0.0) both emit depth-relative links computed against trailing-slash directory URLs (note/index.html). Quartz has no trailingSlash/cleanUrls/flat output option, so the off-by-one persists in v5 — an upgrade alone keeps fix-links.js.
  • Recommendation: migrate to VitePress. It is the only candidate that resolves cross-directory links correctly under flat Cloudflare-Pages serving natively, with zero config and no post-build rewriting — because it emits flat .html files by default and the relative links are sized for that flat layout. It is Node-native (matches the existing toolchain), fast, actively maintained, and has built-in local search.
  • Runner-up: Astro Starlight. Solves the problem with two config lines + root-absolute authored links, and additionally gives folder-autogenerated Diátaxis nav, Pagefind search, and the richest theming/plugin ecosystem (including a wikilink plugin).
  • The one real regression in any migration: Quartz’s signature graph view + automatic backlinks. No mainstream alternative replicates it. If that feature is considered load-bearing for “Big ol’ Brain”, the alternative is to keep Quartz and keep fix-links.js as a deliberate, documented stopgap — but that does not meet the “no post-build hacks” goal.

AC-01 — The cost of the status quo (current Quartz pain)

Section titled “AC-01 — The cost of the status quo (current Quartz pain)”

Quartz computes every internal <a class="internal"> link as a relative path (./, ../) and emits each page folder-style as note/index.html, i.e. at a trailing-slash directory URL (/note/). The relative links are sized against that directory assumption.

Cloudflare Pages, however, serves the build’s flat <slug>.html files at no-trailing-slash URLs (/note). At a no-slash URL the browser’s base directory is one level shallower than Quartz assumed, so every cross-directory ../ overshoots by exactly one segment. Concretely (from the fix-links.js header):

the handbook’s ../reference/glossary.md renders as ../.././reference/glossary, resolving to /reference/... (404) instead of /docs/reference/....

The #463 restructure (flat docs/ → Diátaxis category subdirs: tutorials/, how-to/, reference/, explanation/) turned this from an edge case into a site-wide breakage, because nearly every link is now cross-directory.

scripts/docs-site/fix-links.js (wired into scripts/docs-site/build.sh, run after the Quartz build) is a post-build HTML rewriter. For every emitted .html file it:

  1. Walks all <a class="internal"> links (skips external/absolute/anchor/mailto).
  2. Resolves each href against both a folder-base and a flat-base for the page, because Quartz is internally inconsistent (some links folder-relative, some flat).
  3. Picks whichever candidate points to a file that actually exists in public/.
  4. Rewrites it to a root-absolute /docs/... URL that resolves regardless of trailing-slash. Unverifiable links are left untouched (“never made worse”).

This is a ~80-line guess-and-check normalizer that must run on every build and re-derive the correct URL for every link by probing the filesystem. It works, but it is exactly the kind of post-build hack #487 wants to eliminate.

Item Value
Quartz pinned tag (init.sh) v4.4.0
Newest Quartz line (June 2026) v5.0.0 (default branch); last v4 tag v4.5.2
Workaround scripts/docs-site/fix-links.js + wiring in build.sh
URL-shaping config in Quartz baseUrl only (absolute artifacts), no flat/trailingSlash option

Note: Quartz’s GitHub Releases page stops at v4.0.8; later versions ship as git tags only, so the Releases API “latest” is misleading — the tag list / default branch are authoritative. v5 still has no output-URL-format setting.

Evaluated as of June 2026 against the criteria in AC-03. The decisive criterion — correct cross-directory link resolution under flat no-trailing-slash Cloudflare-Pages serving without post-build rewriting — is called out per candidate.

  • Flat-link resolution: NOT-SOLVED. Same model as 4.4.0 — relative links against folder/trailing-slash URLs, no flat/cleanUrls/trailingSlash option in cfg.ts. (markdownLinkResolution: shortest|absolute|relative controls wikilink-to-file matching, not output URL format — it does not help.) Still needs the rewrite hack.
  • Markdown-native: strongest — Obsidian-flavored MD, native [[wikilinks]], transclusions, callouts, frontmatter.
  • Diátaxis nav: auto “Explorer” tree from folders. Search: built-in (FlexSearch). Graph view + backlinks: yes (signature). Build: fast (esbuild), incremental. Theming: SCSS + Preact overrides; opinionated digital-garden look. Maintained: yes.
  • Migration: N/A (in-place upgrade; review v5 breaking changes).
  • Flat-link resolution: SOLVED-WITH-CONFIG. Sidebar/nav links are root-absolute and base-prefixed (immune to ../ depth). Fix the trailing-slash default with build: { format: 'file' } + trailingSlash: 'never' and author internal links as root-absolute (/reference/glossary). No post-processing. (Astro does not reliably rewrite relative .md→.md links — issue #5680 — so root-absolute authoring is the supported pattern.) The Cloudflare adapter dropping Pages support is SSR-only and irrelevant to a static upload.
  • Markdown-native: MD/MDX/Markdoc, schema’d frontmatter (title required). Wikilinks via plugin (remark-wiki-link / starlight-obsidian).
  • Diátaxis nav: sidebar: autogenerate: { directory } from folders. Search: built-in Pagefind. Graph/backlinks: no. Build: Astro/Vite (fast). Theming: richest — CSS vars, Tailwind, component overrides, large plugin ecosystem. Maintained: very active.
  • Migration: medium-high (content into src/content/docs/, frontmatter schema, wikilink conversion, links → root-absolute; lose graph/backlinks).
  • Flat-link resolution: SOLVED-NATIVELY. Emits flat .html files by default (getting-started.html, not .../index.html); inbound links are relative and browser-resolved against that flat layout, so there is no ../ off-by-one under flat serving — out of the box, no config, no post-processing. cleanUrls: true optionally drops the .html extension; Cloudflare Pages maps /foo/foo.html without a redirect, which the VitePress deploy docs call out as correct.
  • Markdown-native: markdown-it + Vue-in-Markdown, frontmatter, containers. Wikilinks via markdown-it plugin.
  • Diátaxis nav: sidebar is manual config by default; folder autogen via community vitepress-sidebar. Search: built-in local (MiniSearch). Graph/backlinks: no. Build: Vite (very fast HMR/builds). Theming: Vue default theme, extend/replace, CSS vars, slots — polished + customizable. Maintained: active (v2 alpha in progress).
  • Migration: medium (frontmatter largely compatible; wikilink conversion; author or autogen sidebar; lose graph/backlinks).
  • Flat-link resolution: SOLVED-WITH-CONFIG. Rewrites .md/.mdx links to the target’s root-absolute, baseUrl-prefixed URL at build time (React Router <Link>), independent of page depth → survives flat serving. Set trailingSlash: false for flat myDoc.html + no-slash links. The most robust link model of the set. No post-processing.
  • Markdown-native: MD + MDX (JSX). Wikilinks not native (custom remark / pre-convert).
  • Diátaxis nav: autogenerated sidebars from folders + _category_.json. Search: not built-in (Algolia DocSearch or local plugin). Graph/backlinks: not native (community docusaurus-graph). Build: Node/React/Webpack — heaviest toolchain, slowest builds. Theming: most flexible (swizzling, Infima, React). Maintained: Meta-backed, very active.
  • Migration: moderate (frontmatter remap; wikilink conversion; lose graph/backlinks).

MkDocs + Material (core 1.6.1 / Material 9.7.6)

Section titled “MkDocs + Material (core 1.6.1 / Material 9.7.6)”
  • Flat-link resolution: SOLVED-WITH-CONFIG. Default use_directory_urls: truepage/index.html + relative links against trailing-slash dirs → same off-by-one class as Quartz under flat serving. Set use_directory_urls: false → flat page.html + file-to-file relative links whose depth matches the URL at any nesting. CF 308-redirects .html→clean URL (one harmless hop). No post-build rewrite.
  • Markdown-native: Python-Markdown. Wikilinks via plugin (mkdocs-roamlinks etc.).
  • Diátaxis nav: explicit in mkdocs.yml by default; folder-driven via mkdocs-awesome-nav/literate-nav. Search: built-in (lunr). Graph/backlinks: no. Build: Python toolchain (not Node); fast incremental. Theming: flexible (Jinja2 overrides, palettes).
  • Maintenance caveat: Material for MkDocs entered maintenance-only mode in 2026 (last feature release Nov 2025, fixes through Nov 2026; maintainer moved to a new SSG, Zensical). A real longevity risk for a fresh 2026 choice.
  • Migration: moderate (reads YAML frontmatter; wikilink plugin; rebuild nav; lose graph/backlinks).
  • Flat-link resolution: SOLVED-NATIVELY. Output is flat and mirrors source 1:1 (src/reference/glossary.mdreference/glossary.html); cross-dir links are relative and resolve at any depth. CF’s default .html-dropping yields canonical no-slash URLs. No config flag, no hack. Costs: one 308 hop per cross-page click; avoid README/index leaf files (section indexes get a trailing slash); set site-url = "/docs/".
  • Markdown-native: CommonMark (pulldown-cmark). Wikilinks/backlinks not native.
  • Diátaxis nav: hand-maintained SUMMARY.md (or third-party autosummary). Search: built-in (Elasticlunr). Graph/backlinks: absent. Build: Rust binary (not Node); fastest. Theming: Handlebars + CSS vars (least component-driven). Maintained: active (rust-lang).
  • Migration: moderate-high — no native YAML frontmatter (needs a stripper; metadata effectively lost), wikilink conversion, author SUMMARY.md; frontmatter + graph + backlinks all regress.

AC-03 — Scored comparison matrix & recommendation

Section titled “AC-03 — Scored comparison matrix & recommendation”

The flat-serving link fix is the entire motivation for #487, so it dominates. Markdown nativeness and maintenance health are weighted next (a docs tool must read our corpus and must outlive the decision). Graph/backlinks is weighted modestly — it is a genuine Quartz strength but not why we’re here.

Criterion Weight
Correct flat-serving links (no post-build hack) 30
Markdown-native (incl. wikilink path) 15
Maintenance / longevity 15
Diátaxis folder nav 10
Full-text search 10
Build speed / toolchain fit (Node) 10
Theming flexibility 5
Graph view + backlinks 5

Each cell scored 0–5 (5 = best); weighted total normalized to 100.

Criterion (weight) Quartz v5 VitePress Starlight Docusaurus MkDocs+Material mdBook
Flat links, no hack (30) 0 5 4 4 3 4
Markdown-native (15) 5 4 4 4 3 2
Maintenance (15) 4 5 5 5 2 4
Diátaxis nav (10) 5 3 5 5 3 2
Search (10) 5 5 5 3 5 5
Build/toolchain (Node) (10) 4 5 5 3 2 3
Theming (5) 4 4 5 5 4 2
Graph + backlinks (5) 5 0 0 1 0 0
Weighted total /100 64 87 86 79 57 64

Each weighted total is Σ(score·weight) / 5 (max cell 5 × total weight 100 = 500, normalized to 100). Worked detail:

  • VitePress 87 = (5·30 + 4·15 + 5·15 + 3·10 + 5·10 + 5·10 + 4·5 + 0·5)/5 = (150+60+75+30+50+50+20+0)/5 = 435/5.
  • Starlight 86 = (4·30 + 4·15 + 5·15 + 5·10 + 5·10 + 5·10 + 5·5 + 0·5)/5 = (120+60+75+50+50+50+25+0)/5 = 430/5.
  • Quartz v5 64 = (0·30 + 5·15 + 4·15 + 5·10 + 5·10 + 4·10 + 4·5 + 5·5)/5 = (0+75+60+50+50+40+20+25)/5 = 320/5. Note: Quartz scores well on everything except the one decisive criterion — it is an excellent tool whose single disqualifier here is the flat-serving link bug it cannot fix natively.

VitePress and Starlight are close (87 vs 86); VitePress edges ahead purely on the decisive criterion — a native solve beats a config solve for a goal whose whole point is “no hacks”. Starlight wins on nav autogen + theming. Both are defensible.

Recommendation: migrate to VitePress (Starlight a close second)

Section titled “Recommendation: migrate to VitePress (Starlight a close second)”

Why VitePress. It is the only candidate that satisfies AC-04’s bar — correct cross-directory links on flat Cloudflare-Pages serving with no post-build rewriting and no special confignatively, because its default output is flat .html with relative links sized for flat serving. It is Node-native (the existing docs-site toolchain is already Node/npm), builds fast on Vite, ships built-in local search, and is actively maintained.

When to prefer Starlight instead. If folder-autogenerated Diátaxis sidebars and the richest theming/plugin ecosystem (incl. an Obsidian/wikilink plugin) matter more than a zero-config link solve, Starlight is the better pick — it costs two config lines (build.format: 'file' + trailingSlash: 'never') and a switch to root-absolute authored links, and gives a more “docs-product” feel out of the box. VitePress’s one notable gap vs Starlight is sidebar autogen (needs the vitepress-sidebar plugin).

What regresses in any migration (and the honest counter-option). Quartz’s graph view and automatic backlinks have no equivalent in VitePress, Starlight, Docusaurus, MkDocs, or mdBook. Wikilinks ([[...]]) are non-native everywhere except Quartz and need a plugin or a one-time conversion pass. If the graph/backlinks experience is judged load-bearing for the “Big ol’ Brain” identity, the only way to keep it is to keep Quartz and keep fix-links.js as a documented, deliberate stopgap — which fails the “no post-build hacks” goal but preserves the signature feature at zero migration cost. This is a genuine product trade-off, not a technical one, and is the call to confirm before committing to migration.

Migration-effort estimate (Quartz → VitePress)

Section titled “Migration-effort estimate (Quartz → VitePress)”
Task Effort Notes
Scaffold VitePress in .docs-site/, replace Quartz install/build/deploy scripts M init.sh/build.sh/dev.sh/deploy.sh rewrite; drop fix-links.js + .quartz-version
Point config at docs/ as content root, set cleanUrls, base: /docs/ S site-config
Sidebar: add vitepress-sidebar for folder autogen (or hand-author) S–M autogen plugin keeps it folder-driven
Convert [[wikilinks]] → markdown links (or add a markdown-it wikilink plugin) M one-time codemod across docs/; size depends on wikilink usage
Frontmatter compatibility pass S VitePress frontmatter is permissive
Regressions to accept graph view, automatic backlinks (no replacement)
Update /docs-site skill + docs-site.json schema + tests M tooling churn mirrors current Quartz wiring

Overall: medium. The bulk is tooling-script churn + a one-time wikilink conversion; content largely ports as-is. Risk is low because the current site renders fine today — this is a maintainability upgrade, scheduled, not urgent.

Features that would regress (migrating off Quartz)

Section titled “Features that would regress (migrating off Quartz)”
  • Graph view — none of the alternatives have it.
  • Automatic backlinks — none have it natively.
  • Native [[wikilinks]] / transclusions — plugin or conversion required everywhere.
  • Obsidian-vault authoring ergonomics — Quartz is purpose-built for it.
Section titled “AC-04 — Proof of correct flat-serving link resolution”

The PoC lives at scripts/docs-site/poc/. It does a real VitePress 1.6.4 build of a fixture that mirrors the docs/ Diátaxis taxonomy (category dirs plus a two-level-deep explanation/orchestration/ page, with the same cross-directory ../-links the real docs use), then runs verify-links.js — a neutral checker that resolves every emitted internal link the way Cloudflare serves it (flat, no trailing slash) and confirms it points at a real file. No post-build rewriting is run. quartz-model.js emits the same fixture with Quartz’s folder-relative link math as the failing baseline.

Recorded result (2026-06-27, VitePress 1.6.4, Node 25):

VitePress (recommended): 7 pages, 30 links — PASS, every link resolves, no rewriting.
Quartz model (current): 6 pages, 24 links — FAIL, 19 broken under flat serving, e.g.
href="../../../reference/dev-lifecycle" -> /reference/dev-lifecycle (escaped site root)

The Quartz failures are precisely the bug fix-links.js papers over (links resolve to /reference/... instead of /docs/reference/...). VitePress’s links resolve correctly with zero post-processing, satisfying AC-04. Re-run with npm install && npm run build && npm run verify in the PoC dir.