CMS Page Builder (Wave 1): block-based Pages content type #47

Merged
whitlocktech merged 7 commits from feature/cms-page-builder into main 2026-07-10 02:15:06 +00:00
Member

Adds a generic block-based Pages content type — non-technical admins compose pages from a fixed palette of blocks instead of freeform HTML. This is Wave 1 of the spec (build-order steps 1–8); Wave 2 (dynamic blocks) and Wave 3 (revisions, asset picker) are deferred as separate work.

What's included

1. Rich-text alignment — left/center/right controls in the shared RichTextEditor (@tiptap/extension-text-align), stored as text-align on the block node. Server sanitize allowlist updated to permit only text-align via style on p/h1h6.

2. pages table + block registry — new pages table (blocks JSON-as-text, grouped SEO metadata + layout/nav settings columns, published_at). Block registry scaffold on both sides driving renderer/editor/palette/validation from one entry each.

3. Wave 1 blocksheading, rich_text, image, two_column (container, holds ordered arrays of leaf sub-blocks per side), cta, divider, quote. Each has a server validation schema + client renderer/editor. rich_text is sanitized on save via the shared cleanBody allowlist.

4. Save-time validation — every create/update validates blocks against the registry (envelope = 5 reserved keys, registered-type, per-block schema, one-level nesting cap) and sanitizes them. Authoritative server gate; a direct API call can't bypass it.

5. Admin builder UIPagesAdmin list + PageBuilder canvas: block palette, per-block editor cards with show/hide, up/down + native drag reorder, Content/Settings tabs, SEO + layout/nav panels, publish/unpublish, protect + password-gated unprotect modal, draft preview, delete.

6. Routing — public catch-all /:slug (matched after all named routes) + reserved-slug list guarding named routes/API namespaces; /preview/:id/:token outside the maintenance gate.

7. Protected pagesprotected can be set true via normal PATCH but only cleared via POST /pages/:id/unprotect, which re-verifies the current admin's password server-side (independent of JWT, never logged). Protected pages can't be deleted. Audit-logged.

8. Draft previewPOST /pages/:id/preview mints a 1h token; GET /public/pages/:id/preview/:token renders the current (possibly unpublished) state, ungated.

All endpoints live under the existing /api/v1 (admin + public routers) and are added to Swagger.

API surface (all /api/v1)

  • GET/POST /admin/pages, GET/PATCH/DELETE /admin/pages/:id
  • POST /admin/pages/:id/unprotect, POST /admin/pages/:id/preview
  • GET /public/pages/:slug, GET /public/pages/:id/preview/:token

Verification

  • DB model integration test: sanitize strips scripts, invalid blocks rejected with per-path detail, published_at stamped on first publish, slug immutable, reserved/duplicate slugs rejected, protected→delete blocked, unprotect-via-PATCH blocked.
  • Authenticated HTTP smoke through the real routes: create 201, invalid-blocks 400 (+details), publish, public slug fetch, preview mint + tokenized fetch, delete-protected 403, wrong-password unprotect 401.
  • Browser: a published page rendered all 7 block types (incl. center-aligned rich_text and a two-column layout with multiple sub-blocks per side); /preview/:id/:token showed the draft banner and rendered via token with no session. Test pages cleaned up.

