Appearance
Page sections (reference)
CMS pages (#673) get their content from sections — an ordered list of typed blocks (issue #674). This reference documents the data model, the type registry, the draft/publish semantics, and the ordering invariant. For the admin how-to, see managing-pages.md.
The model
A page is composed of rows in the page_sections table, each one block instance:
| Column | Meaning |
|---|---|
id | Opaque UUID. |
page_id | The owning page (pages.id, cascade-deleted with the page). |
type | A registered block type key (see the registry below). |
display_order | Dense 0-based position within the page (no gaps or duplicates). |
draft | The unpublished working content — a JSON map of the type's fields. |
published | The content served publicly, or NULL when the section is draft-only. |
A section's content is always a JSON map of named string fields — never freeform HTML or script. This extends the #548/#551 guardrail from single copy slots to composed sections: because the only field kinds are text and textarea, there is no write path that can inject markup into a page.
The section-type registry
The allowed block types are code-defined in $lib/section-registry.ts — the registry is the model, exactly like the page-content slot registry (#548). Only a type listed there exists, and each type declares its editable fields and their built-in defaults. Adding a block type is one entry there (plus its public render wiring, #675).
The v1 curated set:
| Type | Purpose | Fields (required ★) |
|---|---|---|
hero | Page-leading banner | heading ★, subheading, ctaLabel, ctaHref |
rich_text | A block of body copy | body ★ |
cta | A focused prompt with one button | heading ★, buttonLabel ★, buttonHref ★ |
image | A single image | imageUrl ★, alt ★, caption |
validateSectionContent(type, content) is the pure gate every write runs through: it rejects an unregistered type, an unknown field key (the curated-field guardrail), a non-string value, and a blank required field. Missing optional fields are fine.
Draft / publish
Each section carries its own draft and published content, mirroring the #548 page-content store but keyed per section instead of per (page, slot):
- Add creates the section with the type's default content as its draft and no published content — so a new block is draft-only and never served until published.
- Save draft (
updateSectionDraft) writes the working copy; it never changes what visitors see. - Publish (
publishSection) promotes the current draft topublished. Publishing with no draft is refused. - The public read (
listPublishedSections, used by the #675 renderer) returns only sections whosepublishedcontent is set, indisplay_order.
Ordering invariant
display_order is dense within a page — 0..n-1 with no gaps or duplicates. The write layer maintains this rather than trusting caller input:
- Add appends at position
n. - Remove deletes the row and re-densifies the remainder.
- Reorder (
reorderSections) accepts the full set of the page's section ids in the desired order, validates it is a clean permutation (validateReorder— same length, no missing, no extra, no duplicates), then rewritesdisplay_orderto0..n-1.
isDenseOrder(orders) is the pure predicate that expresses the invariant (used in tests).
Auditing
Every section mutation is written to the audit trail with entityType: 'section' — add (create), edit (update), publish, remove (delete), and reorder — so the composition history of a page is fully traceable.