Add Wave 1 block server schemas (page builder step 3, server half)
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>
This commit is contained in:
@@ -4,22 +4,21 @@
|
||||
// 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.
|
||||
// Wave 1 block definitions are registered below, one require() per block (each
|
||||
// module self-registers on load). Requiring THIS module guarantees they are all
|
||||
// present before anything validates a page's blocks.
|
||||
|
||||
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)
|
||||
// ── Wave 1 block definitions (self-register on require) ────────────────
|
||||
require('./types/heading')
|
||||
require('./types/richText')
|
||||
require('./types/image')
|
||||
require('./types/twoColumn')
|
||||
require('./types/cta')
|
||||
require('./types/divider')
|
||||
require('./types/quote')
|
||||
|
||||
module.exports = {
|
||||
...registry,
|
||||
|
||||
84
server/src/blocks/propHelpers.js
Normal file
84
server/src/blocks/propHelpers.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// Small shared validators used by the Wave 1 block schemas. Each block's schema
|
||||
// composes these and returns a flat array of error strings; validateBlocks
|
||||
// prefixes each with the block path (so 'text is required' becomes
|
||||
// 'blocks[2].props.text is required'). Phrase messages to read well after that
|
||||
// prefix — start with the prop name.
|
||||
|
||||
/** @returns {boolean} true if v is a non-empty (after trim) string. */
|
||||
function isNonEmptyString(v) {
|
||||
return typeof v === 'string' && v.trim().length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a same-origin relative URL ("/uploads/x.png", "/wiki/foo") or an
|
||||
* absolute http/https URL. Rejects javascript:, data:, protocol-relative
|
||||
* ("//evil"), and anything else — the block renderers drop these into hrefs/src
|
||||
* so this is a security boundary, not just a format check.
|
||||
* @param {unknown} v
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isSafeUrl(v) {
|
||||
if (typeof v !== 'string' || v.trim() === '') return false
|
||||
const s = v.trim()
|
||||
if (s.startsWith('//')) return false // protocol-relative — ambiguous origin
|
||||
if (s.startsWith('/')) return true // same-origin relative
|
||||
try {
|
||||
const u = new URL(s)
|
||||
return u.protocol === 'http:' || u.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an enum validator for a prop.
|
||||
* @param {string} name prop name (for the message)
|
||||
* @param {string[]} allowed
|
||||
* @returns {(v: unknown) => string|null} error string or null
|
||||
*/
|
||||
function oneOf(name, allowed) {
|
||||
return (v) => (allowed.includes(v) ? null : `${name} must be one of ${allowed.join(', ')}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a required text prop: present, non-empty, within maxLen.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function requiredText(name, v, maxLen) {
|
||||
if (!isNonEmptyString(v)) return `${name} is required`
|
||||
if (v.length > maxLen) return `${name} must be at most ${maxLen} characters`
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an optional text prop: if present it must be a string within maxLen.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function optionalText(name, v, maxLen) {
|
||||
if (v === undefined || v === null || v === '') return null
|
||||
if (typeof v !== 'string') return `${name} must be a string`
|
||||
if (v.length > maxLen) return `${name} must be at most ${maxLen} characters`
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject any prop key not in `allowed`. Keeps a block's props tight so nothing
|
||||
* unexpected is smuggled through and stored.
|
||||
* @returns {string[]} error strings
|
||||
*/
|
||||
function onlyKeys(props, allowed) {
|
||||
const errors = []
|
||||
for (const key of Object.keys(props)) {
|
||||
if (!allowed.includes(key)) errors.push(`${key} is not an allowed prop`)
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isNonEmptyString,
|
||||
isSafeUrl,
|
||||
oneOf,
|
||||
requiredText,
|
||||
optionalText,
|
||||
onlyKeys,
|
||||
}
|
||||
@@ -12,6 +12,9 @@
|
||||
// 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).
|
||||
@@ -47,6 +50,9 @@ function registerBlock(def) {
|
||||
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`)
|
||||
@@ -55,6 +61,7 @@ function registerBlock(def) {
|
||||
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]),
|
||||
|
||||
22
server/src/blocks/types/cta.js
Normal file
22
server/src/blocks/types/cta.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// cta — a call-to-action button. `text` is the label, `url` the destination
|
||||
// (same-origin path or http/https), `style` picks primary/secondary appearance.
|
||||
const { registerBlock } = require('../registry')
|
||||
const { isSafeUrl, oneOf, requiredText, onlyKeys } = require('../propHelpers')
|
||||
|
||||
const STYLES = ['primary', 'secondary']
|
||||
const MAX_TEXT = 100
|
||||
|
||||
registerBlock({
|
||||
type: 'cta',
|
||||
version: 1,
|
||||
cacheTTL: null,
|
||||
schema(props) {
|
||||
const errors = onlyKeys(props, ['text', 'url', 'style'])
|
||||
const text = requiredText('text', props.text, MAX_TEXT)
|
||||
if (text) errors.push(text)
|
||||
if (!isSafeUrl(props.url)) errors.push('url must be a same-origin path or http(s) URL')
|
||||
const style = oneOf('style', STYLES)(props.style)
|
||||
if (style) errors.push(style)
|
||||
return errors
|
||||
},
|
||||
})
|
||||
12
server/src/blocks/types/divider.js
Normal file
12
server/src/blocks/types/divider.js
Normal file
@@ -0,0 +1,12 @@
|
||||
// divider — a pure spacer / horizontal rule. Carries no props.
|
||||
const { registerBlock } = require('../registry')
|
||||
const { onlyKeys } = require('../propHelpers')
|
||||
|
||||
registerBlock({
|
||||
type: 'divider',
|
||||
version: 1,
|
||||
cacheTTL: null,
|
||||
schema(props) {
|
||||
return onlyKeys(props, [])
|
||||
},
|
||||
})
|
||||
21
server/src/blocks/types/heading.js
Normal file
21
server/src/blocks/types/heading.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// heading — a section heading. `level` picks the tag (h1–h4), `text` is plain
|
||||
// text (the renderer escapes it; no HTML here — use rich_text for markup).
|
||||
const { registerBlock } = require('../registry')
|
||||
const { oneOf, requiredText, onlyKeys } = require('../propHelpers')
|
||||
|
||||
const LEVELS = ['h1', 'h2', 'h3', 'h4']
|
||||
const MAX_TEXT = 200
|
||||
|
||||
registerBlock({
|
||||
type: 'heading',
|
||||
version: 1,
|
||||
cacheTTL: null,
|
||||
schema(props) {
|
||||
const errors = onlyKeys(props, ['level', 'text'])
|
||||
const level = oneOf('level', LEVELS)(props.level)
|
||||
if (level) errors.push(level)
|
||||
const text = requiredText('text', props.text, MAX_TEXT)
|
||||
if (text) errors.push(text)
|
||||
return errors
|
||||
},
|
||||
})
|
||||
27
server/src/blocks/types/image.js
Normal file
27
server/src/blocks/types/image.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// image — a single image with optional caption. `src` must be a same-origin
|
||||
// upload path or an http/https URL (isSafeUrl); `alignment` controls layout.
|
||||
// Stays URL-based until the Wave 3 asset picker lands, then src swaps to an
|
||||
// asset id via a small migration.
|
||||
const { registerBlock } = require('../registry')
|
||||
const { isSafeUrl, oneOf, optionalText, onlyKeys } = require('../propHelpers')
|
||||
|
||||
const ALIGNMENTS = ['left', 'center', 'right', 'full']
|
||||
const MAX_ALT = 300
|
||||
const MAX_CAPTION = 500
|
||||
|
||||
registerBlock({
|
||||
type: 'image',
|
||||
version: 1,
|
||||
cacheTTL: null,
|
||||
schema(props) {
|
||||
const errors = onlyKeys(props, ['src', 'alt', 'caption', 'alignment'])
|
||||
if (!isSafeUrl(props.src)) errors.push('src must be a same-origin path or http(s) URL')
|
||||
const alt = optionalText('alt', props.alt, MAX_ALT)
|
||||
if (alt) errors.push(alt)
|
||||
const caption = optionalText('caption', props.caption, MAX_CAPTION)
|
||||
if (caption) errors.push(caption)
|
||||
const alignment = oneOf('alignment', ALIGNMENTS)(props.alignment)
|
||||
if (alignment) errors.push(alignment)
|
||||
return errors
|
||||
},
|
||||
})
|
||||
20
server/src/blocks/types/quote.js
Normal file
20
server/src/blocks/types/quote.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// quote — a pull quote with optional attribution.
|
||||
const { registerBlock } = require('../registry')
|
||||
const { requiredText, optionalText, onlyKeys } = require('../propHelpers')
|
||||
|
||||
const MAX_TEXT = 1000
|
||||
const MAX_ATTRIB = 200
|
||||
|
||||
registerBlock({
|
||||
type: 'quote',
|
||||
version: 1,
|
||||
cacheTTL: null,
|
||||
schema(props) {
|
||||
const errors = onlyKeys(props, ['text', 'attribution'])
|
||||
const text = requiredText('text', props.text, MAX_TEXT)
|
||||
if (text) errors.push(text)
|
||||
const attribution = optionalText('attribution', props.attribution, MAX_ATTRIB)
|
||||
if (attribution) errors.push(attribution)
|
||||
return errors
|
||||
},
|
||||
})
|
||||
26
server/src/blocks/types/richText.js
Normal file
26
server/src/blocks/types/richText.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// rich_text — a block of HTML authored in the shared rich-text editor. Validated
|
||||
// only for type/size here; the actual safety comes from `sanitize`, which runs
|
||||
// the html through the same allowlist (cleanBody) used for posts/wiki bodies, so
|
||||
// a direct API call can't smuggle unsafe markup past the editor.
|
||||
const { registerBlock } = require('../registry')
|
||||
const { onlyKeys } = require('../propHelpers')
|
||||
const { cleanBody } = require('../../utils/sanitizeHtml')
|
||||
|
||||
const MAX_HTML = 50000
|
||||
|
||||
registerBlock({
|
||||
type: 'rich_text',
|
||||
version: 1,
|
||||
cacheTTL: null,
|
||||
schema(props) {
|
||||
const errors = onlyKeys(props, ['html'])
|
||||
if (typeof props.html !== 'string') errors.push('html must be a string')
|
||||
else if (props.html.length > MAX_HTML) {
|
||||
errors.push(`html must be at most ${MAX_HTML} characters`)
|
||||
}
|
||||
return errors
|
||||
},
|
||||
sanitize(props) {
|
||||
return { ...props, html: cleanBody(props.html) }
|
||||
},
|
||||
})
|
||||
20
server/src/blocks/types/twoColumn.js
Normal file
20
server/src/blocks/types/twoColumn.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// two_column — the only container block. Holds two ordered arrays of sub-blocks
|
||||
// (`left`, `right`). The sub-block arrays are validated by validateBlocks, which
|
||||
// also enforces the one-level nesting cap (a column may not contain another
|
||||
// container). This schema only guards the prop shape; the slot arrays default to
|
||||
// empty when absent.
|
||||
const { registerBlock } = require('../registry')
|
||||
const { onlyKeys } = require('../propHelpers')
|
||||
|
||||
registerBlock({
|
||||
type: 'two_column',
|
||||
version: 1,
|
||||
cacheTTL: null,
|
||||
container: true,
|
||||
containerSlots: ['left', 'right'],
|
||||
schema(props) {
|
||||
// Slot array contents are validated by validateBlocks' container handling;
|
||||
// here we only reject stray props.
|
||||
return onlyKeys(props, ['left', 'right'])
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user