ADR-0059: Public documentation rendering pipeline — self-hosted rustdoc and mdBook onto the Cloudflare site
| State | Accepted |
| Architectural Significance | MEDIUM |
| Domain | Developer Experience |
| Document version | 1.2 |
Reference
Builds on ADR-0052 (documentation site on Cloudflare Pages at
docs.thalweg.dev, apex reserved, build command none) and inherits its
constraints wholesale. Interacts with ADR-0047 (independent per-crate
publishing) — the crates that most need reference docs are the ones published
standalone. Governed by the hard rule in AGENTS.md (nothing company-sensitive on
the world-readable surface), enforced by
scripts/check-public-surface.sh.
Context
The repository is private; only the Cloudflare-served site under
docs/site/ is public (ADR-0052). The prose documentation — ADRs, blueprints,
operations notes, the testing standard — lives as markdown under docs/, and API
reference for the crates does not exist anywhere yet.
Two gaps follow from that:
- No published API reference. The crates are
publish = falsetoday (they are scaffolds), so nothing appears ondocs.rs, and even once they publish, the four internal crates (twg-cli, the two*-arrowcompositions,twg-e2e) never will. There is no renderedcargo docfor anyone — contributor or reader — without checking out the repository and building it. - The prose docs are only readable as raw markdown. They render on GitHub, but
the repository is private, so a reader arriving from the public site cannot reach
them. Linking to GitHub blobs from
docs.thalweg.devwould send public readers to a 404-behind-auth wall.
The binding constraint from ADR-0052 shapes every option: the Cloudflare build
command is NONE. Cloudflare serves docs/site/ verbatim on push; it does not
run mdbook, cargo, or any generator. So anything rendered has to be rendered
before the push and committed as static output, or it does not exist on the
site. A second, build-running deploy path was explicitly rejected in ADR-0052
because it would race Cloudflare’s app.
A further constraint: the site is world-readable and the repository is not, so
whatever is rendered onto it is subject to the same secret-scanning gate as the
hand-written pages. Generated output must pass check-public-surface.sh.
Options considered + consequences
API reference: how to publish cargo doc
Dimensions: works for unpublished crates, no company-sensitive leakage, no second deploy path, cost to keep current.
Option A1: Self-host rustdoc — build cargo doc locally, commit into docs/site/api/
- Pros: works regardless of publish status, so it covers the four internal
crates and today’s all-unpublished workspace alike. No dependency on crates.io or
docs.rs. Served from the same origin as the rest of the site, behind the same (optional) Access policy, scanned by the same gate. Cloudflare stays build-none. - Cons: the rendered tree is committed, so it is large and it can drift from the source if not regenerated. rustdoc’s search index is a minified base64 blob that trips the AWS-key heuristic in the public-surface scan (a false positive to be handled narrowly, not by blinding the gate).
Option A2: Rely on docs.rs
- Rejected.
docs.rsonly builds published crates, and only builds them from crates.io. The workspace is entirely unpublished today, and the four internal crates arepublish = falseforever, sodocs.rscan never carry them. It also puts the reference on a different origin from the rest of the docs, and outside the private-repo boundary and the public-surface gate.
Option A3: A CI job builds docs to a separate api. subdomain
- Rejected. This reintroduces exactly the second deploy path ADR-0052 refused: a GitHub Actions job publishing to its own target, racing Cloudflare and splitting the origin. It also runs the secret-bearing build in CI rather than behind the same gate as everything else. The whole point of build-none is that there is one publish mechanism.
Prose rendering: how to turn the markdown into a browsable site
Dimensions: static output (build-none compatible), auto-nav from the tree, handles the ADR/blueprint YAML frontmatter, Rust-toolchain-native.
Option B1: mdBook, rendered locally into docs/site/reference/
- Pros: produces a static tree that Cloudflare serves as-is. It is the
Rust-native documentation renderer, so it is one
cargo installfor a Rust contributor and shares the toolchain already required. Navigation is a generatedSUMMARY.md, which a committed generator can rebuild from the docs tree so a dropped-in file appears without hand-editing nav. Intra-doc.mdlinks are rewritten to.htmlautomatically. - Cons: the rendered tree is committed (same drift risk as rustdoc, same
mitigation — regenerate on commit). YAML frontmatter in the ADR/blueprint files
is not markdown and renders as raw text unless stripped or preprocessed. mdBook’s
own
searchindex.jsis a minified blob, the same false-positive class as rustdoc’s.
Option B2: MkDocs (or another Python/JS static-site generator)
- Rejected. It adds a non-Rust toolchain (Python + pip) to a Rust workspace purely for docs, which every contributor would then need. mdBook gives the same static-output property while staying inside the toolchain the project already mandates.
Option B3: Link to the raw markdown on GitHub
- Rejected. The repository is private, so public readers arriving from the site
or a future crates.io listing hit an auth wall. This is the exact coupling
ADR-0052 and
DOCS-DEPLOY.mdwarn against.
Freshness: when the rendered output is produced
Option C1: Cloudflare runs the build
- Rejected by ADR-0052. The build command is
NONEand a second deploy path races the first. Not available.
Option C2: A pre-commit hook regenerates incrementally and stages the output
- Pros: the rendered output lands in the same commit as the change that
caused it — the discipline ADR-0052 already established for the graphify map and
AGENTS.mdalready requires for docs. Incremental (only affected crates rebuild their rustdoc; the book rebuilds only whendocs/**markdown changes) keeps commit latency bounded. Fail-soft, so a contributor withoutmdbookor the toolchain is warned, not blocked. - Cons: contributors need
mdbookand the Rust toolchain installed to regenerate locally; without them the hook no-ops and the output can go stale until someone with the tooling commits. Accepted: the tooling is already needed to work on the crates, and the fail-soft design matches the existing graphify hook.
Option C3: A periodic/manual regeneration
- Rejected. Exactly the “I’ll update the docs after” failure mode
AGENTS.mdcalls out — it is how the site once carried a decision count of 44 against an actual 57. Same-commit regeneration is the established discipline here.
Decision
Self-host both reference surfaces, rendered locally and committed, served by the existing build-none Cloudflare project.
- API reference (Option A1).
cargo doc --workspace --no-depsis rendered intodocs/site/api/, served atdocs.thalweg.dev/api/and per-crate at/api/<crate>/.scripts/gen-api-docs.shregenerates the docs for a given set of affected crates and syncs them into the tree.- The rustdoc SOURCE VIEW is stripped, never published. rustdoc’s default
output includes a full syntax-highlighted source view — every crate rendered
into
api/src/**/*.rs.html, indexed byapi/src-files.js, and reachable from a per-itemSourcelink on every page. The repository is private, so publishing that would publish the entire private codebase onto the world-readable site — forbidden by the hard rule inAGENTS.md. rustdoc has no stable flag to suppress source generation, so the pipeline generates then strips:scripts/strip-rustdoc-src.shdeletesapi/src/andapi/src-files.jsand removes the relativeSourceanchors from every rendered page (upstreamhttps://doc.rust-lang.org/…/src/…links, which reference Rust’s own source rather than ours, are preserved).gen-api-docs.shinvokes it after every render — whole-workspace and per-crate alike — so a future docs change can never re-publish the source view. Only the signatures and doc comments (the per-crateapi/<crate>/pages) reach the site.
- The rustdoc SOURCE VIEW is stripped, never published. rustdoc’s default
output includes a full syntax-highlighted source view — every crate rendered
into
- Prose reference (Option B1). An mdBook under
docs/book/renders the ADRs, blueprints, operations notes, testing docs andPUBLISHING.mdintodocs/site/reference/, served atdocs.thalweg.dev/reference/. Itsbuild-dirtargets../site/referencesomdbook build docs/bookwrites straight into the publish root. TheSUMMARY.mdnavigation is generated from the docs tree byscripts/gen-reference-book.sh, which also strips the YAML frontmatter as a pre-render step so it does not appear as raw text. - Freshness (Option C2).
.githooks/pre-commitis extended, alongside the existing graphify step, to regenerate incrementally on commit: changedcrates/<name>/**triggergen-api-docs.shfor just those crates; changeddocs/**markdown triggers aSUMMARYregeneration andmdbook build. Every step is fail-soft — a missingcargo/mdbookor any error warns to stderr and the commit proceeds; the hook always exits 0. - The bespoke landing page stays the apex.
docs/site/index.htmlremains hand-authored; it gains plain links into/reference/and per-crate/api/pages, without a new numbered section (which would renumber the gated nav). - One theme and one way back across all three surfaces. The reference and the
crate docs shipped with their own generator defaults (mdBook’s
rust/ayu, rustdoc’s light/dark), and neither offered a route back to the landing page — a reader arriving at a crate page was stranded. Both are now re-skinned to the landing palette (paper / iron / verdigris) and carry the same persistent brand bar linking back to the main docs:- Reference (mdBook).
default-theme/preferred-dark-themeare pinned tolight, re-skinned bydocs/book/custom/thalweg.css, and a brand bar is injected bydocs/book/custom/thalweg.js— both loaded viaadditional-css/additional-jsand kept outsidesrc/so theSUMMARY/src/regeneration never clobbers them. - Crate docs (rustdoc).
docs/rustdoc/head.html(palette + layout) anddocs/rustdoc/before.html(the brand bar) are injected on every page viaRUSTDOCFLAGS=--html-in-header/--html-before-content, set inscripts/gen-api-docs.sh, so the theme is re-applied on every render rather than patched in after the fact.
- Reference (mdBook).
/api/has a crate overview instead of a 404.cargo doc --workspacerenders one tree per crate but no root index, sodocs.thalweg.dev/api/404’d and per-crate docs were reachable only by guessing a URL.scripts/gen-api-index.shwritesdocs/site/api/index.html— a landing page in the main theme listing and linking every rendered crate. The list is derived from the filesystem (each rustdoc crate root underapi/), so it cannot drift from what was rendered, and it runs after the source-view strip, so it only ever links crate index pages, never source.gen-api-docs.shinvokes it after every render.- The public-surface gate is rescoped minimally.
check-public-surface.shexcludes only the minified search-index artifacts (rustdoc’ssearch-index*.js/search.index/*.js, mdBook’ssearchindex.js/searchindex.json) — not the/apior/referencetrees — so the rendered HTML (real ADR and rustdoc prose) is still scanned for secrets.
The crates stay publish = false. Self-hosting is precisely what lets the
reference exist without publishing, so nothing here flips a crate to published.
Consequences
- Generated output is committed, and must be kept in step. The pre-commit hook
is the mechanism; the fallback is
scripts/gen-api-docs.shandscripts/gen-reference-book.shrun by hand. Regeneration is deterministic, so a stale tree is a diff, not a mystery. - Contributors need
mdbookand the Rust toolchain to regenerate locally. This is documented inPUBLISHING.md,operations/DOCS-DEPLOY.mdandCONTRIBUTING.md. Absent them the hook fail-soft no-ops, matching the graphify hook’s contract. - The file count under
docs/site/grows substantially — rustdoc emits a few thousand files for a 44-crate workspace. This stays well under Cloudflare’s 20,000-files-per-deploy limit, but it is now a number to watch as crates gain real surface; the file count is reported in the pull request that introduces the pipeline. - The public-surface exclusion is narrow by construction. It names the search-index files specifically; the rendered prose is still scanned. If a future generator emits a new minified blob that trips the gate, the fix is to name that file too, never to exclude a whole tree.
- rustdoc
--no-depsdocuments the workspace crates only, not dependencies, so the tree does not balloon with third-party docs and does not leak dependency internals onto the public surface. - The API surface is signatures and doc comments, not source. Stripping the source view (above) means the public reference shows what each crate exposes and how it is documented, but never the implementation — the appropriate boundary for a private codebase’s public reference. The strip is a script step, not a manual deletion, so it holds across every regeneration.
- Cloudflare stays build-none. No second deploy path is introduced; the site is still served verbatim, so ADR-0052’s ordering argument (gates protect the merge, branch protection enforces sequencing) carries over unchanged.
Advice Received
| Date | Advisor | Decision version | Advice |
|---|---|---|---|
| Pending. |
Document version history
| Version | Date | Notes |
|---|---|---|
| 1.0 | 2026-08-03 | Pipeline decided and implemented: self-hosted rustdoc into docs/site/api/, mdBook into docs/site/reference/, incremental fail-soft regeneration in .githooks/pre-commit, narrow search-index exclusion in the public-surface gate. |
| 1.1 | 2026-08-04 | Strip rustdoc’s source view before publishing: scripts/strip-rustdoc-src.sh removes api/src/, api/src-files.js and the relative Source links after every render, wired into gen-api-docs.sh (whole-workspace and per-crate), so the private codebase source is never published. The API surface stays signatures + doc comments only. |
| 1.2 | 2026-08-04 | Unify navigation and theme across the three surfaces. Re-skin the reference (mdBook) and crate docs (rustdoc) to the landing palette and inject a persistent brand bar linking back to the main docs (docs/book/custom/thalweg.{css,js}, docs/rustdoc/{head,before}.html, RUSTDOCFLAGS in gen-api-docs.sh); add a crate overview at api/index.html (via scripts/gen-api-index.sh, filesystem-derived, run after the source strip) so /api/ no longer 404s; and link the drafted ADRs (0001, 0058) from the ADR index so the prose matches the reference nav. The source-view strip is unchanged and still holds after a clean regen. |