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>
120 lines
4.5 KiB
JavaScript
120 lines
4.5 KiB
JavaScript
// 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 }
|