Register all seven Wave 1 block types with their server-side validation schemas, self-registering via server/src/blocks/types/*: heading, rich_text, image, two_column (container), cta, divider, quote. - propHelpers.js: shared validators (isSafeUrl rejects javascript:/data:/ protocol-relative, enum/required/optional text, strict key allowlist). - rich_text carries a `sanitize` normalizer (registry now supports it) that runs html through the shared cleanBody allowlist on save. - Registry entrypoint requires the type modules so all schemas load. Verified: all 7 register; valid blocks pass; malformed props yield precise per-path errors; one-level nesting cap enforced; rich_text sanitize strips script/onerror. Client renderers + editors (step 3 client half) still to come. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
104 lines
4.2 KiB
JavaScript
104 lines
4.2 KiB
JavaScript
// 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,
|
|
}
|