Content model
The page format, the schema system behind it, and the component catalogue. This is the specification the parser, the validator, and the generated agent prompt all derive from.
A page
One markdown file per URL. src/content/displays/index.md serves /displays/.
---
title: Live departure displays for stops, routes, and systems
description: Departure boards driven by your GTFS and GTFS-Realtime feeds,
offline-resilient and compatible with old hardware.
layout: landing
nav: displays
updated: 2026-08-19
---
:::hero
heading: Departure boards riders can actually read.
lead: |
Every stop, every route, every platform, driven by the feeds you already
publish.
ctas:
- { label: Get in touch, href: "mailto:hello@gtfs.media", style: primary }
:::
## What a display shows
Ordinary markdown between blocks becomes a `prose` block. Headings, lists,
links, and emphasis all work. Raw HTML does not.
:::shots
items:
- media: pylon-single
caption: A custom platform pylon, multilingual by default.
href: /displays/
:::
Frontmatter
| Field | Type | Required | Notes |
|---|---|---|---|
| `title` | string | yes | The `
|
| `heading` | string | no | The visible `h1`, when it should differ from the tab title. A tab title often carries the site name; a heading never should |
| `description` | string | yes | Meta description and Open Graph description. 50 to 160 characters |
| `layout` | enum | yes | A file in `layouts/`. Defaults to `page` |
| `nav` | string | no | Which nav item gets `aria-current="page"` |
| `updated` | date | no | Rendered where the layout calls for it |
| `noindex` | boolean | no | Adds the robots directive and drops the page from the sitemap |
| `og` | map | no | Per-page Open Graph overrides |
| `redirects` | list | no | Old paths that should land here. Written into `_redirects` |
Anything not in the layout's declared frontmatter schema is an error, so a typo in a key surfaces at check time instead of silently doing nothing.
Locale is carried by the file's path rather than by a frontmatter field: src/content/es/displays/index.md serves /es/displays/. See TRANSLATION.md.
Blocks
A block opens with :::type on its own line and closes with ::: on its own line. Everything between is YAML. Nothing nests.
:::cards
heading: One platform, three layers
items:
- title: Data platform
href: /developers/
:::
The splitter takes those regions as raw text before any markdown parsing happens, so YAML is never reinterpreted by the markdown parser. Text outside any block is collected into prose blocks.
How much of this is invented
Two conventions, and neither is new.
:::type containers are the CommonMark generic directives proposal, the syntax remark-directive implements and the one Docusaurus, Astro, and most MDX-adjacent tooling already use for admonitions and custom blocks. An author who has written documentation in the last few years has seen it.
YAML is already in the frontmatter of every markdown file everywhere.
What the engine adds on top is one rule: a container's body is YAML rather than markdown. That rule exists so there is never a question about how a block's contents are read, and it is the only thing here a person has to be told.
Everything else is the component vocabulary, which is a vocabulary rather than a syntax. :::hero has to be documented the same way a function has to be documented, and it is, from the schema that defines it.
Documentation, generated from the schemas
One source, three renderings.
| Rendering | Command | For |
|---|---|---|
| `docs/components/*.md`, committed | `cms docs` | A person reading the repo |
| The `components` prompt slot | `cms sync` | The editing agent |
| The validator | every `cms check` | The build |
cms docs writes a page per component: what the block is for, when to reach for it and when not to, every field with its type and its help text, the accessibility rules attached to it, a worked example, and the HTML it produces. cms explain hero prints the same thing in the terminal.
Because all three come from schema.yml, a field added without help text shows up as a gap in the human documentation on the next build, which is the pressure that keeps the reference worth reading. A component whose documentation drifts from its validator is not a state the system can reach.
Fields every block has
Two fields exist on every block without appearing in any component's schema.
| Field | Type | For |
|---|---|---|
| `id` | text, max 64 | A stable anchor, rendered as the section id. Lowercase, hyphenated. Uniqueness is checked across the page |
They live on the block rather than in each schema because every block can take them and repeating them eighteen times would guarantee they drift.
A block with no content at all is an error. With optional fields that is reachable by accident, and a block that renders as nothing still occupies a position in the page and a line in cms outline.
Headings inside a block
A block's markdown body can use any heading level, and the renderer normalises them: whatever the author used as their top level becomes the floor, and everything below keeps its relative depth.
The floor is h2 for a plain block, or h3 for a block that emits its own heading. So ## and ### as a top level both work, and neither produces a skipped level or a second h1.
This exists because the page owns its h1. A legal page written as plain markdown starting with # would otherwise produce two, and the heading tree would be unnavigable.
Relatedly, a layout supplies the page heading only when no block already did. A legal layout renders title as an h1, unless the page has a hero, which carries its own.
Field types
| Type | Accepts | Rendered as |
|---|---|---|
| `text` | plain string | escaped text, no markup |
| `inline` | string with inline markdown | links, `strong`, `em`, `code`, entities |
| `markdown` | block markdown | paragraphs, lists, headings within the block's level |
| `href` | internal path or absolute URL | resolved and link-checked; external links get `target` plus the visually-hidden new-tab note |
| `media` | a name in `src/media/` | a full `
|
| `icon` | a name in the icon set | inline SVG with `aria-hidden="true"` |
| `enum` | one of a listed set | whatever the template does with it |
| `list |
a YAML sequence | repeated, with `min` and `max` enforced. Nests, so `list
|
A media field takes a bare name, with no path and no extension. Passing img/photo.jpg is an error rather than a guess, because the build decides the format and the widths.
text versus inline is the lever that keeps markup out of places it does not belong. A card title is text, so no one can put a link inside a heading that is already inside a link. A card body is inline, so a term can carry a reference.
Block schemas
Every component ships a schema. The validator reads it, and so does the generator that writes the component reference into the agent's prompt.
# components/cards/schema.yml
name: cards
summary: A grid of linked cards. The standard way to route from an overview
page to its sections.
band: auto # auto | light | alt | dark
fields:
heading: { type: inline, required: false }
lead: { type: inline, required: false, max_chars: 220 }
tag: { type: text, required: false, max_chars: 24 }
columns: { type: enum, values: [2, 3, 4], default: 3 }
items:
type: list<card>
min: 2
max: 8
types:
card:
title: { type: text, required: true, max_chars: 48 }
body: { type: inline, required: true, max_chars: 240 }
href: { type: href, required: false }
icon: { type: icon, required: false }
more: { type: text, required: false, help: "The affordance text. Must
name the destination, because WCAG 2.4.9 asks that a link make
sense read on its own." }
accessibility:
- Each card is a single link, so the title must not repeat the more text.
- Two cards on a page may not share more text unless they share a target.
classes_max caps how many CSS classes the component's template may emit, which is how "minimal classes" becomes a number rather than a preference. owns_layout: true means the component wraps its own content and the renderer must not add the standard .wrap; only shots needs it, to escape the content column. sizes_by points a media slot at a sibling field so one slot can declare a different sizes expression per value of that field.
band lets a block declare where it sits in the light and dark alternation, or leave it to the renderer. summary and help are what the agent reads, so they are written for that audience. accessibility entries become check rules and also appear in the prompt, so the reason a constraint exists travels with the constraint.
Component catalogue
Every component maps to markup already in base.css v2. Nothing here invents a class. The engine's class allowlist is built by parsing the stylesheet, so a component that reached for a class the CSS does not define would fail the build.
Structure
| Block | Renders | Notes |
|---|---|---|
| `prose` | `.section > .wrap` with running text | The implicit type for bare markdown |
| `hero` | `.hero > .wrap.hero-grid` | First block on a landing page. Always a dark band. Carries the `h1` |
| `split` | `.split > .split-panel` | Two panels, each with a heading, body, and one button |
| `features` | `.feature-row > .copy + .media` | Copy beside media. `flip` and `uneven` are fields |
| `cards` | `.card-grid > a.card.card-link` | 2 to 8 items, 2 to 4 columns |
| `steps` | `.steps > .step` | Numbered sequence. Numerals are drawn by the CSS |
| `ticks` | `ul.ticks` | Marked list. `style: check` swaps in `.checks` |
| `shots` | `figure.shot` with `figcaption` | Screenshots. Uses the media pipeline |
| `code` | `.code` with `pre > code` | Language field drives the token classes |
| `table` | `table` in a scrolling wrapper | Header row required, caption required |
| `faq` | `details` group | Each item is a `summary` plus body |
| `plain` | `details.plain-summary` | The plain-language summary. See below |
| `proof` | `.proof` | Dark strip for logos and a claim. The one place amber is text |
| `cta` | `.cta-band` | Closing ask. Dark band |
| `embed` | `iframe` in a framed figure | Requires a title, a fallback link, and a declared aspect ratio |
Chrome
Header, nav, and footer are partials driven by src/data/site.yml and nav.yml, not blocks. A page cannot change them, which is the point: they are identical on every page, defined once.
# src/data/nav.yml
main:
- { id: agencies, label: Agencies, href: /agencies/ }
- { id: developers, label: Developers, href: /developers/ }
- { id: contact, label: Get in touch, href: "mailto:hello@gtfs.media", style: cta }
footer:
- heading: Product
links: [...]
legal:
- { label: Privacy policy, href: /privacy/ }
- { label: Terms of use, href: /terms/ }
cms nav add edits this file, and every page picks the change up on the next build. That replaces the current pattern of thirteen copies of the same header.
The plain-language block
plain deserves its own note because it is doing accessibility work, not decoration. WCAG 3.1.5 (AAA) asks for a supplementary version of content that requires more than lower secondary reading ability. gtfs.media already ships one on its home page, as an "In plain language" disclosure.
The engine makes that a first-class component and the conformance ledger tracks it: a page whose prose reads above the threshold, and which has no plain block, is flagged. The reading-level estimate is advisory and a person attests the result, for the reasons set out in ACCESSIBILITY.md.
Addressing blocks
Blocks carry no synthetic ids in the source. cms outline numbers them, and tools and conversation use those numbers.
$ cms outline /displays/ 1 hero "Departure boards riders can actually read." 2 prose h2 "What a display shows" 3 cards 3 items, h2 "Three ways to run a screen" 4 shots 2 items 5 cta "Put your feed to work."
A block that needs a stable anchor sets id: explicitly, and the renderer emits it on the section. Everything else gets heading-derived anchors.
Escape hatches, and where they stop
The model will not cover everything. Three ways out, in order of preference.
Generate the page. A page whose content comes from a data file rather than from an author is a generator: a site-local module that reads the data and returns blocks. gtfs.media's API reference, built from openapi.yaml, is the example. The blocks it returns go through schema validation, rendering, the normalizer, and every check exactly as a hand-written page's do, so a generated page carries the same guarantees as any other. This is the right answer for most things that feel like they need raw HTML.
Add a component. If a site needs something the catalogue lacks, it lands in src/components/ with a schema, a template, and, if it is genuinely shared, a move into the engine and into base.css. The rule already governing site.css applies unchanged: a component appearing on a second site belongs in the shared layer.
A raw block, gated. For the genuine one-off that is neither generated nor repeated. :::raw takes an HTML body. It is off by default, and turning it on for a site is a line in site.yml that names the pages allowed to use it. Raw content still passes through the normalizer, the class allowlist, and every accessibility check, so it can bend the model without escaping the guarantees. Its presence on a page is reported by cms check, so raw blocks stay visible rather than becoming a quiet habit.
There is no fourth way out, and none of the three lets markup reach a page without passing the gate.