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:
@@ -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.
|
||||
|
||||
29
server/src/blocks/index.js
Normal file
29
server/src/blocks/index.js
Normal file
@@ -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,
|
||||
}
|
||||
96
server/src/blocks/registry.js
Normal file
96
server/src/blocks/registry.js
Normal 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,
|
||||
}
|
||||
119
server/src/blocks/validateBlocks.js
Normal file
119
server/src/blocks/validateBlocks.js
Normal file
@@ -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 }
|
||||
Reference in New Issue
Block a user