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”| Field | Type | Required | Notes |
|---|---|---|---|
options.allowComments | boolean | No | Defaults to false. When on, editors can leave comment threads on the document in its overlay, and so can named viewer sign-ins of the audiences in options.commentAudiences. Editors read it from the saved draft and viewers from the published site, so a change takes effect when the editor saves (and, for viewers, publishes). Turning it off hides existing threads without deleting them. Hidden in the editor on a site with no comment store: comments need platform mode, or (standalone) SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY and PORTAL_SITE_ID, and a standalone site must first apply the five comment migrations to its own Supabase project — see Auth & audiences setup. Editor guide: Reviewing with comments |
options.commentAudiences | string[] | No | Defaults to []. Comment access: the audience names whose named sign-ins may comment when options.allowComments is on, each seeing every thread on the document, from other assigned audiences and from editors. An assigned audience must also be able to open the document (page and section access). Empty means editors only. Viewers read it from the published site. Shown in the editor as Who can comment, offering the audiences the section’s access allows |
options.inlinePreview | boolean | No | Defaults to false. When on, the file also renders inline below its row at the content width, in a fixed-height frame it scrolls within (HTML in the same sandboxed iframe as the overlay, PDFs in the same pdf.js viewer); clicking the row still opens the overlay. Shown in the editor as Inline preview on the Display tab |
options.queryString | string | No | Defaults to "", max 1024 characters. HTML files only: the query string the document opens with (leading ? optional), so its own scripts can read it from location.search. It rides the gated URL into the overlay and any new-tab open, never a download; on the bucket tier the file route signs it into the presigned redirect, since a parameter appended after signing would break the signature. Names the route or storage reserve are dropped: download, x-amz-*, response-*, and storage subresources (acl, attributes, cors, legal-hold, partNumber, retention, tagging, torrent, uploadId, uploads, versionId). Shown in the editor as Query string on the Parameters tab, for HTML files only |
options.breakpoints | boolean | No | Defaults to false. Takes effect only when options.allowComments is on and the file is HTML: the document overlay then offers Desktop (1440px), Tablet (768px) and Mobile (390px) width presets, and a thread’s width tag switches to its preset. Shown in the editor only under Allow comments and only for HTML files. Pins show at every width either way |
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" |