Blocks
Pages in Spillway are built from blocks. A block is a Puck component: a set of fields the editor shows in the right-hand panel, default values, and a render function that turns the props into HTML. The core blocks live in packages/blocks (@spillway-cms/blocks, MIT). The server and the admin both import the same config, so what you see in the editor is what the server renders.
@spillway-cms/blocks exports:
| Export | What it is |
|---|---|
config | The Puck Config: every block, the root, and the categories (Layout, Content, Marketing). |
blockMeta | { [blockName]: { description, aiHint } }, used by docs and the AI layer. |
blockCss | The complete stylesheet as a string. The same CSS is emitted to dist/styles.css (@spillway-cms/blocks/styles.css). |
blockStyleSlots, builtInBlockStyles | The style slots of each styled block and the built-in styles every site starts with (Block styles). |
blockStyleCss, blockStylesCss | The CSS rule set of one block style, or of several, built from tokens. |
styleClassFor, legacyStyleKey, isStyledCoreBlock | The sw-style-* class a block renders, the style an old Button variant stands for, and whether a block has styles. |
emptyLayout | The Puck document for a new page: no blocks. |
sampleHomeLayout | The home page the setup wizard seeds (Hero, Text, Cards, Call to action). |
How a block is stored
A page's layout field holds a Puck document. Each block is an entry in content with a type (the block name) and props (the field values plus an id):
{
"content": [
{ "type": "Heading", "props": { "id": "Heading-1", "text": "Hello", "level": "h2", "align": "left" } }
],
"root": { "props": { "title": "About" } },
"zones": {}
}
Blocks that contain other blocks (Columns) store the children inside their own props, as a slot array. See Columns below.
A styled block may also carry style, the key of a block style, e.g. "style": "dark-band" on a Hero.
Block styles
Hero, Cards, Button, CallToAction, Heading and Text take an optional style prop: the key of one of the site's named styles for that block (Brand → Styles; see theming.md). The page builder shows it as the Style field, a picker of the styles by name with a thumbnail of each, never a color. The block renders the class sw-style-<block>-<key> (lowercase block name: sw-style-calltoaction-band); the server resolves the key first (a missing or unknown key becomes the block's default style) and inlines the style's rules. Without a resolved key, as in a front end that renders the blocks itself, a block falls back to the built-in default class.
Each styled block declares its style slots: what a style may set, each a list of values that map to tokens.
| Block | Slot | Values (the built-in default first) |
|---|---|---|
| Hero | background | image (the hero's background image under a dark overlay, the surface color without one), surface, primary, secondary, accent, neutral-900 |
| Hero | text | auto (light on brand colors and images, dark on the surface), light, dark |
| Hero | layout | stacked, split (text on the left, the image on the right half from 900 px) |
| Cards | border | outline, none |
| Cards | fill | none (the page background), neutral-50, primary-50 |
| Cards | image | top, left (beside the text from 640 px) |
| Button | emphasis | primary, secondary, text (an underlined link) |
| CallToAction | background | primary, secondary, accent, neutral-900, surface, none |
| CallToAction | shape | band (full width), card (a bordered box) |
| CallToAction | alignment | center, left |
| Heading, Text | tone | default, muted, accent |
A brand color the site never set falls back to the primary color, a neutral shade to a fixed gray. The built-in styles are Photo band, Dark band and Split (Hero); Outlined and Filled (Cards); Primary, Secondary and Text link (Button); Band, Card and Dark band (CallToAction); Default, Accent and Muted (Heading); Default, Muted and Accent (Text). The defaults reproduce each block's look from before styles. Hero align and size, Cards columns and the Heading and Text fields stay ordinary fields; a style changes the look, not the content or the layout choices an editor makes per block.
The blocks
Field types are Puck field types: text, textarea, select, radio, array, slot. The "Default" column shows what a block looks like when you drop it on the canvas.
Hero (Marketing)
A large opening section with an optional background image.
| Field | Type | Default |
|---|---|---|
eyebrow | text | "Introducing" |
title | text | "A headline that says what you do" |
subtitle | textarea | one sentence |
primaryLabel, primaryHref | text | "Get started", # |
secondaryLabel, secondaryHref | text | "Learn more", # |
backgroundImage | text (URL) | empty |
align | radio: left, center | center |
size | radio: default, large | default |
Renders <section class="sw-block sw-hero ..."> with an <h1>. A button is omitted when its label is empty. With a background image, the section gets a dark overlay and white text (the default Photo band style; other styles may leave the image out or put it beside the text). Takes style (Block styles).
{ "type": "Hero", "props": { "id": "Hero-1", "eyebrow": "Spillway CMS", "title": "Build pages from blocks.", "subtitle": "Publish with review.", "primaryLabel": "Open the admin", "primaryHref": "/admin", "secondaryLabel": "", "secondaryHref": "", "backgroundImage": "", "align": "center", "size": "large" } }
Heading (Content)
| Field | Type | Default |
|---|---|---|
text | text (inline editable on the canvas) | "Section heading" |
level | select: h1, h2, h3, h4 | h2 |
align | radio: left, center, right | left |
Renders the chosen heading element inside .sw-heading. Use h2 for sections; the Hero already provides the page's h1.
Text (Content)
| Field | Type | Default |
|---|---|---|
content | textarea | two short paragraphs |
format | select: paragraphs, html | paragraphs |
size | radio: normal, lead | normal |
align | radio: left, center, right | left |
With paragraphs, the text is split on blank lines into <p> elements; a single newline becomes <br>. With html, the content is injected as-is with dangerouslySetInnerHTML, so only editors you trust should use it. Output is wrapped in .sw-prose, which styles lists, quotes, code and tables.
{ "type": "Text", "props": { "id": "Text-1", "content": "First paragraph.\n\nSecond paragraph.", "format": "paragraphs", "size": "lead", "align": "left" } }
Image (Content)
| Field | Type | Default |
|---|---|---|
src | text (URL, usually /media/<id>/<filename>) | empty |
alt | text, required | empty |
caption | text | empty |
width | radio: normal, wide, full | normal |
rounded | radio: Yes/No (stored as boolean) | true |
Renders a <figure> with the image and an optional <figcaption>. With no src, a striped placeholder is shown so the block is visible on the canvas. full drops the container and spans the viewport.
Responsive behavior. When src is a library URL (/media/<id>/<filename>), the server looks the item up before render (resolveImageBlock in packages/core/src/media/image-blocks.ts) and passes media (dimensions, focal point, format) to the block. The block then renders <picture> with a WebP <source srcset> at 480, 768, 1200 and 1920 px (never wider than the original), an <img> with the same widths in the original format as fallback, sizes matching the chosen width, real width/height attributes so the page does not jump, loading="lazy", decoding="async" and object-position from the focal point. The page is tagged media:<id>. External URLs and SVG/GIF render as a plain <img>. In the editor (no server) the block shows the plain image too. The Hero block does the same for backgroundImage: a library image becomes an eagerly loaded <img class="sw-hero__bg"> with fetchpriority="high", cropped to 16:7 around the focal point at every width; any other URL stays a CSS background.
Columns (Layout)
| Field | Type | Default |
|---|---|---|
columns | array (2 to 4 items), each with a content slot | two empty columns |
gap | select: normal | normal |
Each column is a slot: drop any block into it. The grid is one column on phones, two from 640px, and the full count from 900px. Stored shape:
{
"type": "Columns",
"props": {
"id": "Columns-1",
"gap": "normal",
"columns": [
{ "content": [ { "type": "Image", "props": { "id": "Image-1", "src": "/media/abc/team.jpg", "alt": "The team", "caption": "", "width": "normal", "rounded": true } } ] },
{ "content": [ { "type": "Text", "props": { "id": "Text-2", "content": "We are a small team.", "format": "paragraphs", "size": "normal", "align": "left" } } ] }
]
}
}
CallToAction (Marketing)
| Field | Type | Default |
|---|---|---|
title | text | "Ready to get started?" |
text | textarea | one sentence |
buttonLabel, buttonHref | text | "Contact us", # |
style | the block style | the site's default (built in: band) |
The built-in band style is a full-width strip in the primary color with a white button; card is a bordered box on the surface color; dark-band a dark neutral strip. Pages saved before block styles stored band or card here, which are the keys of those built-in styles, so they render as before.
Cards (Marketing)
| Field | Type | Default |
|---|---|---|
cards | array (1 to 12) of { title, text, href, imageUrl } | three cards |
columns | radio: 2, 3, 4 (number) | 3 |
Renders a <ul class="sw-cards__grid">. A card with an href becomes a link (the whole card is clickable and shows "Read more"). An imageUrl renders a 16:9 image on top (or beside the text, with a style whose image is left). Takes style.
{ "type": "Cards", "props": { "id": "Cards-1", "columns": 3, "cards": [ { "title": "Fast", "text": "Five-minute setup.", "href": "/docs/install", "imageUrl": "" } ] } }
Spacer (Layout)
| Field | Type | Default |
|---|---|---|
size | radio: s, m, l, xl | m |
Vertical space only (1rem, 2.5rem, 4rem, 6rem). Hidden from assistive technology.
Divider (Layout)
| Field | Type | Default |
|---|---|---|
width | radio: normal, narrow | normal |
A horizontal rule inside the container.
Embed (Content)
| Field | Type | Default |
|---|---|---|
html | textarea | empty |
caption | text | empty |
The HTML is injected with dangerouslySetInnerHTML into a responsive 16:9 frame; any iframe or video inside is stretched to fill it. Paste the embed code from YouTube, Vimeo, or a map provider.
{ "type": "Embed", "props": { "id": "Embed-1", "html": "<iframe src=\"https://www.youtube.com/embed/VIDEO_ID\" title=\"Product tour\" allowfullscreen></iframe>", "caption": "" } }
Button (Content)
| Field | Type | Default |
|---|---|---|
label | text | "Learn more" |
href | text | # |
align | radio: left, center, right | left |
style | the block style | the site's default (built in: primary) |
A single <a class="sw-button">. External links get rel="noopener". The emphasis comes from the style (Primary, Secondary or Text link). Pages saved before block styles may carry variant: "secondary"; such a Button without a style renders with the secondary style.
Form (Marketing)
A lead-capture form. The block stores only a reference; the server attaches the definition before rendering.
| Field | Type | Default |
|---|---|---|
formKey | text | empty |
title | text | empty |
intro | textarea | empty |
submitLabel | text (override) | empty, uses the definition's label |
Before Render, core's block resolver looks up the form by key and adds form: FormDefinition (without connectors, so secrets never reach the HTML) to the props, plus nonce (the page's CSP nonce placeholder, for the inline script) and, when the site has a CAPTCHA provider under Site settings → Forms, captcha: { provider, siteKey, script } (never the secret). It tags the page with form:<id> so saving the form evicts it; the site tag already evicts it when the CAPTCHA settings change. With no matching form the block renders a dashed placeholder Form: <key>.
With a form it renders <form method="post" action="/api/forms/<key>/submit" class="sw-form" data-sw-form="<key>"> (plus enctype="multipart/form-data" when the form has a file field) with one labeled control per field (input for text, email, tel, number and hidden; input type="file" with accept and multiple from the field; textarea; select with a disabled "Choose…" option; a checkbox with the label beside it), required attributes, help text wired through aria-describedby, an empty .sw-form__error line per field for inline messages, a visually hidden honeypot _hp (aria-hidden, tabindex="-1", autocomplete="off"), hidden _t (render time), _page and _utm, the CAPTCHA widget (<div class="cf-turnstile"> or <div class="h-captcha"> with data-sitekey, and the provider's script tag with the nonce), a role="status" region for the outcome, and the submit button. Two columns from 640px; textareas, checkboxes, file inputs and the buttons span both.
A field with visibleWhen carries the rule as data-sw-when (JSON) on its wrapper. A form with steps renders a .sw-form__progress list (one item per step, aria-current="step" on the current one), one <fieldset data-sw-step="n"> per step with a "Step n of m:" legend, and Back / Next buttons that are hidden until the script runs: without JavaScript every step is visible and the one submit button sends it all.
A small inline script (plain ES5, no dependencies, carrying the page nonce) runs once per form on load: it sets _t to the load time (the server value is the render time, which a cached page would make stale; both pass the 2 second rule), fills _page and _utm from the current URL, evaluates the data-sw-when rules on every change (a hidden field is hidden and its controls disabled, so it neither submits nor blocks with required; the server applies the same rules), drives the stepper (one step at a time; Next posts the values to /api/forms/<key>/validate with _step and shows that step's errors inline before moving on), shows the result of a non-JavaScript submit (?form=<key>&sent=1 or &error=1 in the URL), and intercepts submit to POST with fetch (JSON, or the multipart FormData when a file is attached), showing the success message or the field errors in place (jumping back to the step that holds the first error) and resetting the CAPTCHA widget after a failure. If fetch fails (network error) it submits the form normally and the server answers with a 303 back to the page. Without JavaScript the form works the same way through that redirect.
{ "type": "Form", "props": { "id": "Form-1", "formKey": "contact", "title": "Talk to us", "intro": "We answer within a day.", "submitLabel": "" } }
Styles live under .sw-form* and use only theme tokens; the status region uses .sw-form__status--success and --error, field errors .sw-form__error, the stepper .sw-form__progress (.is-current, .is-done) and .sw-form__step.
Collection (Marketing)
| Field | Type | Default |
|---|---|---|
collectionId | text: the id or key of a saved collection | "" |
display | select: cards, list, grid | cards |
columns | select: 2, 3, 4 (number; cards and grid only) | 3 |
showDate | radio: yes / no | true |
showSummary | radio: yes / no (ignored by grid) | true |
emptyText | text shown when the query returns nothing | Nothing here yet. |
filters | array of { field, label, kind }, kind search, taxonomy, select or daterange (see below) | [] |
pageSize | number: items per page when filters or the pager are on | 12 |
showPager | radio: yes / no | false |
showResultCount | radio: yes / no | false |
The stored props are only the ones above. Before a page renders, the server looks up the collection (by id or key), runs its query over published entities and passes the result as items: CollectionItem[] ({ id, type, title, path, summary?, imageUrl?, date? }); see resolveBlockProps in packages/core/src/render/resolve.ts. With items present the block renders <ul class="sw-collection__list"> with one <li class="sw-collection__item"> per entry: the title linked to the entity's path, the hero image (cards only), the date and the summary. Without items (in the editor, or in a bare render) it shows a placeholder box, Collection: <id> (resolved when published), so authors know what will appear. An unknown id renders emptyText.
Pages that contain the block are tagged collection:<id> and list:<type>, and are evicted when an entity of that type is published, unpublished, updated while published or deleted, and when the collection itself changes.
{ "type": "Collection", "props": { "id": "Collection-1", "collectionId": "latest_posts", "display": "cards", "columns": 3, "showDate": true, "showSummary": true, "emptyText": "No posts yet." } }
Shared (Marketing)
| Field | Type | Default |
|---|---|---|
sharedKey | the key of a shared block (in the admin, a picker over the library) | "" |
Renders a block saved in the component library (Shared blocks in the admin). The server resolves sharedKey to the stored component data and attaches it as blocks (one element, with that block's own resolvers applied, so a shared Collection lists its items); the block renders it by calling the referenced block's own render function, wrapped in <div class="sw-shared" data-shared="<key>">. Slot content inside the shared block (Columns) is rendered too. Without resolved data (the editor, an unknown key) it shows a dashed placeholder naming the key. Pages are tagged shared:<key> and evicted when the shared block changes. A shared block cannot be a Shared block itself. See content-model.md.
{ "type": "Shared", "props": { "id": "Shared-1", "sharedKey": "campus_promo" } }
Visitor filters and a pager: filters is an array of { field, label, kind } where kind is search (keyword over the title and the type's text fields), taxonomy or select (a drop-down; options come from the vocabulary's terms, the select field's options or the distinct values in use), or daterange (from/to; matched against occurrences for daterange fields, against the field itself for date fields). pageSize (default 12), showPager and showResultCount complete it. When any of them is set the server reads ?q=, ?<field>=, ?from=, ?to= and ?page= from the page URL, applies them on top of the collection's own query, and passes listing (form action, hidden inputs for unrelated query parameters, the chosen values, the options per field, total, page count and pager links) so the block renders a GET form (.sw-listing__form, labeled controls, a Reset link), the count (role="status") and a pager (nav[aria-label="Pagination"], aria-current="page"). Such pages are cached per query string for 60 s.
{ "type": "Collection", "props": { "id": "Collection-2", "collectionId": "program_finder", "display": "cards", "columns": 3, "showDate": false, "showSummary": true, "emptyText": "No programs match.", "filters": [ { "field": "name", "label": "Search", "kind": "search" }, { "field": "degreeLevel", "label": "Degree level", "kind": "taxonomy" } ], "pageSize": 12, "showPager": true, "showResultCount": true } }
Calendar (Marketing)
| Field | Type | Default |
|---|---|---|
collectionId | text: the id or key of a collection whose type has a daterange field | "" |
view | radio: month (one grid per month) or list (occurrences grouped by day) | month |
monthsAhead | number: how many months to show, 1 to 12 | 1 |
The server resolves months (for each month: label, first weekday, day count and the occurrences that fall in it, with local date and time in the event's own zone), feedHref (/feeds/<key>.ics) and today. The first month is the current one, or ?month=YYYY-MM from the page URL; the first grid carries previous/next links. The grid is a real <table> with weekday headers; each occurrence is a link to the event (prefixed with its time unless all-day). Without resolved months the block shows a placeholder. Pages with a calendar are cached per query string for 60 s and tagged collection:<id> and list:<type>.
{ "type": "Calendar", "props": { "id": "Calendar-1", "collectionId": "upcoming_events", "view": "month", "monthsAhead": 1 } }
Personalize (Marketing)
| Field | Type | Default |
|---|---|---|
audienceKey | text: the key of an audience (Audiences in the admin) | "" |
default | slot: content for everyone else | [] |
variant | slot: content for visitors who match the audience | [] |
Renders as an island: on a cached page the server emits <div class="sw-island sw-personalize" data-sw-island="<entityId>/<blockId>"> around the default slot, and the inline island script replaces it with the visitor's version from GET /_island/… after the page loads (matching happens on the server from the page URL, referrer, cookies and user agent). The island endpoint renders the block with the internal prop show set to default or variant; island and show are never stored. In the editor both slots are shown stacked with labels. Pages are tagged audience:<key>. See personalization.md.
{ "type": "Personalize", "props": { "id": "Personalize-1", "audienceKey": "newsletter", "default": [ { "type": "Text", "props": { "id": "Text-1", "content": "Welcome.", "format": "paragraphs", "size": "normal", "align": "left" } } ], "variant": [ { "type": "Text", "props": { "id": "Text-2", "content": "Welcome back, reader.", "format": "paragraphs", "size": "normal", "align": "left" } } ] } }
Experiment (Marketing)
| Field | Type | Default |
|---|---|---|
experimentKey | text: the key of an experiment (Experiments in the admin) | "" |
a | slot: variant A, the control | [] |
b | slot: variant B | [] |
c | slot: variant C (optional) | [] |
The same island mechanism as Personalize: cached pages carry variant A inside <div class="sw-island sw-experiment" data-sw-island="…" data-sw-variant="a">; the island endpoint assigns a variant by weight for a running experiment (sticky through the sw_x cookie), renders that slot and reports the exposure, which the beacon counts. Draft, paused and ended experiments show A. Pages are tagged experiment:<key>.
{ "type": "Experiment", "props": { "id": "Experiment-1", "experimentKey": "hero_copy", "a": [ … ], "b": [ … ], "c": [] } }
RegistryBrowse (Marketing)
| Field | Type | Default |
|---|---|---|
kind | select: all, module, theme, kit (the kind the page opens on) | all |
pageSize | number: results per page (1–100) | 20 |
showSearch | radio: yes / no | true |
showKinds | radio: yes / no (the All / Modules / Themes / Kits tabs) | true |
showSort | radio: yes / no | true |
layout | radio: list, grid | list |
emptyText | text shown when nothing matches | No packages match… |
The project site's package registry (see website.md). On an instance that hosts the registry (REGISTRY_HOST=1) the server reads ?q=, ?kind=, ?sort= and ?page= from the page URL, queries the registry service and passes data (the search state, the kind tabs with counts, the result cards, total and pager links). The block renders a GET search form (form[role=search] with a labeled search box and a sort select), the kind tabs (nav[aria-label="Package kind"], aria-current="page"), a result count (role="status"), one <li class="sw-reg-card"> per package (name linked to /registry/<name>, kind badge, tier badge for modules, Reviewed or Checks passed and Verified publisher badges, description, publisher, downloads, latest version, updated date) and the same pager as the Collection block. Everything works without JavaScript. Elsewhere, and in the editor, it shows a placeholder. Pages are tagged registry and cached per query for 60 s.
{ "type": "RegistryBrowse", "props": { "id": "RegistryBrowse-1", "kind": "all", "pageSize": 20, "showSearch": true, "showKinds": true, "showSort": true, "layout": "list", "emptyText": "No packages match." } }
RegistryPackage (Marketing)
| Field | Type | Default |
|---|---|---|
basePath | text: the browse page's path; the package name is what follows it in the URL | /registry |
showReadme | radio: yes / no | true |
showVersions | radio: yes / no | true |
showChangelog | radio: yes / no | true |
showAgents | radio: yes / no (the "For agents" section) | true |
notFoundText | text shown for an unknown name | No package by that name… |
One package's page. The server takes the name from the request path after basePath (/registry/@acme/forms → @acme/forms) and passes data: the header (name, latest version, kind, tier, reviewed / checks passed / verified / deprecated badges, description, publisher link, downloads, license, compatible core range, updated date, homepage, repository, manifest.json link), banners (deprecation note, "awaiting review" for an unlisted trusted module, security advisories with severity, affected range and link), the install command with a Copy button (a 400-byte inline script carrying the page's CSP nonce; the command stays selectable text without it), the capability sheet for modules (each capability with a one-line explanation; outbound HTTP hosts and writes are marked sensitive), the version table (version, date, size, core range, status: listed / awaiting review / rejected / yanked), the README and changelog rendered from Markdown (packages/blocks/src/markdown.ts: a dependency-free converter that escapes every character of the source first, so raw HTML never passes through, and keeps only http(s), mailto and relative links), screenshots for themes, keywords, and a "For agents" box linking the agent-readable manifest.json and the signed index. An unknown name renders a "Package not found" heading with a link back to the registry.
Catch-all pages. One page serves every package: publish a page at /registry/any holding this block. A published page whose last path segment is any renders every unmatched path under its parent (the deepest such ancestor wins), with the request path passed to the resolvers, so /registry/forms and /registry/@acme/forms both reach it. The same convention gives /registry/publishers/any to the PublisherProfile block. A kit can ship these pages (a kit page slug may hold slashes; each segment is slugified on its own); spillwaycms.com's website kit ships both and /themes (website.md). Pages holding registry blocks are tagged registry and registry:<name> and evicted by the registry service after every mutation, so a publish, review decision or advisory shows at once.
{ "type": "RegistryPackage", "props": { "id": "RegistryPackage-1", "basePath": "/registry", "showReadme": true, "showVersions": true, "showChangelog": true, "showAgents": true, "notFoundText": "No package by that name." } }
RegistryStats (Marketing)
| Field | Type | Default |
|---|---|---|
title | text (optional heading) | "" |
showPublishers | radio: yes / no | true |
showDownloads | radio: yes / no | true |
The registry's totals as a row of figures: packages (linked to /registry), modules, themes and kits (linked to the kind filter), publishers and downloads, plus the date of the last publish. Placeholder off a registry host.
{ "type": "RegistryStats", "props": { "id": "RegistryStats-1", "title": "", "showPublishers": true, "showDownloads": true } }
ThemeGallery (Marketing)
| Field | Type | Default |
|---|---|---|
title | text | Themes |
intro | textarea | A theme is a kit of… |
limit | number: themes to show (1–60) | 12 |
columns | radio: 2, 3 | 3 |
showBrowseLink | radio: yes / no ("All n themes" when more exist) | true |
emptyText | text | No themes have been published yet. |
The theme directory: registry packages of kind theme (a kit manifest with "kind": "theme" and screenshots), most downloaded first, as cards with the first screenshot, title (the kit label), description, publisher, downloads, latest version and Preview / Details buttons. Preview points at the per-theme preview site when the registry renders one, otherwise at the package page (the current behavior). Screenshot URLs are /registry/v1/packages/<name>/<version>/files/<path>, the public endpoint that serves the manifest's declared screenshots (and the package documents) out of the stored tarball, cached immutable.
{ "type": "ThemeGallery", "props": { "id": "ThemeGallery-1", "title": "Themes", "intro": "", "limit": 12, "columns": 3, "showBrowseLink": true, "emptyText": "No themes yet." } }
PublisherProfile (Marketing)
| Field | Type | Default |
|---|---|---|
handle | text: a fixed publisher handle; empty reads it from the URL | "" |
basePath | text: the path the handle follows in the URL | /registry/publishers |
layout | radio: list, grid | grid |
notFoundText | text | No publisher by that handle. |
A publisher's public page: avatar initial, display name, @handle, publishing-since date, Verified publisher badge, website, package and download counts, and their packages as the same cards as RegistryBrowse. Put it on the catch-all page /registry/publishers/any (see RegistryPackage) or set handle for a page about one publisher.
{ "type": "PublisherProfile", "props": { "id": "PublisherProfile-1", "handle": "", "basePath": "/registry/publishers", "layout": "grid", "notFoundText": "No publisher by that handle." } }
OrgProfile (Marketing)
| Field | Type | Default |
|---|---|---|
handle | text: a fixed organization handle; empty reads it from the URL | "" |
basePath | text: the path the handle follows in the URL | /registry/orgs |
layout | radio: list, grid | grid |
showMembers | radio: true, false | true |
notFoundText | text | No organization by that handle. |
An organization's public page (registry.md, Organizations): avatar or initial, display name, @handle, Verified publisher badge when a reviewer verified it, website, the namespaces it owns, package, download and member counts, the members with their roles (linking their publisher pages) and the packages of every namespace it owns as the same cards as RegistryBrowse. Those cards, the package page and the theme gallery say "by <organization>" with a link here for any package whose namespace an organization owns, and carry the organization's verified badge. Put it on the catch-all page /registry/orgs/any (see RegistryPackage) or set handle for a page about one organization.
{ "type": "OrgProfile", "props": { "id": "OrgProfile-1", "handle": "", "basePath": "/registry/orgs", "layout": "grid", "showMembers": true, "notFoundText": "No organization by that handle." } }
ModuleBlock (Modules)
| Field | Type | Default |
|---|---|---|
module | the installed module's name (in the admin, a picker over the modules that offer blocks) | "" |
blockKey | the block key the module's manifest declares (a picker over that module's blocks) | "" |
props | object: the block's own props, with the fields the manifest declares | {} |
A block an installed module provides (modules.md, Blocks in the page builder). The server's resolver asks the module system for the HTML at render time and attaches it as html (sanitised with the rich text allow-list for a sandboxed module, the module's own markup for a trusted one); the block renders it inside <div class="sw-module-block sw-module-block--<module>-<key>" data-module data-block>. Pages are tagged module:<name> and evicted when the module is enabled, disabled, removed, reinstalled or its settings change. Without HTML (the module disabled, uninstalled, in safe mode, the key no longer declared, a failed render, or no block chosen yet) it shows a dashed placeholder naming the block and the reason, so a page never breaks because of a module. In the page builder the core fields are replaced by the module and block pickers plus the declared fields, and the canvas shows the server's rendering through POST /api/render/block. The prop is blockKey rather than key because React reserves key.
{ "type": "ModuleBlock", "props": { "id": "ModuleBlock-1", "module": "hello-banner", "blockKey": "notice", "props": { "text": "Open day on May 4", "href": "/open-day", "linkLabel": "Details", "tone": "info", "dismissible": false } } }
DocsFeedback (Marketing)
| Field | Type | Default |
|---|---|---|
formKey | text: the form that stores the answers | docs_feedback |
question | text | Was this page helpful? |
yesLabel, noLabel | text | Yes, No |
commentLabel | text | Tell us more (optional) |
thanksText | text | Thanks for the feedback. |
version | text: the documented version, recorded with every answer | empty |
"Was this page helpful?" at the end of a documentation page: two submit buttons (name="helpful", yes or no) and an optional comment, in a plain <form method="post" action="/api/forms/<formKey>/submit"> with the page path (page) and version as hidden fields, so it works without JavaScript (the server redirects back to the page with ?form=<key>&sent=1, which the block reads to show the thanks line in place of the question) and asks for no personal data. The forms module's honeypot (_hp), time-to-submit rule (_t) and rate limit apply like for any form. An inline script with the page's nonce posts the answer in place when fetch is available.
Before Render, core's resolver checks that the form exists on the site (otherwise the block renders a placeholder naming the key), adds the page path, the outcome of a non-JS submit and the CSP nonce, tags the page with form:<id> and marks it query-dependent on form, sent and error. The docs kit creates the docs_feedback form (fields helpful select, comment, hidden page and version) and places this block last on every doc page's layout; the Dashboard shows the answers of the last 30 days as the Docs feedback card (GET /api/forms/:id/feedback).
{ "type": "DocsFeedback", "props": { "id": "DocsFeedback-1", "formKey": "docs_feedback", "question": "Was this page helpful?", "yesLabel": "Yes", "noLabel": "No", "commentLabel": "Tell us more (optional)", "thanksText": "Thanks for the feedback.", "version": "0.1.0" } }
StatusBoard (Marketing)
| Field | Type | Default |
|---|---|---|
title | text (optional heading) | Status |
instances | list of { label, url }: a name and the instance's public URL (https://www.example.org; a trailing /status or /status.json is dropped) | empty |
showUptime | radio: yes / no | true |
Other Spillway instances' health, built for the project site's status page (spillwaycms.com/status). Before render, core's resolver (core/src/status/board.ts) fetches each instance's /status.json on the server through the SSRF-guarded outbound fetch with a 3 second timeout, and keeps each answer for 60 seconds. Every row shows the state (Up, Degraded with the instance's own flags, or Down with the reason: an HTTP error, no answer within 3 s, or an answer that is not a Spillway status), the version, the response time, when it was last checked, and a 90-day uptime bar: one cell per UTC day, colored by the worst check of the day, with the share of checks that got an answer. A summary line above the rows says how many instances are down or degraded. URLs that are not http(s), or that carry credentials, are skipped; at most 50 rows.
The bar's history is the status_check table, filled by the status probe step of the ticker (core/src/status/probe.ts): every five minutes it probes STATUS_PROBE_URLS plus every URL a published StatusBoard names, records one row per instance and deletes rows older than 90 days. The page is cached for a minute at most. A site that publishes this block on a page at /status replaces the instance's built-in status page there; /status.json stays the instance's own. See install.md, Status.
{ "type": "StatusBoard", "props": { "id": "StatusBoard-1", "title": "Status", "showUptime": true, "instances": [{ "label": "spillwaycms.com", "url": "https://spillwaycms.com" }, { "label": "Registry", "url": "https://registry.spillwaycms.org" }] } }
Root
The root has one field, title (text), and renders <main class="sw-page"> around all blocks. The admin's page builder copies a non-empty root title into the entity title when you save. The document <title> is set by the server from the entity's SEO title or title.
Adding a block
- Open
packages/blocks/src/config.tsx. - Add a props interface and add it to
BlockProps. - Add an entry to
config.componentswithlabel,fields(give every field alabel),defaultProps(so a freshly dropped block looks finished) andrender. - Add the block name to one of the
categorieslists. - Add an entry to
blockMetainsrc/meta.tswith a one-line description and anaiHint. - Add styles to
src/css.tsunder a.sw-<block>class. - For a block with styles: add it to
BLOCK_STYLED_CORE_BLOCKSin@spillway-cms/shared, declare its slots inblockStyleSlotsand its built-in styles (one default that reproduces the block's look) insrc/styles.ts, write its rules inblockStyleCss(every value of a slot writes the same properties), and addstyleClassFor('<Block>', style)to its class. - Run
pnpm --filter @spillway-cms/blocks build. This compiles TypeScript and regeneratessrc/styles.cssanddist/styles.cssfromblockCss;pnpm --filter @spillway-cms/blocks testruns the render tests.
Render functions must be server-safe:
- No React hooks, no
useState,useEffect, context, refs. - No
window,document,localStorage,fetch. - No client-only imports (no CSS modules, no component libraries that touch the DOM on import).
- Pure functions of their props: the same props always give the same HTML.
import typefor anything from@puckeditor/core; never import its runtime (it pulls in the editor).
If a block needs interactivity, render the markup on the server and add behavior with a small script the site loads separately, keyed on a data- attribute.
Server-resolved props: a block may need data the editor does not store (the Form's definition, a Collection's items). Register a resolver in core with registerBlockResolver('<Block>', async (props, env) => extraProps) from packages/core/src/render/resolve.ts; it runs for every block of that type at any depth before Render, and env.addTag() adds cache tags. The resolved key is not a Puck field, so read it in render through a cast (see src/form.tsx).
Slots: declare a field { type: 'slot' }. In render the prop arrives as a component; render it as <Content className="sw-column" />. Puck stores the children in the parent's props, so the stored page data is a tree.
CSS conventions
- Every class starts with
sw-. Block:.sw-<block>; element:.sw-<block>__<part>; modifier:.sw-<block>--<value>. - A block is a full-width
<section class="sw-block">; inside it,.sw-containercaps the width at--sw-max-width. Use.sw-container--narrowfor prose and.sw-container--widefor images. - Mobile first. Breakpoints are
640pxand900px. - Links inside a page use the primary color; buttons (
.sw-button) keep their own colors, since the page's link rule skips them. - Use the theme variables; never hard-code the brand color. Derived tokens (
--sw-color-muted,--sw-color-border,--sw-color-surface) are computed from the theme withcolor-mixso they follow it. - Links and buttons have a visible focus ring (
--sw-focus-ring). Keep it. - No external fonts, no images in the CSS, no JavaScript.
Theme CSS variables
The server sets these on :root from the site's theme tokens (Brand in the admin). The stylesheet declares fallbacks so it also works without them.
| Variable | Theme token | Default |
|---|---|---|
--sw-color-primary | colorPrimary | #1d4ed8 |
--sw-color-text | colorText | #111827 |
--sw-color-bg | colorBackground | #ffffff |
--sw-font-body | fontBody | system-ui stack |
--sw-font-heading | fontHeading | inherit |
--sw-radius | radius | 8px |
--sw-max-width | maxWidth | 1120px |
Choose a primary color with enough contrast against white; the primary button uses white text on it. The text color should pass 4.5:1 against the background.
Spillway 0.1.0 · Edit this page on GitHub