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 | Background color hint |
invertFrom | string | No | Theme at which to invert the image |
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" |
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 |
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.
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. |
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" |
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.
Shared types
Section titled “Shared types”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" |