Files
website/server/src/blocks/propHelpers.js
Claude 6d31869ba2 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>
2026-07-09 20:30:58 -05:00

85 lines
2.6 KiB
JavaScript

// 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,
}