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,96 @@
// Block registry (server side) — the single source of truth for what block
// types exist, how their props validate, and how long a rendered block may be
// cached. The admin builder UI, the public renderer, and this server-side
// validation are all driven from a registry entry rather than a switch statement
// scattered across files: adding a block later means adding ONE entry (here on
// the server for schema/cache, and one in client/src/blocks for the React
// renderer/editor), not editing four places.
//
// A registered definition looks like:
// {
// type: 'heading', // stable string id, unique across the registry
// version: 1, // prop-schema version; bump when props change so a
// // one-time migration can transform older blocks
// schema: (props) => [], // returns an array of error strings ([] = valid)
// cacheTTL: null, // seconds a rendered instance may be cached;
// // null = never cache (static blocks). Dynamic
// // Wave 2 blocks set this (e.g. server_status: 10).
// container: false, // true only for block types that hold sub-blocks
// containerSlots: [], // prop keys holding sub-block arrays, e.g.
// // ['left','right'] for two_column
// }
//
// This module is intentionally empty of block types — it only defines the
// pattern. Wave 1 block definitions register themselves via ./index.js.
// The only keys allowed at the top level of a stored block object. Everything
// block-specific lives inside `props`; nothing else lives at the top level.
// Ordering is the array position, not a stored field — so a reorder is just a
// reorder of the array, and `id` is never derived from position.
const RESERVED_KEYS = Object.freeze(['id', 'type', 'version', 'visible', 'props'])
const registry = new Map()
/**
* Register a block definition. Throws on a missing type or a duplicate — both
* are programmer errors surfaced at boot, not runtime input.
* @param {object} def
* @returns {object} the normalized, frozen definition
*/
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}`)
}
if (def.schema != null && typeof def.schema !== 'function') {
throw new Error(`registerBlock: ${def.type}.schema must be a function`)
}
const containerSlots = def.containerSlots || []
if (def.container && containerSlots.length === 0) {
throw new Error(`registerBlock: container block ${def.type} needs containerSlots`)
}
const entry = Object.freeze({
type: def.type,
version: Number.isInteger(def.version) ? def.version : 1,
schema: def.schema || null,
cacheTTL: def.cacheTTL == null ? null : Number(def.cacheTTL),
container: Boolean(def.container),
containerSlots: Object.freeze([...containerSlots]),
})
registry.set(entry.type, entry)
return entry
}
/** @returns {object|null} the definition for `type`, or null if unknown. */
function getBlock(type) {
return registry.get(type) || null
}
/** @returns {boolean} whether `type` is a registered block. */
function hasBlock(type) {
return registry.has(type)
}
/** @returns {object[]} all registered definitions (registration order). */
function listBlocks() {
return [...registry.values()]
}
/**
* Drop every registered block. Test-only — lets a suite register a fixture set
* and start from a known-empty registry.
*/
function _resetRegistry() {
registry.clear()
}
module.exports = {
RESERVED_KEYS,
registerBlock,
getBlock,
hasBlock,
listBlocks,
_resetRegistry,
}