Rule to Assure Collection Schema is Flexible
Don't: write a content collection schema that requires a field, rejects an unknown key, or demands an exact type. Every one of those turns an authoring slip into a build failure.
Do: make every field optional, add .passthrough(), and coerce rather than
validate. When a value fails, drop the property and keep the document.
This is layer 2 of two. Layer 1 — tolerating malformed YAML during the parse itself — is YAML Frontmatter Parsing Must Be Lenient. Both must hold; a lenient parser feeding a strict schema still fails the build.
Why
Our frontmatter is loosely adherent by design, not by accident. Content is hand-authored, pasted out of Obsidian, and written across years during which the conventions themselves changed. The spec that describes ideal frontmatter opens by saying so: "In practice, frontmatter is scattered… This document describes the aspirational baseline for new files — not a validator's checklist."
A schema that enforces the aspiration against the reality just breaks the site. Concretely, in this corpus you will encounter, all legitimately:
at_semantic_versionandsemantic_version— permanently-accepted aliases for the same property, ~4,140 files to ~330ledeanddescription— both accepteddate_authored_final_draft:present but deliberately empty — the empty key is the signal that finality is being trackeddocuments with no dates at all, and documents whose only dates are editorial
keys no current site reads, which cost nothing to carry and must never be deleted
The canonical helper set
Already implemented across the tree — copy it, don't reinvent it. From
astro-knots/splash/src/content.config.ts:
const lenientString = z.preprocess(
(v) => (v === '' || v === null ? undefined : v),
z.string().optional(),
);
const lenientDate = z.preprocess(
(v) => (v === '' || v === null ? undefined : v),
z.coerce.date().optional(),
);…plus lenientStringArray (which also promotes a bare string to a one-element
array), lenientNumber, and lenientBoolean.
Two things make these work:
The
preprocessstep maps''andnulltoundefined. This is the load-bearing part.date_authored_final_draft:with no value parses asnull, andz.coerce.date()turnsnullinto the Unix epoch rather than rejecting it — so without the preprocess you get a silently wrong 1970 date, which is worse than an error..passthrough()on the object so unknown keys survive intoentry.datainstead of throwing.
Sixteen content.config.ts files in this tree already use .passthrough(). It
is the established pattern, not a proposal.
The trap: leniency moves the failure, it does not remove it
This is the part that costs real money, and it is not obvious.
A schema that requires nothing cannot fail a build. It also cannot protect the consuming code — and that code typically walks a list of field names and takes the first that resolves:
date_modified ?? date_updated ?? date_createdWritten before the editorial date convention existed, that chain has never heard
of date_authored_current_draft. Give it a document whose only dates are
editorial and it resolves to nothing. The build passes. The page renders. The
date is blank and the sort key is zero. No error, no log line, nothing to grep
for.
This is not hypothetical. Audited 2026-08-17 across every surface that renders
context-v/: 13 of 13 page files across 8 splashes were editorial-blind, and
16 documents were already rendering blank dates in production.
A strict schema is the safer failure here, because it fails loudly. Leniency buys build resilience and pays for it in silent wrongness. So:
Append new field names to the END of every resolution chain, preserving precedence, whenever the standard grows a field.
Grep for chains, not just schemas, before retiring or adding any key:
BASHgrep -rn "date_modified ??\|date_created ??\|\.data\.date\b" src \ --include="*.astro" --include="*.ts"If a chain result is passed to
.toISOString(), the new keys must also be declared in the schema so they coerce toDate. Undeclared,.passthrough()hands them through as raw strings and the build dies on a method that isn't there.Prove chain changes are zero-diff by building before and after. Be aware these builds are not deterministic — the search widget and, on some sites, lightbox and memomark components mint a random DOM id per build, so an unnormalized diff shows 100% of pages changed and proves nothing.
How to apply
Whenever you scaffold a site or touch src/content.config.ts:
Every field
.optional(). No exceptions for "obviously required" ones — see the load-bearing-key escalation in the layer-1 reminder, which handles genuine requirements at the loader rather than the schema..passthrough()on every object schema.Declare both spellings of aliased fields (
at_semantic_versionandsemantic_version;ledeanddescription) — a consumer resolvinga ?? bneeds both to survive validation.Declare fields the site does not currently render but that documents carry. Declaring is cheap; a future consumer that needs coercion will thank you.
Prefer
z.coerce.*over exact types, always behind the''/nullpreprocess.
Triggers
Writing the first
content.config.tsfor a new site undersites/Adding a collection to an existing site
A
pnpm buildfailing on Zod validation of frontmatterAdding a field to the frontmatter standard — the schema and every resolution chain need it, and the chains are the half everyone forgets
A rendered date, tag, or author that is mysteriously blank on a page that builds fine
Related
YAML Frontmatter Parsing Must Be Lenient — layer 1, the parse-layer companion
Managing Complex Markdown Content at Build Time — the broader blueprint
context-v/skills/context-vigilance/references/frontmatter-spec.md— the standard the leniency exists to absorb drift against