// 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) // sanitize: (props) => props, // optional normalizer run on save AFTER // // validation, e.g. rich_text runs its html through // // the shared allowlist; returns cleaned props // 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`) } if (def.sanitize != null && typeof def.sanitize !== 'function') { throw new Error(`registerBlock: ${def.type}.sanitize 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, sanitize: def.sanitize || 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, }