Section schema reference
Every built-in section is defined with defineSection({ type, schema, ... }). The type string is the discriminator stored in each section’s JSON file; the Zod schema is the source of truth for content shape.
Each block on disk has this outer envelope:
{ id: string; // unique within the site type: string; // section type key (see below) content: { ... }; // type-specific content options?: { ... }; // type-specific display options layout?: { colSpan?: number; // only meaningful inside a container };}link_heading
Section titled “link_heading”Source: packages/primitives/src/components/sections/LinkHeading/index.tsx
A top-level section heading. Generates a navigation anchor.
| Field | Type | Required | Notes |
|---|---|---|---|
content.heading | string | Yes | Heading text |
sub_heading
Section titled “sub_heading”Source: packages/primitives/src/components/sections/SubHeading/index.tsx
A second-level heading. Excluded from the top-level nav by default.
| Field | Type | Required | Notes |
|---|---|---|---|
content.heading | string | Yes | Heading text |
content.excludeFromNav | boolean | No | When true, omits this heading from nav generation |
sub_sub_heading
Section titled “sub_sub_heading”Source: packages/primitives/src/components/sections/SubSubHeading/index.tsx
A third-level heading. Never appears in top-level nav.
| Field | Type | Required | Notes |
|---|---|---|---|
content.heading | string | Yes | Heading text |
content.excludeFromNav | boolean | No | When true, omits this heading from nav generation |
Source: packages/primitives/src/components/sections/Prose/index.tsx
A rich-text body block. Content is stored as sanitized HTML.
| Field | Type | Required | Notes |
|---|---|---|---|
content.body | string | Yes | HTML string; listed in richTextFields and sanitized on save |
Source: packages/primitives/src/components/sections/Media/index.tsx
A single image or video block.
content fields
Section titled “content fields”| Field | Type | Required | Notes |
|---|---|---|---|
content.ref | SingleMediaReference | Yes | Image or video reference (see below) |
content.link | LinkValue | No | Optional link wrapping the media |
SingleMediaReference — type: "image"
Section titled “SingleMediaReference — type: "image"”| Field | Type | Required | Notes |
|---|---|---|---|
type | "image" | Yes | Discriminator |
imageId | string | Yes | Media manifest ID; defaults to "" |
caption | string | string[] | No | Caption text |
background | string | No | Legacy; not rendered — use the section’s options.background |
invertFrom | string | No | CSS colour; the image inverts when the page background matches it (e.g. for dark-mode logo swaps) |
border | boolean | No | Render a border |
objectFit | "cover" | "contain" | No | CSS object-fit |
SingleMediaReference — type: "video"
Section titled “SingleMediaReference — type: "video"”Inherits all "image" fields, plus:
| Field | Type | Required | Notes |
|---|---|---|---|
type | "video" | Yes | Discriminator |
poster | string | No | Poster image URL |
autoplay | boolean | No | Auto-play on load |
loop | boolean | No | Loop playback |
muted | boolean | No | Muted by default |
options fields
Section titled “options fields”| Field | Type | Required | Notes |
|---|---|---|---|
options.square | boolean | No | Force square aspect ratio |
options.showCaption | boolean | No | Render the caption below the media |
options.border | boolean | No | Render a border |
options.objectFit | "cover" | "contain" | No | CSS object-fit; defaults to "contain" |
options.background | ImageBackground | No | Backdrop filling the image box; images only, ignored for video. Omit for none (see Shared types) |
Source: packages/primitives/src/components/sections/Video/index.tsx
A watchable video player — self-hosted from the media library or embedded from YouTube/Vimeo/Instagram. Instagram embeds render the post card at a kind-derived aspect ratio (reels 9:16, posts 4:5) that editors can override. Use playback "click" for content viewers press play on (controls + audio) and "ambient" for silent looping footage. For decorative loops inside grids prefer the media block; large self-hosted files require the site’s asset storage.
content fields
Section titled “content fields”| Field | Type | Required | Notes |
|---|---|---|---|
content.source | LibrarySource | ExternalSource | Yes | Where the video comes from (see below) |
content.caption | string | No | Rendered beneath the player when options.showCaption is on |
content.source — type: "library"
Section titled “content.source — type: "library"”| Field | Type | Required | Notes |
|---|---|---|---|
type | "library" | Yes | Discriminator |
ref.imageId | string | Yes | Media manifest id of the video asset; defaults to "" |
poster.imageId | string | No | Media manifest id of an image used as the poster instead of the auto-generated frame. Kept as a sibling of ref (not nested inside it) so the SSR ref-baker resolves the override independently, instead of colliding with the video’s own auto-generated poster |
content.source — type: "external"
Section titled “content.source — type: "external"”| Field | Type | Required | Notes |
|---|---|---|---|
type | "external" | Yes | Discriminator |
provider | "youtube" | "vimeo" | "instagram" | Yes | Derived from the pasted URL |
id | string | Yes | Provider video id parsed from the pasted URL (Instagram: the post/reel shortcode) |
kind | "post" | "reel" | "tv" | No | Instagram only: which embed path the shortcode came from (/p/ → "post"). Omitted for other providers |
url | string | Yes | The original video page URL as pasted |
width | number | Yes | Aspect-ratio width (from oEmbed; 16 when unknown; Instagram: 9 for reels/tv, 4 for posts — editors can override via the aspect toggle) |
height | number | Yes | Aspect-ratio height (from oEmbed; 9 when unknown; Instagram: 16 for reels/tv, 5 for posts — editors can override via the aspect toggle) |
options fields
Section titled “options fields”| Field | Type | Required | Notes |
|---|---|---|---|
options.playback | "ambient" | "click" | No | "click" (default): poster + native controls with audio. "ambient": autoplays muted in a loop with no controls |
options.showCaption | boolean | No | When true, render the caption beneath the player |
Examples
Section titled “Examples”Library source:
{ "type": "video", "content": { "source": { "type": "library", "ref": { "imageId": "a1b2c3d4" } }, "caption": "Behind the scenes" }, "options": { "playback": "click", "showCaption": true }}Library source with a poster override (note poster is a sibling of ref, each with its own imageId):
{ "type": "video", "content": { "source": { "type": "library", "ref": { "imageId": "a1b2c3d4" }, "poster": { "imageId": "e5f6a7b8" } } }, "options": { "playback": "click" }}External source:
{ "type": "video", "content": { "source": { "type": "external", "provider": "youtube", "id": "dQw4w9WgXcQ", "url": "https://youtu.be/dQw4w9WgXcQ", "width": 16, "height": 9 } }, "options": { "playback": "ambient" }}Instagram reel (aspect ratio derived from the URL kind; Instagram ignores the playback option — the embed card controls its own playback):
{ "type": "video", "content": { "source": { "type": "external", "provider": "instagram", "id": "C1a2B3c4D5e", "kind": "reel", "url": "https://www.instagram.com/reel/C1a2B3c4D5e/", "width": 9, "height": 16 } }, "options": {}}button
Section titled “button”Source: packages/primitives/src/components/sections/Button/index.tsx
A call-to-action button.
| Field | Type | Required | Notes |
|---|---|---|---|
content.text | string | Yes | Button label |
content.link | LinkValue | No | Destination; see LinkValue below |
content.download | boolean | No | Adds download attribute to the rendered anchor |
container
Section titled “container”Source: packages/primitives/src/components/sections/Container/index.tsx
A layout wrapper that holds child blocks. Nesting is capped at depth 2 (a container may hold leaves, not other containers).
| Field | Type | Required | Notes |
|---|---|---|---|
content.layout | "grid" | "masonry" | Yes | Layout engine: grid arranges children in fixed columns with per-child column spans; masonry flows children through a CSS multi-column gallery at their natural height (no column spans); defaults to "grid" |
content.columns | number (int, 1–6) | Yes | Grid: number of columns children flow across. Masonry: number of CSS multi-columns children flow through — the editor’s Columns select floors this at 2 in masonry mode (a single masonry column would just be a plain stack). Defaults to 1 |
content.equalizeRowHeight | boolean | Yes | Grid only: stretch every child to the height of the tallest child in its row (best-effort — applies to children whose root element can stretch, e.g. media/cards). Ignored in masonry. Defaults to false |
content.children | Section[] | Yes | Child blocks (any section type, each with an id and optional layout.colSpan); defaults to [] |
content.childDefaults | Record<string, unknown> | No | Default options applied to children added via the editor |
Masonry is columns-only (CSS columns-N multi-column flow, gap-8 + per-item mb-8 break-inside-avoid) — there is no justified-rows flow and no aspect-ratio layout engine; items flow at their natural height in real DOM order, so drag-and-drop reordering works the same as grid.
Child blocks may carry layout.colSpan (int ≥ 1). In grid layout, if colSpan exceeds columns, it is clamped to columns on the next parse. In masonry layout, layout.colSpan is stripped from every child on parse — masonry has no column spans.
spacer
Section titled “spacer”Source: packages/primitives/src/components/sections/Spacer/index.tsx
An empty vertical gap. No content fields.
| Field | Type | Required | Notes |
|---|---|---|---|
content | {} | — | Always an empty object |
reference
Section titled “reference”Source: packages/primitives/src/components/sections/Reference/index.tsx
Shows read-only, live copies of other sections. A Reference stores pointers, never content: it resolves them on every render against the published site, so editing a source updates every Reference that shows it.
| Field | Type | Required | Notes |
|---|---|---|---|
content.tagIds | string[] | Yes | Tag ids from tags.json; every section carrying one is shown. Defaults to [] |
content.sectionIds | string[] | Yes | Individually picked section ids, from any page. Defaults to [] |
Resolution rules:
- Union, site order. Tagged and picked sections combine; an overlap shows once. Items are listed in site order (pages in nav order, then position on the page), whatever order the ids are stored in.
- Never widens exposure. An item shows only if the viewer passes its source page’s access and its source section’s access — on top of the referencing page’s and the Reference’s own.
- Status. Only live sections on live pages. Hidden (not-in-nav) pages count; archived pages do not.
- Nesting. Containers come whole. References in the source set are skipped, the Reference itself included. A Reference is top-level only and cannot go inside a container.
- Nav. Headings (and nav-on TL;DR plates) inside the copies join the referencing page’s nav as its own entries. Their anchors are suffixed with the Reference’s own id (
<heading>-<reference id>, then-2,-3on further collision), so two References showing the same section never collide and a copy’s anchor doesn’t change when References are reordered; anchors native to the page never move. In the editor, the same headings appear in the sidebar nav. - Dangling ids. Unknown section ids and deleted tags drop silently on the viewer, and the editor flags them. The MCP connector’s
save_sectionsis stricter: it rejects a Reference that names an unknown tag or section when the Reference is saved.validate_siteandpnpm exec authoring validatereport the same dangling ids as warnings.
colors
Section titled “colors”Source: packages/primitives/src/components/sections/Colors/index.tsx, schema.ts
A brand color palette display.
content fields
Section titled “content fields”| Field | Type | Required | Notes |
|---|---|---|---|
content.colors | ColorItem[] | Yes | One entry per color |
ColorItem
Section titled “ColorItem”| Field | Type | Required | Notes |
|---|---|---|---|
name | string | No | Display name for the color |
spaces | ColorSpace[] (min 1) | Yes | One or more color space representations |
ColorSpace
Section titled “ColorSpace”At least one field must be present.
| Field | Type | Required | Notes |
|---|---|---|---|
hex | string | No | 6-digit hex (#rrggbb) |
rgb | string | No | Free-form RGB string |
cmyk | string | No | Free-form CMYK string |
pantone | string | No | Pantone name or code |
options fields
Section titled “options fields”| Field | Type | Required | Notes |
|---|---|---|---|
options.label | string | No | Section label text |
options.columns | number (int, 2–4) | No | Display columns |
options.collapsing | boolean | No | Enable collapsing layout |
options.showLabel | boolean | No | Render the label; defaults to true in settings |
icon_list
Section titled “icon_list”Source: packages/primitives/src/components/sections/IconList/index.tsx
A list of labelled items, each with optional icon and do/don’t marker, or numbered 1, 2, 3… instead of icons.
content fields
Section titled “content fields”| Field | Type | Required | Notes |
|---|---|---|---|
content.items | IconListItem[] | Yes | One entry per list item |
IconListItem
Section titled “IconListItem”| Field | Type | Required | Notes |
|---|---|---|---|
label | string | Yes | Short label |
text | string | Yes | Body text |
icon | string | No | Icon identifier |
dodont | "do" | "dont" | No | Do/Don’t marker |
options fields
Section titled “options fields”| Field | Type | Required | Notes |
|---|---|---|---|
options.icon | string | null | No | Default icon for all items |
options.showLabel | boolean | No | Render item labels |
options.stackText | boolean | No | Stack label + text vertically |
options.dodont | "do" | "dont" | No | Whole-list Do/Don’t. Renders every item as a green check ("do") or red x ("dont") by default; a per-item dodont or a per-item icon overrides it. Inserted ready-made by the “Do / Don’t list” Add-menu preset. |
options.marker | "icon" | "number" | No | What marks each item. Absent means "icon". "number" renders bold numerals starting at 1 in the icon’s place (same size, column and color — a per-item dodont still tints its numeral), ignoring per-item and default icons. Standard lists only: ignored when options.dodont is set. |
dodont_media
Section titled “dodont_media”Source: packages/primitives/src/components/sections/DoDontMedia/index.tsx
A media block with an explicit Do / Don’t annotation. Designed to be used inside a container alongside other dodont_media blocks.
content fields
Section titled “content fields”| Field | Type | Required | Notes |
|---|---|---|---|
content.ref | SingleMediaReference | Yes | Image or video reference; same shape as media |
content.dodont | "do" | "dont" | Yes | Annotation shown on the block |
content.link | LinkValue | No | Optional link wrapping the media |
options fields
Section titled “options fields”| Field | Type | Required | Notes |
|---|---|---|---|
options.square | boolean | No | Force square aspect ratio |
options.showCaption | boolean | No | Render the caption below the media |
options.border | boolean | No | Render a border |
options.objectFit | "cover" | "contain" | No | CSS object-fit; defaults to "contain" |
options.background | ImageBackground | No | Backdrop filling the image box; images only, ignored for video. Omit for none (see Shared types) |
document
Section titled “document”Source: packages/primitives/src/components/sections/Document/index.tsx
One downloadable/viewable file — a PDF or a single self-contained HTML file — presented as a clickable row. Clicking opens it in a full-viewport preview overlay without leaving the page; on phones a PDF opens in the OS viewer instead. Pick the file from the media library (documents tab); leave the title blank to show the file name without its extension, or type one to override it. Leave “Show in Documents menu” on for anything a viewer should be able to reach from any page — those entries collect into a derived Documents group at the bottom of the left nav. Use one section per file; for a set of downloads, place several document sections in a container.
Bytes are auth-gated end to end: the row and the overlay both read /api/document-file/{documentId}/{filename}, which enforces the site gate plus the referencing section’s and page’s access. File metadata (name, size, kind) lives in the src/content/document-manifest.json sidecar, keyed by content.doc.documentId — never in image-manifest.json.
content fields
Section titled “content fields”| Field | Type | Required | Notes |
|---|---|---|---|
content.title | string | Yes | Row title. Blank renders the file name without its extension — a display fallback, never written back into content |
content.doc.documentId | string | Yes | Hash key into the document manifest. Deliberately not imageId — documents are not resolved by the media ref-baker |
content.showInDocumentsNav | boolean | No | Defaults to true. When on, the document joins the derived “Documents” group at the bottom of the left nav |
options fields
Section titled “options fields”None.
section_summary
Section titled “section_summary”Source: packages/primitives/src/components/sections/SectionSummary/index.tsx
The end-of-section TL;DR plate (“TL;DR / Summary” in the Add menu): a primary-colored plate with a kicker, a script accent, numbered principles (bold headline + optional one-sentence explainer, each with an optional jump-link back to the sub-section it distills) and a footer with a note and a copy-summary action. Renders as static HTML on the viewer — no hydration; the copy action is a tiny inline script. Colors and fonts are theme tokens: --color-primary / --color-on-primary / --color-primary-contrast, and the optional site hooks --font-label and --font-accent, both falling back to the heading font.
It is the one non-heading section that can put itself in the page nav. With options.nav set, it adds an entry under options.navLabel: "heading" at the top level (like a Link Heading), "subheading" nested under the preceding Link Heading — and, exactly like a Sub Heading, dropped from the nav when there is none (the editor shows a hint). The anchor is derived from the label; if another anchor on the page already uses it (a heading’s, or another summary’s), it gets -2, -3… Heading anchors never move.
content fields
Section titled “content fields”| Field | Type | Required | Notes |
|---|---|---|---|
content.kicker | string | No | Uppercase label, top-left. Default "" |
content.accent | string | No | Script flourish, top-right, e.g. "tl;dr". Default "" |
content.items | SummaryItem[] | No | The principles, in display order; numbered automatically |
content.footNote | string | No | Footer note; empty = auto "<count> principles" |
SummaryItem
Section titled “SummaryItem”| Field | Type | Required | Notes |
|---|---|---|---|
headline | string | No | The principle, short and bold |
explainer | string | No | Optional one-sentence explainer |
link | LinkValue | No | Optional jump-link; empty renders nothing but keeps its line |
linkLabel | string | No | Jump-link text; auto-derived for internal links, falls back to the URL for external |
options fields
Section titled “options fields”| Field | Type | Required | Notes |
|---|---|---|---|
options.showAccent | boolean | No | Default true |
options.showFooter | boolean | No | Default true |
options.showCopy | boolean | No | Copy-summary action. Default true |
options.numbered | boolean | No | Item numerals. Default true |
options.nav | "off" | "heading" | "subheading" | No | Page-nav entry level. Absent = off |
options.navLabel | string | While nav is on | Nav entry text; no label = no entry. Prefilled from the kicker in the editor |
Shared types
Section titled “Shared types”ImageBackground
Section titled “ImageBackground”Defined in packages/primitives/src/schemas/shared.ts. A per-placement backdrop behind an image, discriminated on mode. Used by media and dodont_media as options.background.
Light / Dark (mode: "theme") — the site’s light or dark surface, chosen separately per color mode. A dark plate chosen for light mode stays dark.
| Field | Type | Notes |
|---|---|---|
mode | "theme" | |
light | "none" | "light" | "dark" | Backdrop in light mode |
dark | "none" | "light" | "dark" | Backdrop in dark mode |
Custom (mode: "custom") — one color in both modes.
| Field | Type | Notes |
|---|---|---|
mode | "custom" | |
color | string | 6-digit hex, e.g. "#3b1f3a" |
LinkValue
Section titled “LinkValue”Defined in packages/primitives/src/schemas/link.ts. A discriminated union on kind.
External link (kind: "external"):
| Field | Type | Notes |
|---|---|---|
kind | "external" | |
href | string | Must be empty, relative, http(s)://, or mailto:; dangerous schemes (javascript:, data:) are rejected |
target | "_self" | "_blank" |
Internal link (kind: "internal"):
| Field | Type | Notes |
|---|---|---|
kind | "internal" | |
pageId | string | ID of the target page |
anchorSectionId | string | null | undefined | Optional in-page anchor |
target | "_self" | "_blank" |