Skip to content

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:

ColumnMeaning
idOpaque UUID.
page_idThe owning page (pages.id, cascade-deleted with the page).
typeA registered block type key (see the registry below).
display_orderDense 0-based position within the page (no gaps or duplicates).
draftThe unpublished working content — a JSON map of the type's fields.
publishedThe 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:

TypePurposeFields (required ★)
heroPage-leading bannerheading ★, subheading, ctaLabel, ctaHref
rich_textA block of body copybody ★
ctaA focused prompt with one buttonheading ★, buttonLabel ★, buttonHref ★
imageA single imageimageUrl ★, 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 to published. Publishing with no draft is refused.
  • The public read (listPublishedSections, used by the #675 renderer) returns only sections whose published content is set, in display_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 rewrites display_order to 0..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.