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:
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