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.

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 whose featured field 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).