Add pages table + block registry scaffold (page builder step 2)

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>
This commit is contained in:
2026-07-09 20:26:14 -05:00
parent 6180e8a071
commit fcef08e9b6
6 changed files with 384 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
// Client block registry entrypoint. Importing this module registers every
// browser-side block definition (renderer + editor + palette entry) exactly
// once, then re-exports the registry API. The page builder and the public page
// renderer should import from HERE, not ./registry, so the definitions are
// loaded before anything reads the registry.
//
// Wave 1 definitions are registered below as each block is built (spec build
// order step 3), one import per block.
export * from './registry'
// ── Wave 1 block definitions ──────────────────────────────────────────
// import './types/heading' // added in step 3
// import './types/richText'
// import './types/image'
// import './types/twoColumn'
// import './types/cta'
// import './types/divider'
// import './types/quote'

View File

@@ -0,0 +1,84 @@
// Block registry (client side) — mirrors the server registry
// (server/src/blocks/registry.js) but carries the browser-only concerns: the
// React renderer, the admin edit form, and the palette icon/label. The page
// builder's palette, drag-reorder canvas, per-block edit panel, and the public
// page renderer all read from this registry, so adding a block later is one
// entry here (plus its server-side schema entry) rather than edits scattered
// across the builder and renderer.
//
// A registered definition looks like:
// {
// type: 'heading', // must match the server registry type
// version: 1, // must match the server schema version
// label: 'Heading', // palette display name
// icon: 'heading', // palette icon key
// component: HeadingBlock, // renderer: (props) => JSX
// editor: HeadingEditor, // admin edit form: ({ props, onChange }) => JSX
// defaults: () => ({ ... }), // starting props when a block is added
// container: false, // true only for two_column
// containerSlots: [], // ['left','right'] for two_column
// }
//
// This module only defines the pattern; Wave 1 definitions register via
// ./index.js as each block is built (spec build order step 3).
const registry = new Map()
// Kept in sync with the server's RESERVED_KEYS — the only top-level keys on a
// stored block object. Exported so the builder can construct envelopes without
// hard-coding the shape.
export const RESERVED_KEYS = ['id', 'type', 'version', 'visible', 'props']
/**
* Register a block definition. Throws on a duplicate type — a programmer error
* caught at module load, not runtime.
* @param {object} def
* @returns {object} the stored definition
*/
export function registerBlock(def) {
if (!def || typeof def.type !== 'string' || def.type.length === 0) {
throw new Error('registerBlock: a block definition needs a string `type`')
}
if (registry.has(def.type)) {
throw new Error(`registerBlock: block type already registered: ${def.type}`)
}
const entry = {
type: def.type,
version: Number.isInteger(def.version) ? def.version : 1,
label: def.label || def.type,
icon: def.icon || null,
component: def.component || null,
editor: def.editor || null,
defaults: typeof def.defaults === 'function' ? def.defaults : () => ({}),
container: Boolean(def.container),
containerSlots: def.containerSlots ? [...def.containerSlots] : [],
}
registry.set(entry.type, entry)
return entry
}
/** @returns {object|null} the definition for `type`, or null if unknown. */
export function getBlock(type) {
return registry.get(type) || null
}
/** @returns {boolean} whether `type` is a registered block. */
export function hasBlock(type) {
return registry.has(type)
}
/** @returns {object[]} all registered definitions (registration order). */
export function listBlocks() {
return [...registry.values()]
}
/**
* Generate a stable block id. Called once when a block is added to the canvas;
* never derived from array position, so a reorder keeps ids intact (they are the
* React key and the future revision-history join point).
* @returns {string}
*/
export function makeBlockId() {
const rand = Math.random().toString(36).slice(2, 8).toUpperCase()
return `b_${rand}`
}