// ── The `email.*` block registry ─────────────────────────────────────────── // // ENGAGEMENT.md §4.4. A sibling of `blocks/registry.js`, not an extension of it, // settled with the org lead at the start of Phase 5a. Three reasons, in order of // how much they cost if ignored: // // 1. **These blocks render on the SERVER.** Page blocks do not: `blocks/` carries // `schema` / `sanitize` / `cacheTTL` and the actual drawing happens in React // (`client/src/blocks/BlockRenderer.jsx`). Mail has no React — a message body // is a string this process produces — so an email definition carries `toHtml` // and `toText`. `registerBlock` freezes a fixed field set and would silently // DROP both. // 2. **One registry would be one namespace.** `blocks/validateBlocks.js`'s only // server consumer is `pages.model.js`; registering `email.heading` into that // Map makes a CMS page containing an email block validate and save, and the // client renderer has nothing to draw for it. // 3. The two entry shapes genuinely differ: `cacheTTL` and `container` mean // nothing to a mail body, and a renderer means nothing to a cached page block. // // What IS shared is everything that is the same rule for both, and it is shared by // binding rather than by copy: `propHelpers`, the envelope/id/nesting walk // (`makeValidateBlocks`) and the validate-then-sanitize order (`makeSanitizeBlocks`). // §4.4's "do not build a second editor" is honoured where it is about the editor — // Phase 5b drives these through the existing block/prop-panel machinery. // // A registered definition looks like: // { // type: 'email.heading', // version: 1, // schema: (props) => [], // error strings ([] = valid) // sanitize: (props) => props, // optional, run on save AFTER validation // toHtml: (props, ctx) => '…', // a table ROW; see render.js for the shell // toText: (props, ctx) => 'text', // '' means "contributes nothing" // variables: (props) => [], // optional; see below // } // // `variables` exists because of ONE block, and the exception is the reason it has // to be declared rather than inferred. Every other block references a declared // variable the same way a person writes it — as a `{{token}}` inside an authored // string — so scanning the string props finds them all. `email.itemList` does not: // its `variable` prop holds a BARE NAME (`items`), because the block iterates the // value rather than interpolating it. A save-time check that only scanned tokens // would pass a template pointing its one repeating block at a variable no trigger // declares, and the failure would surface as an empty digest in someone's inbox. // A block that reads a variable by any means other than a token says so here. // // `ctx` is the render context (render.js): resolved brand values, an `interp` // that substitutes declared variables HTML-escaped, and `interpText` that does // the same without escaping for the plain-text part. const registry = new Map() // Same envelope as a page block — deliberately the same constant list, because // the shared validator enforces it and the two must not diverge. const { RESERVED_KEYS } = require('../blocks/registry') /** * Register an email block definition. Throws on a missing type, a duplicate, or a * missing renderer — all three are programmer errors surfaced at boot. * @param {object} def * @returns {object} the normalized, frozen definition */ function registerEmailBlock(def) { if (!def || typeof def.type !== 'string' || def.type.length === 0) { throw new Error('registerEmailBlock: a block definition needs a string `type`') } if (!def.type.startsWith('email.')) { // The prefix is not needed to disambiguate — this is its own Map — but a // stored blocks array should say what it is when someone reads the row. throw new Error(`registerEmailBlock: ${def.type} must be namespaced "email."`) } if (registry.has(def.type)) { throw new Error(`registerEmailBlock: block type already registered: ${def.type}`) } if (typeof def.toHtml !== 'function' || typeof def.toText !== 'function') { // §4.4: "Every block type gets a toText(props) alongside its renderer, so a // text part always exists." A block that can only produce HTML would make a // published template's text part depend on which blocks it happened to use. throw new Error(`registerEmailBlock: ${def.type} needs both toHtml and toText`) } if (def.schema != null && typeof def.schema !== 'function') { throw new Error(`registerEmailBlock: ${def.type}.schema must be a function`) } if (def.sanitize != null && typeof def.sanitize !== 'function') { throw new Error(`registerEmailBlock: ${def.type}.sanitize must be a function`) } if (def.variables != null && typeof def.variables !== 'function') { throw new Error(`registerEmailBlock: ${def.type}.variables must be a function`) } const entry = Object.freeze({ type: def.type, label: def.label || def.type, version: Number.isInteger(def.version) ? def.version : 1, schema: def.schema || null, sanitize: def.sanitize || null, toHtml: def.toHtml, toText: def.toText, // Null, not a default `() => []`: `variables.js` distinguishes "this block // declares no non-token references" from "this block was never asked", and // only the second is worth a comment when a new block type is added. variables: def.variables || null, // The shared walk reads these; email has no containers, and saying so here is // what lets `makeValidateBlocks` be the same function for both families. container: false, containerSlots: Object.freeze([]), }) registry.set(entry.type, entry) return entry } /** @returns {object|null} the definition for `type`, or null if unknown. */ function getEmailBlock(type) { return registry.get(type) || null } /** @returns {boolean} whether `type` is a registered email block. */ function hasEmailBlock(type) { return registry.has(type) } /** @returns {object[]} all registered definitions (registration order). */ function listEmailBlocks() { return [...registry.values()] } /** Drop every registered block. Test-only. */ function _resetRegistry() { registry.clear() } module.exports = { RESERVED_KEYS, registerEmailBlock, getEmailBlock, hasEmailBlock, listEmailBlocks, _resetRegistry, }