ADR-0004: Use one permissive Starlight schema plus a separate structural linter
Read the transcript
1. The mismatch between seven content types and one collection
Host: So today we’re digging into ADR-0004, which is basically about a headache anyone using Starlight for a big docs site eventually hits. You’ve got seven totally different kinds of documents in this project — ADRs, Learn modules, Build, Architecture pages, Interviews, Reference, Cheat Sheets — and each one wants its own frontmatter and its own required sections. An ADR needs an adrNumber and adrStatus and a Status through References structure, while a Learn module needs a moduleNumber, a difficulty rating, and fifteen separate required sections. Totally different shapes.
Guest: Right, and the wrinkle is that Starlight only gives you one schema per content collection, and this whole site runs on a single docs collection. There’s no built-in way to say ‘this subfolder gets these fields, that subfolder gets those.’ So you’re stuck: either force every single page to satisfy the union of all seven types’ requirements, which means meaningless placeholder fields on pages that don’t need them, or you split into multiple collections and lose the sidebar and pagination behavior Starlight gives you for free. Neither of those is a real answer, which is exactly why this ADR exists.
2. Permissive schema at build time, strict linter in CI
Host: So how do you actually resolve that fork without giving up on either side? What did the ADR land on?
Guest: The trick is splitting where strictness lives. The Starlight schema in content.config.ts stays permissive — it’s the base schema plus every section-specific field, but everything’s optional or defaulted at the Zod level. So the build only rejects frontmatter that’s actually broken, like a wrong type or an invalid enum, never a page for skipping a field that doesn’t apply to it.
Host: And the real contract, the one that says an ADR needs a decision section and a module needs certain fields, that lives somewhere else entirely?
Guest: Exactly, it’s written once in packages/shared/src/schemas.ts as fully-required Zod schemas, and those same schemas back that package’s own tests. Then scripts/lint-content-structure.ts runs in CI, knows the required sections per directory — learn modules, architecture systems, ADR decisions — and checks real content against that stricter contract, completely separate from the build.
3. Living with two systems and duplicated schemas
Host: So walk me through what you’re actually accepting by living with two systems. I get that overview pages like learn/index or adr/index can skip fields that only matter for the numbered pages underneath — that seems like a clean win.
Guest: That’s exactly the tradeoff, which is why pnpm ci always runs both — build and lint:content — so nobody gets to rely on green build alone. The upside is the canonical shape of each content type lives in exactly one documented place, packages/shared/src/schemas.ts, that a Node script or even an editor integration could import later. And yes, that means two schema definitions, the Astro extend schema and the shared per-type ones, overlap and have to be kept in sync by hand — but that’s deliberate, because sharing one Zod instance across Astro’s content layer and plain Node tooling would couple both to whatever zod version happens to be installed where, and silent instanceof mismatches across duplicate zod installs is a genuinely worse failure mode than a small hand-maintained duplication you can actually see in a diff. That’s the whole ADR in one sentence, really: split the strictness, document the shape once, and accept a bit of manual sync as the price of not coupling two tools to one shifting dependency.
Generated from this page by Claude Sonnet 5 on , spoken by Kokoro-82M running locally. Two synthetic voices, not a recorded conversation. Every claim is drawn from this page — where it differs from the text above, the text is correct.
Status
Section titled “Status”Accepted. Implemented in apps/handbook/src/content.config.ts and
scripts/lint-content-structure.ts.
Context
Section titled “Context”Learn, Build, Architecture, Interview, Reference, ADR, and Cheat Sheets each require a different
set of frontmatter fields (an ADR needs adrNumber and adrStatus; a Learn module needs
moduleNumber and difficulty) and a different set of required body sections (an ADR needs
Status/Context/Problem/Options/Decision/Consequences/References; a Learn module needs fifteen
different sections). Starlight, as configured, has one docs content collection covering every
section — Starlight’s content-collection architecture doesn’t support multiple schemas for
subdirectories of a single collection.
Problem
Section titled “Problem”How do we get per-section frontmatter validation and per-section required-body-sections
enforcement without either (a) forcing every page in every section to satisfy the union of every
section’s required fields, or (b) running multiple Starlight docs collections, which Starlight
doesn’t support cleanly for a single sidebar/site.
Options
Section titled “Options”- One Starlight collection, one strict schema with every field required. Every page — including
a section’s own
index.mdxoverview — would need to satisfy fields that don’t apply to it (an overview page isn’t a numbered ADR or a numbered Learn module), forcing meaningless placeholder values into frontmatter just to pass validation. - Multiple
docs-like collections, one per section. Starlight is built around a singledocscollection owning sidebar generation,editLink, andlastUpdated; splitting it apart means reimplementing sidebar/pagination logic Starlight already provides for free. - One Starlight collection with every section-specific field declared optional, combined with a
separate content-structure linter (
scripts/lint-content-structure.ts) that walks each section’s directory and enforces the fields and body sections that section actually requires.
Decision
Section titled “Decision”apps/handbook/src/content.config.ts defines one docs collection whose schema is Starlight’s
base schema extended with every section-specific field, all declared optional (or defaulted) at the
Zod level — so the build only fails on frontmatter that’s outright malformed (wrong type, invalid
enum value), not on a page choosing not to set fields that don’t apply to it. The stricter,
per-section contract — which fields and which body sections a type of page must have — is
expressed once in packages/shared/src/schemas.ts (as fully-required Zod schemas, used by that
package’s own tests) and enforced against real content by
scripts/lint-content-structure.ts in CI, which knows the required sections list per directory
(learn/modules/*, architecture/systems/*, adr/decisions/*, …).
Consequences
Section titled “Consequences”- Section overview pages (
learn/index.mdx,adr/index.mdx, …) can omit fields that only apply to the numbered pages beneath them, without weakening validation for those numbered pages. - The “canonical shape” of each content type is documented in exactly one place
(
packages/shared/src/schemas.ts) that both a Node script and, in principle, an editor integration could import — rather than being implicit in whatevercontent.config.tshappens to accept. - This is two systems instead of one: a page can pass Starlight’s build-time schema check and still
fail
pnpm lint:contentfor missing a required section. That’s the intended division of labor (type/shape validation vs. structural completeness), but it does mean “the build succeeded” is not synonymous with “the content is complete” —pnpm ciruns both for that reason. - Two schema definitions (the Astro-side extend schema and
packages/shared’s per-type schemas) describe overlapping fields and must be kept in sync by hand. This is deliberate: sharing a single Zod schema instance across Astro’s content layer and plain Node tooling would couple both to whichever zod version happens to be installed where, which has historically been a worse failure mode (silent instanceof mismatches across duplicate zod installs) than a small amount of duplication reviewed in a diff.
References
Section titled “References”- Astro content collections
- Starlight’s
docsSchema, which this ADR’s schema extends rather than replaces.