From fcef08e9b68b41efc83a86222c17636d278eee26 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 20:26:14 -0500 Subject: [PATCH] Add pages table + block registry scaffold (page builder step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/src/blocks/index.js | 19 +++++ client/src/blocks/registry.js | 84 ++++++++++++++++++++ server/db/schema.sql | 37 +++++++++ server/src/blocks/index.js | 29 +++++++ server/src/blocks/registry.js | 96 ++++++++++++++++++++++ server/src/blocks/validateBlocks.js | 119 ++++++++++++++++++++++++++++ 6 files changed, 384 insertions(+) create mode 100644 client/src/blocks/index.js create mode 100644 client/src/blocks/registry.js create mode 100644 server/src/blocks/index.js create mode 100644 server/src/blocks/registry.js create mode 100644 server/src/blocks/validateBlocks.js diff --git a/client/src/blocks/index.js b/client/src/blocks/index.js new file mode 100644 index 0000000..6d7f95d --- /dev/null +++ b/client/src/blocks/index.js @@ -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' diff --git a/client/src/blocks/registry.js b/client/src/blocks/registry.js new file mode 100644 index 0000000..e22ca0c --- /dev/null +++ b/client/src/blocks/registry.js @@ -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}` +} diff --git a/server/db/schema.sql b/server/db/schema.sql index 2d1407f..504260e 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -486,6 +486,43 @@ CREATE TABLE IF NOT EXISTS mod_notes ( INDEX idx_mod_notes_user (discord_user_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Generic CMS pages composed from a fixed palette of blocks (the page builder). +-- `blocks` is a JSON array of block-envelope objects ({ id, type, version, +-- visible, props }); it is stored as text and parsed/validated in app code +-- against the block registry (server/src/blocks) on every save — the same +-- pattern role_menus.mapping uses, since MariaDB's JSON type is just LONGTEXT and +-- the driver hands it back as a string anyway. The seo_*/og_image/canonical_url/ +-- robots and layout/nav_* columns are metadata/settings surfaced grouped in the +-- API response; several have no consumer yet but are cheap to add now and painful +-- to retrofit once real pages exist. published_at mirrors posts: stamped the first +-- time a page goes to 'published'. +CREATE TABLE IF NOT EXISTS pages ( + id INT AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(160) NOT NULL UNIQUE, + title VARCHAR(200) NOT NULL, + blocks MEDIUMTEXT NOT NULL, -- JSON array of block objects + status ENUM('draft','published') NOT NULL DEFAULT 'draft', + protected TINYINT(1) NOT NULL DEFAULT 0, + author_id INT NULL, + -- SEO / social metadata (grouped under `metadata` in the API response). + seo_title VARCHAR(200) NULL, + meta_description VARCHAR(400) NULL, + og_image VARCHAR(500) NULL, + canonical_url VARCHAR(500) NULL, + robots VARCHAR(100) NULL, + -- Presentation / navigation (grouped under `settings` in the API response). + layout ENUM('default','full_width','landing') NOT NULL DEFAULT 'default', + show_in_nav TINYINT(1) NOT NULL DEFAULT 0, + nav_group ENUM('main','footer','account','hidden') NULL, + nav_order INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + published_at DATETIME NULL, + CONSTRAINT fk_pages_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_pages_status (status), + INDEX idx_pages_nav (show_in_nav, nav_group, nav_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- Migrations for databases created before the wiki upgrade. Each statement uses -- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get -- these columns from the CREATE TABLE above; existing installs get them here. diff --git a/server/src/blocks/index.js b/server/src/blocks/index.js new file mode 100644 index 0000000..ac07161 --- /dev/null +++ b/server/src/blocks/index.js @@ -0,0 +1,29 @@ +// Block registry entrypoint. Requiring this module registers every server-side +// block definition (schema + cache policy) exactly once, then re-exports the +// registry API and the blocks validator. Anything that needs to validate a +// page's blocks or look up a block type should require THIS module, not +// ./registry directly, so the definitions are guaranteed to be loaded. +// +// Wave 1 block definitions are registered below, one require() per block, as +// they are built (spec build order step 3). Until then the registry is empty and +// validateBlocks rejects any block type — which is correct: no page can save a +// block that has no server-side schema yet. + +const registry = require('./registry') +const { validateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS } = require('./validateBlocks') + +// ── Wave 1 block definitions ────────────────────────────────────────── +// require('./types/heading').register(registry) // added in step 3 +// require('./types/richText').register(registry) +// require('./types/image').register(registry) +// require('./types/twoColumn').register(registry) +// require('./types/cta').register(registry) +// require('./types/divider').register(registry) +// require('./types/quote').register(registry) + +module.exports = { + ...registry, + validateBlocks, + MAX_BLOCKS, + MAX_SUBBLOCKS, +} diff --git a/server/src/blocks/registry.js b/server/src/blocks/registry.js new file mode 100644 index 0000000..d85c809 --- /dev/null +++ b/server/src/blocks/registry.js @@ -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, +} diff --git a/server/src/blocks/validateBlocks.js b/server/src/blocks/validateBlocks.js new file mode 100644 index 0000000..db4dcff --- /dev/null +++ b/server/src/blocks/validateBlocks.js @@ -0,0 +1,119 @@ +// Server-side validation for a page's `blocks` array, run on every save before +// persisting. The admin UI validates client-side too, but that can be bypassed +// by a direct API call, so this is the authoritative gate: it enforces the block +// envelope (reserved keys only), that every `type` is a registered block, that +// each block's props satisfy the registry schema, and the one-level nesting cap +// (only container blocks may hold sub-blocks, and sub-blocks may not themselves +// be containers). +// +// Returns { valid, errors } — a flat list of human-readable error strings, each +// prefixed with the path to the offending block (e.g. `blocks[2].props.text`). +// It never throws on bad input; callers turn a non-empty `errors` into a 400. + +const { getBlock, RESERVED_KEYS } = require('./registry') + +// Bound the payload so a single page can't carry an unreasonable block tree. +const MAX_BLOCKS = 100 // top-level blocks per page +const MAX_SUBBLOCKS = 50 // sub-blocks per container slot +const ID_RE = /^[A-Za-z0-9_-]{1,40}$/ + +/** + * Validate a stored blocks array against the registry. + * @param {unknown} blocks + * @returns {{ valid: boolean, errors: string[] }} + */ +function validateBlocks(blocks) { + const errors = [] + if (!Array.isArray(blocks)) { + return { valid: false, errors: ['blocks must be an array'] } + } + if (blocks.length > MAX_BLOCKS) { + errors.push(`blocks may not exceed ${MAX_BLOCKS} top-level entries`) + } + const seenIds = new Set() + blocks.forEach((block, i) => { + validateBlock(block, `blocks[${i}]`, seenIds, errors, { nested: false }) + }) + return { valid: errors.length === 0, errors } +} + +/** + * Validate one block envelope in place. `nested` = true when validating a + * sub-block inside a container slot, which forbids further nesting. + */ +function validateBlock(block, path, seenIds, errors, { nested }) { + if (block === null || typeof block !== 'object' || Array.isArray(block)) { + errors.push(`${path} must be an object`) + return + } + + // Envelope: only the reserved keys, nothing smuggled at the top level. + for (const key of Object.keys(block)) { + if (!RESERVED_KEYS.includes(key)) { + errors.push(`${path}.${key} is not an allowed top-level key`) + } + } + + // id — stable, unique across the whole page (top-level and nested share one + // namespace since ids are the future join point for revision history). + if (typeof block.id !== 'string' || !ID_RE.test(block.id)) { + errors.push(`${path}.id must be a short id string`) + } else if (seenIds.has(block.id)) { + errors.push(`${path}.id duplicates another block id (${block.id})`) + } else { + seenIds.add(block.id) + } + + // visible — optional in input, but if present must be a boolean. + if (block.visible !== undefined && typeof block.visible !== 'boolean') { + errors.push(`${path}.visible must be a boolean`) + } + + // props — always an object bag. + const props = block.props + if (props === null || typeof props !== 'object' || Array.isArray(props)) { + errors.push(`${path}.props must be an object`) + } + + // type — must resolve to a registered block. + const def = typeof block.type === 'string' ? getBlock(block.type) : null + if (!def) { + errors.push(`${path}.type is not a registered block type (${String(block.type)})`) + return // can't validate props or nesting without a definition + } + + // Per-block prop schema from the registry. + if (def.schema && props && typeof props === 'object') { + let schemaErrors = [] + try { + schemaErrors = def.schema(props) || [] + } catch (err) { + schemaErrors = [`schema threw: ${err.message}`] + } + for (const e of schemaErrors) errors.push(`${path}.props.${e}`) + } + + // Nesting: only container blocks may hold sub-blocks, capped at one level. + if (def.container) { + if (nested) { + errors.push(`${path} is a container and may not be nested inside another container`) + return + } + for (const slot of def.containerSlots) { + const sub = props ? props[slot] : undefined + if (sub === undefined) continue // an empty slot is allowed + if (!Array.isArray(sub)) { + errors.push(`${path}.props.${slot} must be an array of blocks`) + continue + } + if (sub.length > MAX_SUBBLOCKS) { + errors.push(`${path}.props.${slot} may not exceed ${MAX_SUBBLOCKS} blocks`) + } + sub.forEach((child, j) => { + validateBlock(child, `${path}.props.${slot}[${j}]`, seenIds, errors, { nested: true }) + }) + } + } +} + +module.exports = { validateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS }