Spec 02 — Content Schema
The strict schemas for page types, blocks, menus, site configuration, media and featured content.
1. Mechanics (normative)
- Content is frontmatter + body. Frontmatter is YAML validated by the collection schema; body is Markdown.
- The reference schema language is Zod, used at build time. The schemas below are the normative contract; the reference implementation is illustrative.
- Validation is total and build-fatal (Constitution §2): unknown frontmatter
fields are rejected (
.strict()), missing fields are rejected, wrong types are rejected. There is no warnings-only mode. - MDX appears only inside blobs and (if a site opts in) composed-block data. Ordinary page bodies are Markdown.
2. Reference page types
A conformant site is not required to use these five types, but the family SHOULD stay on them; they are the shared vocabulary that makes sites understandable to each other and to agents.
2.1 info — a static information page
info: z.object({
title: z.string(),
ref: z.string(), // menu/display name, may differ from title
summary: z.string().optional(),
variant: z.string().optional(), // token name
body: z.string().optional(), // markdown; an info page may be pure blocks
blocks: blockList.optional(), // §3
}).strict()
2.2 document — a long-form publication
For consultation responses, reports, statements: long, structured, referenced, possibly with attachments.
document: z.object({
title: z.string(),
published: z.coerce.date(),
updated: z.coerce.date().optional(),
summary: z.string(),
featured: kebabName.optional(), // slot this entry fills (§9)
attachments: z.array(z.object({
label: z.string(),
file: z.string(), // repo-relative asset path
})).optional(),
body: z.string(), // markdown; footnotes are ordinary markdown footnotes
}).strict()
2.3 post — a dated article
News, blog writing, ride reports, book notes. One type, many templates via
variant.
post: z.object({
title: z.string(),
date: z.coerce.date(),
updated: z.coerce.date().optional(),
summary: z.string(),
cover: z.string().optional(), // image path; alt text REQUIRED in media schema
variant: z.string().optional(),
tags: z.array(z.string()).default([]),
featured: kebabName.optional(), // slot this entry fills (§9)
attachments: attachmentSchema.optional(), // e.g. a free book's PDF/EPUB
body: z.string(),
}).strict()
2.4 composed — a block-composed page
Home pages and landing pages.
composed: z.object({
title: z.string(),
ref: z.string(),
blocks: blockList, // non-empty, ordered; §3
}).strict()
2.5 blob — the escape hatch
blob: z.object({
title: z.string(),
ref: z.string(),
justification: z.string(), // one line; audited per spec/01 §2.4
body: z.string(), // Markdown/MDX, inside the chrome
}).strict()
The mandatory justification field is deliberate friction.
3. Reference block library
Each block type has its own strict schema; blockList validates that every
entry’s type is known and its data conforms. Reference set:
| Block | Purpose | Key fields |
|---|---|---|
hero |
Opening banner | heading, lede, images[] (with alt), cta? |
card-grid |
Linked card layout | cards[]: { title, summary, ref?, href?, image? } |
text |
Prose section | heading?, body (markdown) |
quote |
Pull quote | text, attribution? |
gallery |
Image sequence | images[] { src, alt, caption? } |
stats |
Numeric aggregates | items[] { label, projection } — values RESOLVED at build, never typed (spec/01 §7) |
link-list |
Curated links | items[] { label, href, note? } |
feature |
Spotlight one content item | ref, lede? |
cta |
Call to action | heading, body?, links[] |
notice |
Highlighted info box | body (markdown), severity? |
Sites MAY add block types; they MUST live in the site’s block registry with schemas, and they are subject to the same chrome rule as everything else.
4. Menus schema
menu: z.object({
items: z.array(menuItem),
}).strict()
menuItem: z.object({
label: z.string(),
ref: z.string().optional(), // content reference — preferred
href: z.string().optional(), // internal path, /#anchor, or external URL
children: z.array(menuItem).optional(),
}).strict().refine(item => item.children?.length
? true // parent label may omit both
: exactlyOneOf(item.ref, item.href))
A leaf item MUST carry exactly one of ref/href. A parent item with
children MAY omit both — a dropdown label that does not itself navigate.
(Found in validation: a real site’s “More” dropdown menu.)
5. Site config schema
site: z.object({
name: z.string(),
domain: z.string(),
contactEmail: z.string().email(),
registration: z.object({
charityNumber: z.string().regex(/^\d{7,8}$/).optional(),
companyNumber: z.string().regex(/^\d{8}$/).optional(),
}).optional(),
chrome: z.object({
headerMenu: z.string(), // menu name
footerMenu: z.string(),
bannerMenu: z.string().optional(),
}),
footer: z.object({
note: z.string().optional(), // disclaimer line
links: linkListSchema.optional(),
social: linkListSchema.optional(),
}).optional(),
}).strict()
6. Media schema
Every image reference anywhere in content MUST carry alt text. The media
schema enforces { src, alt } pairs; a bare string is rejected. Long
descriptions are caption?.
Why: alt text is the one field that is invisible in every WYSIWYG and therefore missing everywhere. Making it schema-mandatory is cheaper than any later remediation.
7. The trust file
trust.yaml is schema-validated (spec/04 §4) and lives in the site
repository — the dial is versioned with the site, and a change to the dial is
itself a PR.
8. Theme manifest schema
Each theme declares its capabilities; the build validates content against the
active theme’s manifest (spec/01 §9):
themeManifest: z.object({
name: z.string(),
blocks: z.array(z.string()), // block types this theme renders
pageTypes: z.array(z.string()), // page types this theme templates
tokens: z.array(z.string()), // design knob names this theme consumes
}).strict()
tokens is the authority for token writes: a token not in the manifest is not
a knob of the active theme and MUST be refused.
9. Featured content
Featuring is a flag on the content itself: a page entry declares the
slot it fills via an optional featured: <slot-name> frontmatter field
(kebab-case; post and document carry it in the reference schemas, and
any site-defined type MAY). Surfaces reference the slot as
featured/<slot> (spec/01 §5.1). There is no separate featured file and
no dedicated tool: featuring an entry is an ordinary page edit through the
ordinary content path.
Resolution semantics (normative):
featured/<slot>resolves to the single entry whosefeaturedfield equals<slot>.- A referenced slot with zero or multiple carriers fails the build, naming the slot and the candidates (Constitution §2 — never a silently vacant or ambiguous slot). Moving a feature is therefore a two-edit change (clear the old flag, set the new) that cannot half-ship.
- An entry flagged for a slot no surface references is not an error; validation SHOULD warn.
Setting, moving, or clearing a featured flag is a content-update class
change (spec/04 §3.1): promoting content is the most routine of requestor
asks and must not carry structural friction. A site SHOULD document its
slot conventions (names, how many, who decides) in its repo notes; the
convention for a small campaign site is a single slot (e.g.
current-news).