Client builds clean (216 modules). The admin builder canvas itself was not visually verified in-session (no admin session in the test browser; passwords aren't entered by the agent) — it compiles clean and reuses the already-proven block editors + endpoints.

Notes

  • Endpoints reuse /api/v1 (not a new v2) and block order is array position — both confirmed with @whitlocktech.
  • nav_group / show_in_nav are stored but not yet consumed by any site nav (per spec).

🤖 Generated with Claude Code

Adds a generic block-based **Pages** content type — non-technical admins compose pages from a fixed palette of blocks instead of freeform HTML. This is **Wave 1** of the spec (build-order steps 1–8); Wave 2 (dynamic blocks) and Wave 3 (revisions, asset picker) are deferred as separate work. ## What's included **1. Rich-text alignment** — left/center/right controls in the shared `RichTextEditor` (`@tiptap/extension-text-align`), stored as `text-align` on the block node. Server sanitize allowlist updated to permit only `text-align` via `style` on `p`/`h1`–`h6`. **2. `pages` table + block registry** — new `pages` table (blocks JSON-as-text, grouped SEO metadata + layout/nav settings columns, `published_at`). Block registry scaffold on both sides driving renderer/editor/palette/validation from one entry each. **3. Wave 1 blocks** — `heading`, `rich_text`, `image`, `two_column` (container, holds ordered arrays of leaf sub-blocks per side), `cta`, `divider`, `quote`. Each has a server validation schema + client renderer/editor. `rich_text` is sanitized on save via the shared `cleanBody` allowlist. **4. Save-time validation** — every create/update validates blocks against the registry (envelope = 5 reserved keys, registered-type, per-block schema, one-level nesting cap) and sanitizes them. Authoritative server gate; a direct API call can't bypass it. **5. Admin builder UI** — `PagesAdmin` list + `PageBuilder` canvas: block palette, per-block editor cards with show/hide, up/down + native drag reorder, Content/Settings tabs, SEO + layout/nav panels, publish/unpublish, protect + password-gated unprotect modal, draft preview, delete. **6. Routing** — public catch-all `/:slug` (matched after all named routes) + reserved-slug list guarding named routes/API namespaces; `/preview/:id/:token` outside the maintenance gate. **7. Protected pages** — `protected` can be set true via normal PATCH but only cleared via `POST /pages/:id/unprotect`, which re-verifies the current admin's password server-side (independent of JWT, never logged). Protected pages can't be deleted. Audit-logged. **8. Draft preview** — `POST /pages/:id/preview` mints a 1h token; `GET /public/pages/:id/preview/:token` renders the current (possibly unpublished) state, ungated. All endpoints live under the existing `/api/v1` (admin + public routers) and are added to Swagger. ## API surface (all `/api/v1`) - `GET/POST /admin/pages`, `GET/PATCH/DELETE /admin/pages/:id` - `POST /admin/pages/:id/unprotect`, `POST /admin/pages/:id/preview` - `GET /public/pages/:slug`, `GET /public/pages/:id/preview/:token` ## Verification - **DB model integration test**: sanitize strips scripts, invalid blocks rejected with per-path detail, `published_at` stamped on first publish, slug immutable, reserved/duplicate slugs rejected, protected→delete blocked, unprotect-via-PATCH blocked. - **Authenticated HTTP smoke** through the real routes: create 201, invalid-blocks 400 (+details), publish, public slug fetch, preview mint + tokenized fetch, delete-protected 403, wrong-password unprotect 401. - **Browser**: a published page rendered all 7 block types (incl. center-aligned rich_text and a two-column layout with multiple sub-blocks per side); `/preview/:id/:token` showed the draft banner and rendered via token with no session. Test pages cleaned up. Client builds clean (216 modules). The admin builder canvas itself was not visually verified in-session (no admin session in the test browser; passwords aren't entered by the agent) — it compiles clean and reuses the already-proven block editors + endpoints. ## Notes - Endpoints reuse `/api/v1` (not a new `v2`) and block order is array position — both confirmed with @whitlocktech. - `nav_group` / `show_in_nav` are stored but not yet consumed by any site nav (per spec). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 7 commits 2026-07-10 02:05:23 +00:00
Shared RichTextEditor gains @tiptap/extension-text-align for heading and
paragraph nodes, serializing alignment as inline text-align on the block
node so it round-trips through save/reload. Fixed once at the shared
component so it also flows into the upcoming rich_text and two_column
page blocks.

Server sanitize allowlist now permits `style` on p/h1-h6, constrained by
allowedStyles to text-align (left/right/center/justify) only; all other
CSS properties and values are stripped.

Step 1 of the CMS Page Builder spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New `pages` table: slug/title/blocks(JSON-as-text)/status/protected, author
FK, grouped SEO metadata + layout/nav settings columns (added up front per
spec — cheap now, painful to retrofit), published_at mirroring posts.

Block registry scaffold, server and client, defining the pattern without
any block types yet (Wave 1 lands in step 3):
- server/src/blocks: registry (register/get/list, reserved envelope keys,
  container metadata) + validateBlocks (authoritative save-time gate:
  envelope, registered-type, per-block schema, one-level nesting cap) +
  index entrypoint that will register Wave 1 defs.
- client/src/blocks: mirror registry carrying renderer/editor/palette +
  makeBlockId, plus index entrypoint.

Verified: schema applies idempotently against the dev DB (pages table +
indexes present); validator exercised for empty/non-array/unknown-type/
bad-envelope/duplicate-id/nested-container cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Register all seven Wave 1 block types with their server-side validation
schemas, self-registering via server/src/blocks/types/*:
heading, rich_text, image, two_column (container), cta, divider, quote.

- propHelpers.js: shared validators (isSafeUrl rejects javascript:/data:/
  protocol-relative, enum/required/optional text, strict key allowlist).
- rich_text carries a `sanitize` normalizer (registry now supports it) that
  runs html through the shared cleanBody allowlist on save.
- Registry entrypoint requires the type modules so all schemas load.

Verified: all 7 register; valid blocks pass; malformed props yield precise
per-path errors; one-level nesting cap enforced; rich_text sanitize strips
script/onerror.

Client renderers + editors (step 3 client half) still to come.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Client block registry now carries a renderer, edit form, palette label/icon,
and defaults for all seven Wave 1 blocks (self-registering via
client/src/blocks/types/*): heading, rich_text, image, two_column, cta,
divider, quote.

- BlockRenderer + BlockList render stored blocks via the registry (respect
  `visible`, tolerate unknown types), reading getBlock from ./registry to
  avoid the index -> twoColumn -> BlockRenderer cycle.
- editorKit: shared Field/TextField/TextAreaField/SelectField styled with the
  existing admin form classes; rich_text editor reuses RichTextEditor
  (variant post), image editor reuses the shared uploader.
- two_column editor is a mini per-column canvas (add from the leaf-only
  palette, edit via each block's registry editor, reorder, remove).
- theme.css: public block styles (heading/image alignment/cta/quote/
  two-column responsive grid) + column sub-block editor styles.

Verified: all 11 modules transform cleanly under esbuild. Full visual
verification comes with the builder UI (step 5) + public route (step 6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Backend for the CMS page builder, all under the existing /api/v1:

- pages.model: authoritative save gate — validates blocks against the
  registry and sanitizes them on every create/update; maps rows to/from the
  grouped API shape (metadata / settings); slug validated + reserved-checked
  at create and immutable after; `protected` can be set true via PATCH but
  only cleared via the unprotect path; published_at stamped on first publish.
- sanitizeBlocks: post-validation normalizer (applies each block's sanitize,
  stamps version, defaults visible, recurses container slots).
- reservedSlugs: guards page slugs from shadowing named routes/API namespaces.
- Admin routes (staff-gated): GET/POST /pages, GET/PATCH/DELETE /pages/:id,
  POST /pages/:id/unprotect (password step-up, verified against the caller's
  own hash, never logged), POST /pages/:id/preview (1h token). Audit-logs
  create/publish/unpublish/protect/unprotect/delete.
- Public routes: GET /public/pages/:slug (published; staff see drafts; site-
  mode gated) and GET /public/pages/:id/preview/:token (ungated, token is the
  access control). Preview token primitives added to auth/token.js.
- Swagger annotations for all new endpoints.

Verified end-to-end: model integration test against the dev DB (sanitize,
invalid-block rejection, slug immutability, protected/unprotect, dup/reserved
slug, published_at) + authenticated HTTP smoke (201 create, 400 invalid
blocks, publish, public slug fetch, preview mint+fetch, 403 delete-protected,
401 wrong-password unprotect).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- PagesAdmin: list view of pages (title/slug/status/protected/updated) with
  new/edit navigation and a View link to the live page.
- PageBuilder: full-page block canvas — palette (adds any registered block),
  per-block editor cards with show/hide, up/down + native drag reorder, and
  remove; Content / Settings tabs; SEO metadata + layout/nav settings panels;
  publish/unpublish; protect (PATCH) and password-gated unprotect (modal);
  draft preview (mints a token, opens /preview/:id/:token); delete (blocked
  while protected). Surfaces server block-validation details on save.
- CmsPage: public renderer for /:slug (published; staff see drafts) and the
  token-gated /preview/:id/:token, rendering blocks via BlockList and
  reflecting the page title/meta.
- Routing: /:slug catch-all after all named routes + /preview/:id/:token
  outside the maintenance gate; admin /admin/pages, /pages/new, /pages/:id.
- api client: public page/pagePreview + admin pages CRUD/unprotect/preview.
- AdminLayout: "Pages" nav entry (Content group) with icon.
- theme.css: builder canvas + preview-banner + shell-wide styles.

Client builds clean (216 modules).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
swagger-output.json now documents GET/POST /admin/pages, GET/PATCH/DELETE
/admin/pages/:id, POST /admin/pages/:id/{unprotect,preview}, and the public
GET /public/pages/:slug + /public/pages/:id/preview/:token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
whitlocktech approved these changes 2026-07-10 02:14:57 +00:00
whitlocktech merged commit d49008e9f2 into main 2026-07-10 02:15:06 +00:00
whitlocktech deleted branch feature/cms-page-builder 2026-07-10 02:15:07 +00:00
Sign in to join this conversation.
No description provided.