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>
85 lines
2.6 KiB
JavaScript
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,
|
|
}
|