SpectreSkills
← All skills

cms-pages-and-templates

v1.0.0

Split CMS pages from the theme — a page is a workspace-scoped row (Content) and the theme supplies only the layout it renders through (Theme), with variant fallback to default so a theme swap never 404s a live URL. Trigger when adding pages, templates, or handles to a page builder, or when deciding whether something belongs in the bundle.

CMS & Page Builderscmspagestemplatescontent
Install
npx @spectre-apps/skills add cms-pages-and-templates

Writes .claude/skills/cms-pages-and-templates/SKILL.md. Add --user to install globally.

What it does

Pages and page templates

A page is a row, not a node in the bundle. This is the one place a Shopify-OS-2.0-style CMS diverges from "everything is in the theme", and it diverges deliberately: it is Shopify's own split, where a Page is Content and a page template is Theme.

pages table                     ThemeBundle.templates.page
  handle    "pricing"             default   { order, sections }
  title     "Pricing"             wide      { order, sections }
  visible   true                  all-sections  (sandbox, editor-only)
  templateVariant "wide" ────────────┘

templateVariant is the template_suffix analogue. Resolve it against whichever theme is live and fall back to default when that theme does not ship the layout. That fallback is the entire point. A page is workspace-wide and a theme is one design among several, so a page WILL meet themes that never heard of its layout. Falling back means the URL keeps working. Refusing to resolve lets a theme swap 404 a live page — the failure the split exists to remove.

One function decides it (resolvePageVariant), shared by the editor and the public renderer. A second copy is a theme swap that 404s a live URL.

Saving a page is publishing it

A theme version freezes the bundle. Nothing freezes a page row. The public site therefore serves a frozen design beside live page copy, and the two revalidate together — ping the renderer on every page write.

A rollback restores a design and leaves a month of copy edits alone. That asymmetry is the trade that buys pages their independence. Say so out loud in the UI ("saves immediately") so an operator is not surprised.

Consequences:

  • Hiding a page is the only way to draft one. Once anything is published, every visible page renders — through default if the live theme has nothing better.
  • Sections belong to a LAYOUT that pages share. "Open in Customizer" from a page screen edits something that may not be only this page's. Count the others, including pages that merely fall back to the layout, and say so on the CTA. A default that looks unused is not.
  • default can be edited, never renamed, never deleted. Every unassigned page renders there.
  • A dangling templateVariant is kept, not scrubbed. Show it as "not in the live theme" and render through default meanwhile. Nulling it on save forgets the operator's intent the moment the layout comes back.
  • Renaming a layout re-points page rows only in the LIVE theme. A draft's rename must not move rows the live theme also renders, or an unpublished edit changes the public site. Deleting a layout re-points nothing.

Metadata is a form; sections are the Customizer

Edit title, handle, description, SEO, visibility on a plain admin form. Edit sections only in the Customizer, on the layout. A second section editor on the page screen would need its own preview, its own undo, and its own reconciler calls, and the two would drift within a release.

  • One writer for every field. The handle is the page's address and every other screen keys the page by it, so a separate "rename" path lets a description save land on a page the operator already moved.
  • The description is a pair. Rich HTML is what the operator writes; plain text is its projection. The renderer needs a meta description on every request and the pages list needs a summary, and neither should be parsing HTML to get one. The rich half wins when both arrive; supplying HTML at all is an assertion (an empty one is a deliberate clear); emptiness is judged by the projection, because an emptied contentEditable is <p><br></p> and storing that leaves the field non-empty forever.
  • Empty SEO fields mean "follow the page", not "no listing". Title falls back to the page title plus the theme's suffix; description falls through the page's own description before the theme default. The admin draws a preview by calling the same two functions the renderer calls, so the preview cannot drift from what ships.
  • handle_taken is a unique index, not a key check in a JSON object. Two operators creating /pricing at the same moment used to both succeed.
  • new is a reserved handle. The static /pages/new segment wins over [handle], so a page handled new would be live on the public site and unreachable in the admin. The rule lives in the contract, because there are two writers and a guard in one is a guard the other forgets.
  • "New page" navigates rather than creates. A page's handle is a URL. Nothing is written until the form submits. New theme / new form can mint an untitled row; a page cannot.

Visibility and schedules

Absent visible normalizes to visible. Every page written before a gate existed was public; defaulting the other way takes a live site's pages down on deploy. An unparseable schedule normalizes to "no schedule" for the same reason — the failure that matters is the one that hides a live page.

A schedule is evaluated per request, not at generation time. Convert date + time inputs to an ISO instant in the browser; parsing wall-clock values on the server reads them in the container's zone and silently shifts the date. Nothing on the page screen may read new Date() during render — that is a hydration mismatch.

The sandbox is a template, not a page

A per-theme scratch surface that was a page would show up in the workspace's page list, visible in every theme and deletable from any of them. Make it a reserved template variant (all-sections) whose only address is a preview-gated route. Exclude it from every "in use" count — it holds one instance of every section type, so leaving it in lies.

What a page history costs

Layout rolls back with the theme. Page copy has no versions, so an overwritten title is gone. That is the known cost of taking pages out of the bundle. The fix is a page-versions table, not putting them back. See cms-theme-contract for why Content does not live in the bundle.