Deterministic site assembly: generated nav, supplemental content mapping, planning-corpus projections
Status: open/proposed
Summary
Section titled “Summary”- The docs site is a pure build artifact: one
sdlc docs generate sitepass owns every file under<docs_site>/src/content/docs/and the sidebar. A file the pass did not place is either a stale generated page (deleted loudly) or foreign hand-written content (hard error). (§1) - Nothing generated is committed: the Astro scaffold ships at
plugin/site-template/and materializes into<docs_site>/(located bysdlc.yaml’sdocs_sitekey); a consumer commits onlysite.yamlandsupplemental/— Cloudflare rebuilds the rest on every deploy. (§1) - Supplemental hand-written pages live outside the content root in
<docs_site>/supplemental/, mapped in through a declarative manifest (<docs_site>/site.yaml) that places each page or group in the nav — top level, or two levels at most. (§2) - Naming collisions fail the build: a single route registry
records every placement; duplicate routes, supplemental pages inside
generated namespaces, and duplicate nav labels exit
NAMESPACE_CONFLICT. (§2) - The sidebar is generated:
site.yaml’s ordered nav slots expand into a generated module thatastro.config.mjsimports — the plugin-owned config stays a thin shell. (§3) - Every planning entity type projects onto the site: a generic projection (roster + per-entity child pages, the D-0009-reference-entity-and-docs-appendix P6 pattern generalized) renders any type with zero wiring; per-type modules override where the generic shape isn’t right. Glossary and References re-platform as the first two overrides. (§4)
- The Roadmap page becomes generated from milestone entities — static narrative preamble plus Active/Emerging tables, the data-model-page precedent. (§5)
- Site pages render through Eta templates, one per page family, matching the index/glossary/references artifact generators and the report-kind pattern. (§6)
Context
Section titled “Context”D-B4CA-term-entity-and-generated-glossary and
D-0009-reference-entity-and-docs-appendix moved the site’s
generated surface from a side script (site/scripts/regen.mjs) into
the docs service: sdlc docs generate site now writes the reference
pages (skills, entities, ops), the glossary and references rosters
with per-entity child pages, the Data model page, and a generated
sidebar module for the Appendix group. The pattern is proven twice:
entity type → generated roster → generated site pages.
But the site is only mostly generated, and the boundary is implicit:
- Seven hand-written pages (
index.md, fourarchitecture/*pages,roadmap.md,changelog.md) live undeclared inside the generated content root. Nothing distinguishes them from generated output except the absence of a banner; nothing fails if a hand-written page collides with a generated route. - The sidebar in
astro.config.mjsis hand-maintained except for the Appendix splice. Adding a generated page family means editing config by hand. - The planning corpus — decisions, standards, principles, milestones,
tasks, backlog, products, drivers, capabilities — has no site
projection at all. The wiki front door (
docs/index.md) renders all of it; the site renders none of it. site.tsbuilds pages by string concatenation while every other generator in the service renders Eta templates.
T-ETML-decouple-regen-from-task-work-prs records the operational cost of the implicit boundary: regen runs pick up unrelated drift because nothing guarantees the generated surface is in lockstep at all times. M-GJBW’s site-strategy question (“what’s hand-written vs. generated”) is answered here for the mechanics; its taxonomy, audience, and drift-policy questions stay deferred.
Decision
Section titled “Decision”1. The site is a pure build artifact
Section titled “1. The site is a pure build artifact”One sdlc docs generate site pass owns <docs_site>/src/content/docs/
entirely — pages and nav. The site directory is read from sdlc.yaml’s
docs_site key (default site), and every site path — content root,
site.yaml manifest, supplemental/, generated modules — resolves
beneath it, so a consuming project hosts the site wherever it likes.
This repo hosts it at sites/df-docs/ under the monorepo sites/ tree.
Every file under the content root is placed by the pass:
| Source | Placement |
|---|---|
| Plugin surface (skills, entity schemas, op registry) | Rendered reference pages (unchanged) |
Planning entities (docs/planning/<plural>/) | Projected rosters + child pages (§4) |
Supplemental pages (<docs_site>/supplemental/) | Mirrored per the manifest (§2) |
After placement, the pass sweeps the whole content root. A file it did not place is classified by the AUTO-GENERATED banner:
| File state | Classification | Action |
|---|---|---|
| Carries the banner | Stale generated page | Deleted, loudly (existing orphan-sweep semantics, widened to the whole root) |
| No banner | Foreign hand-written content | Hard error — the fix is mv to <docs_site>/supplemental/ plus a manifest entry |
Deleting unbannered files silently would destroy hand-authored work; ignoring them would re-open the implicit boundary this decision closes. Erroring names the file and the fix.
Generation is idempotent (writeIfChanged, byte-stable ordering).
The Astro app ships with the plugin. The generic scaffold —
astro.config.mjs, content.config.ts, routeData.ts, tsconfig.json,
package.json, public/, src/assets/ — lives at
plugin/site-template/, and every real run materializes it into
<docs_site>/ (overwriting; it is plugin-owned) before generating the
content, the nav (src/generated/sidebar.mjs), and the site metadata
(src/generated/site-config.mjs, emitted from site.yaml’s site:
block — title / description / social). Nothing project-specific is baked
into the scaffold; the ops-discovery helper runs plugin-relative
(plugin/lib/services/docs/list-ops.ts). A fresh clone becomes a
complete, buildable Astro app on the first sdlc docs generate site.
Nothing generated is committed. The scaffold, the content root,
src/generated/, node_modules, and dist are gitignored; a
consumer’s <docs_site>/ commits only site.yaml and supplemental/.
Cloudflare regenerates everything from source on every deploy (the root
bun run build:doc-site), which keeps hundreds of generated files out
of every merge. generateSite keys its “does this project have a docs
site” guard on the presence of the manifest (site.yaml), not the
generated content root — so a fresh clone (Cloudflare/CI) creates and
populates the content root rather than skipping. Validation splits by
surface:
| Surface | Gate |
|---|---|
Committed artifacts (docs/index.md, glossary.md, references.md) | sdlc docs generate --check byte-drift gate, wired into quality_checks |
| The site | Built for real (sdlc docs generate site) at the pre-commit boundary (project-check-site-build) and in quality_checks — the build throws the same NAMESPACE_CONFLICT / foreign-page / stale-orphan errors; for an uncommitted artifact there is nothing to byte-diff against |
2. The supplemental content model
Section titled “2. The supplemental content model”Hand-written pages are authored under <docs_site>/supplemental/,
outside the content root, and declared in <docs_site>/site.yaml:
# <docs_site>/site.yaml — consumed by `sdlc docs generate site`site: # site identity → src/generated/site-config.mjs title: sdlc description: Personal SDLC management as plain markdown …landing: file: index.md # supplemental/index.md → content-root index.mdnav: - group: Architecture # supplemental group (level 1) items: # ordered children (level 2) - file: architecture/overview.md - file: architecture/deterministic-first.md - generated: data-model # generated page slotted inside a supplemental group - file: architecture/github-ref-leases.md - generated: reference # expands to Ops / Entities / Skills groups - generated: planning # expands to per-type planning projections (§4) - generated: roadmap # single generated page (§5) - subsite: intersect # nav link into a nested child site - file: changelog.md # supplemental top-level page - generated: appendix # expands to Glossary / Referencessubsites: # nested Astro sites mounted under /<slug>/ - slug: intersect dir: packages/ts/intersect/docs-site label: Intersect| Rule | Enforcement |
|---|---|
| Manifest shape | Zod schema; unknown keys, missing files, and dangling generated: keys are errors |
| Nav depth | Two levels: top-level entries; group children. Deeper nesting is a schema error (“for now” — the cap is policy, not architecture) |
| Routes | A supplemental page’s route derives from its path under <docs_site>/supplemental/; the mirror step copies it into the content root |
| Mixed groups | A supplemental group may slot generated pages (generated: data-model above) so author-controlled ordering and generated content compose |
| Subsites | A subsite: slot must reference a declared subsites: entry; duplicate slugs, a missing dir, or a dir without astro.config.mjs are errors. sdlc docs build-subsites builds each child with --base /<slug>/ into the parent’s dist/<slug>/ |
| Undeclared supplemental file | A file under <docs_site>/supplemental/ not reachable from the manifest is an error — no silent dead content |
Collision semantics. The pass maintains one route registry. Every placement — generated or supplemental — registers its content-root-relative path:
| Collision | Outcome |
|---|---|
| Two placements claim one route | Error naming both claimants |
Supplemental route inside a generated namespace (reference/, planning/, glossary/, references/) | Error, even when no current file collides — generated namespaces are reserved. Single-file generated pages (roadmap.md, architecture/data-model.md) are covered by duplicate-route detection instead |
| Duplicate nav label at the same level | Error |
All collision failures exit NAMESPACE_CONFLICT (exit 7, the
registry’s existing taxonomy slot).
3. The nav is generated
Section titled “3. The nav is generated”The sidebar is a generated module —
<docs_site>/src/generated/sidebar.mjs — assembled from site.yaml’s
slot order. The scaffold’s astro.config.mjs imports it, and site
identity (title, description, social) arrives the same way via the
generated site-config.mjs; the config itself stays logic-free.
| Slot | Expansion |
|---|---|
generated: reference | Ops / Entities / Skills autogenerated groups (unchanged content) |
generated: planning | One collapsed child group per projected entity type (§4), roster-anchor children per the P6 pattern |
generated: appendix | Glossary / References groups with anchor children (existing behavior, re-platformed) |
generated: roadmap, generated: data-model | Single nav entries |
subsite: <slug> | Nav link to the child site mounted at /<slug>/ |
file: / group: | Supplemental links and groups in declared order |
routeData.ts (shipped in the scaffold) injects every roster page’s
“On this page” entries from the same generated module — planning
rosters included.
4. Planning-corpus projections
Section titled “4. Planning-corpus projections”Every entity type under docs/planning/<plural>/ projects onto the
site as a roster page plus per-entity child pages — the
D-0009-reference-entity-and-docs-appendix P6 shape, generalized:
| Aspect | Generic projection (default) |
|---|---|
| Roster route | /planning/<plural>/ |
| Roster columns | Id (linked to child page, <a id> row anchor) / Title / Summary (^summary inlined; the site renders no Obsidian transclusions) |
| Bucketing | bucketFor reused: Active table; Emerging / Obsoleted follow-up with explicit Status column; unknown statuses routed to follow-up, never dropped |
| Child route | /planning/<plural>/<slug>/ rendered from the full entity body |
| Wikilinks | Resolve to sibling child routes where the target has a page; otherwise code-formatted (P6 semantics) |
| New types | Appear by construction — a new entity package ships on the site with zero wiring |
Types whose roster wants domain columns register an override — a
site_projection.ts in the entity package (entity-specific code
lives with the entity; the report-kind precedent), listed in an
explicit registry in the docs service (the kinds.ts precedent):
| Type | Override |
|---|---|
| term | Term / Definition / Source columns; keeps /glossary/ routes (link stability; stays in Appendix, not Planning) |
| reference | Link (host-labelled) / Cited-by columns; keeps /references/ routes |
| task | Adds Status / Impact / Complexity columns; depends_on rendered as links on child pages |
| milestone | Adds Version / Target date; feeds the Roadmap page (§5) |
Glossary and References re-platforming onto the framework is the proof the framework subsumes the existing specials — no behavior or route changes.
High-volume types (task, backlog) get child pages like every other type; static builds tolerate hundreds of routes. Build-time budget is an open question with a recorded trigger, not a blocker.
5. The Roadmap page becomes generated
Section titled “5. The Roadmap page becomes generated”roadmap.md is entity-shaped — it narrates milestones — so it belongs
in the generated set:
| Block | Source |
|---|---|
| Direction narrative | Static preamble owned by the generator (the data-model-page precedent: hand-authored knowledge, reviewed as code) |
| Active / Emerging milestones | Milestone entities: Id / Title / Version / Target date / Summary (^summary inlined) |
| Shipped history | closed/* milestones with completion notes |
index.md (landing), architecture/* prose, and changelog.md stay
hand-written — they encode judgment no entity carries — and live under
<docs_site>/supplemental/ as manifest entries.
6. Code structure, templating, and CLI
Section titled “6. Code structure, templating, and CLI”| Concern | Placement |
|---|---|
| Orchestration | plugin/lib/services/docs/site.ts stays the entry point, reduced to sequencing |
| Manifest | plugin/lib/services/docs/site/manifest.ts — Zod schema, loader, validation |
| Route registry + collisions | plugin/lib/services/docs/site/routes.ts |
| Nav assembly | plugin/lib/services/docs/site/nav.ts — slot expansion, sidebar.mjs emission |
| Supplemental mirror | plugin/lib/services/docs/site/supplemental.ts |
| Projection framework | plugin/lib/services/docs/site/projections.ts — generic projection + override registry |
| Per-type overrides | plugin/lib/model/entities/<type>/site_projection.ts |
| Roadmap data | plugin/lib/services/docs/site/roadmap.ts |
| Subsites | plugin/lib/services/docs/site/subsites.ts — validation + the sdlc docs build-subsites build |
| Page rendering | One Eta template per page family (site_roster_template.eta, site_child_page_template.eta, site_roadmap_template.eta, …) beside the existing artifact templates, rendered via site_render.ts / projections_render.ts |
Eta everywhere. Page families render through Eta templates from
validated data structures — the same schema-then-template discipline
as the report kinds and the index/glossary/references artifacts.
String building survives only where a template is structurally
impossible (the sidebar.mjs module emission).
CLI surface. sdlc docs generate site is one pass, everything —
and doubles as the site’s validation
(§1).
sdlc docs generate --check regenerates against a throwaway target
and exits non-zero on drift; bare, it covers the committed artifacts
(name site explicitly to check local site content).
sdlc docs build-subsites builds the declared child sites into the
parent’s dist/.
T-ETML-decouple-regen-from-task-work-prs’s coupling concern
dissolves: the committed artifacts are drift-gated and the site is
uncommitted, so a per-task PR’s regen diff is exactly its own change.
Options considered
Section titled “Options considered”| Choice | Options | Taken | Why |
|---|---|---|---|
| Manifest location | <docs_site>/site.yaml, key in sdlc.yaml, convention-only (directory = nav) | <docs_site>/site.yaml | Content-shaped config co-locates with the site; sdlc.yaml stays process config (it carries only the docs_site location). Convention can’t express ordering, mixed groups, or a landing slot, and can’t fail meaningfully |
| Supplemental home | Outside the content root, in-place + manifest whitelist | Outside | ”Every file is generator-placed” is only enforceable when nothing legitimate lives in the root. In-place keeps the implicit boundary this decision exists to close |
| Foreign files in content root | Hard error, silent delete, ignore | Banner-split: bannered = delete, unbannered = error | Bannered files are provably machine-written — deleting is the existing orphan sweep. Unbannered files are someone’s work; deleting destroys it, ignoring re-opens the hole |
| Projection registration | Generic default + explicit override registry, discovery walk, fully explicit | Generic + overrides | Zero wiring for new types (the D-0009 virtue, extended); overrides stay type-safe and greppable in one registry file (kinds.ts precedent). A discovery walk buys nothing over the generic default |
| Planning routes | /planning/<plural>/, top-level /<plural>/ | /planning/… | Shrinks the reserved-namespace surface supplemental pages must avoid; groups the corpus semantically |
| Roadmap | Generated from milestones, stay supplemental | Generated | The content is entity-shaped; the data-model page already proves the static-narrative-plus-generated-tables split |
| Nav emission | Generated sidebar.mjs module, config-side assembly from a data file | Generated module | The P6 appendix_sidebar.mjs precedent works; keeps astro.config.mjs logic-free |
| Page rendering | Eta per page family, keep string builders | Eta | Consistency with every other generator in the service; schema-then-template is the house discipline (S-0004-sdlc-cli-llm-head-deterministic-tail) |
Consequences
Section titled “Consequences”astro.config.mjsis a plugin-owned thin shell; nav and site-identity edits live insite.yamland survive regeneration by construction.- The whole content root is machine-written and uncommitted. Hand-edits anywhere under it are overwritten on the next pass; the build is the drift gate.
- Every planning entity type — current and future — ships on the site by construction. The “third catalog type is near-mechanical” promise of D-0009-reference-entity-and-docs-appendix becomes zero-effort.
/dev-update-docssemantic verification shrinks to<docs_site>/supplemental/— the only prose left that can drift.- T-ETML-decouple-regen-from-task-work-prs closes:
--checkin the quality gates guards the committed artifacts and the uncommitted site carries no PR diff at all, so per-task regen diffs are self-caused. - M-GJBW’s site-strategy bullet is answered mechanically; taxonomy, audiences, and drift policy stay deferred there.
- Glossary/References pages re-platform onto the projection framework
with no route changes; the Appendix-only
appendix_sidebar.mjsmodule retires into the generated sidebar. - The site gains ~10 roster pages and a few hundred child routes; build time and search-index size get a recorded budget (open question).
Implementation
Section titled “Implementation”All eight phases — substrate (manifest, routes, ownership sweep), the generated nav, the supplemental migration, the projection framework, corpus rollout, high-volume types, the generated roadmap, and hardening (drift gates, collision/determinism test suites, skill updates) — landed as tickets under M-0005; the root design task was T-7D9E-design-deterministic-site. The build-time budget measurement remains open (see Open questions).
Out of scope
Section titled “Out of scope”- Hosting configuration — Cloudflare builds the site via the root
bun run build:doc-site; the Pages project setup itself sits outside this decision. - The wiki-side artifacts (
docs/index.md,glossary.md,references.md) — unchanged contracts. - M-GJBW’s taxonomy, audience model, and drift policy.
- Search tuning beyond Starlight defaults.
Open questions
Section titled “Open questions”- Build budget. At what page count does the static build or the
Pagefind index become a problem? No measurement is recorded yet; the
mitigation — a per-type
childPages: falseroster-only flag — exists in the projection shape, unused so far. - Supplemental frontmatter pass-through. May supplemental pages carry arbitrary Starlight frontmatter (badges, table of contents flags)? Frontmatter passes through verbatim; revisit if it breaks determinism guarantees.
- Slug overrides. Supplemental routes derive from file paths; no override syntax until a real page needs one.
References
Section titled “References”- D-0009-reference-entity-and-docs-appendix — the P6 child-page + anchor-sidebar pattern this decision generalizes.
- D-B4CA-term-entity-and-generated-glossary — the original entity → roster → site-page pattern.
- D-0007-deterministic-op-substrate — the op registry the generator lives in.
- D-H7FS-op-substrate-surface — output contract and exit-code
taxonomy (
NAMESPACE_CONFLICT). - S-0004-sdlc-cli-llm-head-deterministic-tail — the head/tail split the generator follows.
- S-0005-entity-definition-contract — the entity-package contract
site_projection.tsjoins. - RF-0001-astro-starlight — sidebar, autogenerate, route middleware behavior.
- RF-0003-eta — the template engine.