Skip to content

Deterministic site assembly: generated nav, supplemental content mapping, planning-corpus projections

Status: open/proposed

  • The docs site is a pure build artifact: one sdlc docs generate site pass 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 by sdlc.yaml’s docs_site key); a consumer commits only site.yaml and supplemental/ — 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 that astro.config.mjs imports — 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)

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, four architecture/* 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.mjs is 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.ts builds 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.

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:

SourcePlacement
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 stateClassificationAction
Carries the bannerStale generated pageDeleted, loudly (existing orphan-sweep semantics, widened to the whole root)
No bannerForeign hand-written contentHard 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:

SurfaceGate
Committed artifacts (docs/index.md, glossary.md, references.md)sdlc docs generate --check byte-drift gate, wired into quality_checks
The siteBuilt 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

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.md
nav:
- 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 / References
subsites: # nested Astro sites mounted under /<slug>/
- slug: intersect
dir: packages/ts/intersect/docs-site
label: Intersect
RuleEnforcement
Manifest shapeZod schema; unknown keys, missing files, and dangling generated: keys are errors
Nav depthTwo levels: top-level entries; group children. Deeper nesting is a schema error (“for now” — the cap is policy, not architecture)
RoutesA supplemental page’s route derives from its path under <docs_site>/supplemental/; the mirror step copies it into the content root
Mixed groupsA supplemental group may slot generated pages (generated: data-model above) so author-controlled ordering and generated content compose
SubsitesA 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 fileA 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:

CollisionOutcome
Two placements claim one routeError 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 levelError

All collision failures exit NAMESPACE_CONFLICT (exit 7, the registry’s existing taxonomy slot).

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.

SlotExpansion
generated: referenceOps / Entities / Skills autogenerated groups (unchanged content)
generated: planningOne collapsed child group per projected entity type (§4), roster-anchor children per the P6 pattern
generated: appendixGlossary / References groups with anchor children (existing behavior, re-platformed)
generated: roadmap, generated: data-modelSingle 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.

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:

AspectGeneric projection (default)
Roster route/planning/<plural>/
Roster columnsId (linked to child page, <a id> row anchor) / Title / Summary (^summary inlined; the site renders no Obsidian transclusions)
BucketingbucketFor 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
WikilinksResolve to sibling child routes where the target has a page; otherwise code-formatted (P6 semantics)
New typesAppear 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):

TypeOverride
termTerm / Definition / Source columns; keeps /glossary/ routes (link stability; stays in Appendix, not Planning)
referenceLink (host-labelled) / Cited-by columns; keeps /references/ routes
taskAdds Status / Impact / Complexity columns; depends_on rendered as links on child pages
milestoneAdds 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.

roadmap.md is entity-shaped — it narrates milestones — so it belongs in the generated set:

BlockSource
Direction narrativeStatic preamble owned by the generator (the data-model-page precedent: hand-authored knowledge, reviewed as code)
Active / Emerging milestonesMilestone entities: Id / Title / Version / Target date / Summary (^summary inlined)
Shipped historyclosed/* 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.

ConcernPlacement
Orchestrationplugin/lib/services/docs/site.ts stays the entry point, reduced to sequencing
Manifestplugin/lib/services/docs/site/manifest.ts — Zod schema, loader, validation
Route registry + collisionsplugin/lib/services/docs/site/routes.ts
Nav assemblyplugin/lib/services/docs/site/nav.ts — slot expansion, sidebar.mjs emission
Supplemental mirrorplugin/lib/services/docs/site/supplemental.ts
Projection frameworkplugin/lib/services/docs/site/projections.ts — generic projection + override registry
Per-type overridesplugin/lib/model/entities/<type>/site_projection.ts
Roadmap dataplugin/lib/services/docs/site/roadmap.ts
Subsitesplugin/lib/services/docs/site/subsites.ts — validation + the sdlc docs build-subsites build
Page renderingOne 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.

ChoiceOptionsTakenWhy
Manifest location<docs_site>/site.yaml, key in sdlc.yaml, convention-only (directory = nav)<docs_site>/site.yamlContent-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 homeOutside the content root, in-place + manifest whitelistOutside”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 rootHard error, silent delete, ignoreBanner-split: bannered = delete, unbannered = errorBannered 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 registrationGeneric default + explicit override registry, discovery walk, fully explicitGeneric + overridesZero 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
RoadmapGenerated from milestones, stay supplementalGeneratedThe content is entity-shaped; the data-model page already proves the static-narrative-plus-generated-tables split
Nav emissionGenerated sidebar.mjs module, config-side assembly from a data fileGenerated moduleThe P6 appendix_sidebar.mjs precedent works; keeps astro.config.mjs logic-free
Page renderingEta per page family, keep string buildersEtaConsistency with every other generator in the service; schema-then-template is the house discipline (S-0004-sdlc-cli-llm-head-deterministic-tail)
  • astro.config.mjs is a plugin-owned thin shell; nav and site-identity edits live in site.yaml and 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-docs semantic verification shrinks to <docs_site>/supplemental/ — the only prose left that can drift.
  • T-ETML-decouple-regen-from-task-work-prs closes: --check in 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.mjs module 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).

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).

  • 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.
  • 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: false roster-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.

← Back to Decisions