// ── Which declared variables a template references ───────────────────────── // // ENGAGEMENT.md §4.6.2: "A template referencing an undeclared variable is refused // at save, naming the variable — the editor validates, it does not blindly // interpolate module JSON." // // This is the walk that makes that sentence enforceable. It is deliberately a // SEPARATE pass from rendering: a render only discovers a bad reference when a // value happens to be missing at that moment, which makes the failure depend on // the event rather than on the template. Phase 5a's `renderTemplate` already // reports `missing` for exactly that runtime case; this answers the static // question — what does this template ask for at all — and it can therefore refuse // a save before any mail exists. // // Two kinds of reference, and both have to be found or the check is theatre: // // - **Tokens** in every authored string: the subject, an overriding text part, // and every string-valued prop on every block. `scanTokens` finds these. // - **Named references** a block declares (`registry.js`'s `variables`), which // today is `email.itemList.variable` and its bare `items`. A token scan cannot // see these and would pass them silently. // // The block walk mirrors `makeValidateBlocks`' — top level plus container slots — // rather than sharing it, because that function's job is to decide validity and // this one's is to collect names from a structure already known to be valid. The // email family has no containers today; the slot arm exists so that adding one // does not quietly halve this function's coverage. const { scanTokens } = require('./interpolate') const { getEmailBlock } = require('./registry') /** Every distinct token name in a string, an array of strings, or a nested plain object. */ function tokensIn(value, out) { if (typeof value === 'string') { for (const name of scanTokens(value)) out.add(name) return } if (Array.isArray(value)) { for (const entry of value) tokensIn(entry, out) return } if (value && typeof value === 'object') { for (const entry of Object.values(value)) tokensIn(entry, out) } } function walkBlock(block, out) { if (!block || typeof block !== 'object') return tokensIn(block.props, out) const def = getEmailBlock(block.type) if (def && typeof def.variables === 'function') { let named = [] try { named = def.variables(block.props || {}) || [] } catch { // A definition that throws on malformed props must not take the save path // down with it: validation runs first and has already refused those props, // so the only way here is a definition bug, and the right answer to that is // to contribute no names rather than to 500 the request. named = [] } for (const name of named) if (typeof name === 'string' && name) out.add(name) } for (const slot of def?.containerSlots || []) { const children = block.props?.[slot] if (Array.isArray(children)) for (const child of children) walkBlock(child, out) } } /** * Every declared-variable name this template references, in no particular order. * * @param {{ blocks?: unknown[], subject?: string, text_body?: string|null }} template * @returns {string[]} */ function referencedVariables(template) { const out = new Set() tokensIn(template?.subject, out) tokensIn(template?.text_body, out) if (Array.isArray(template?.blocks)) for (const block of template.blocks) walkBlock(block, out) return [...out] } /** * The names `referencedVariables` found that `declared` does not contain. * * @param {{ blocks?: unknown[], subject?: string, text_body?: string|null }} template * @param {Array<{ name: string }>} declared what §4.3 declares for this template's * trigger, PLUS the ambient variables every template may use — the caller * passes `templates.variablesFor(...)`, which already merges the two. * @returns {string[]} sorted, so the error message is stable across saves */ function undeclaredVariables(template, declared) { const known = new Set((declared || []).map((v) => v && v.name).filter(Boolean)) return referencedVariables(template) .filter((name) => !known.has(name)) .sort() } module.exports = { referencedVariables, undeclaredVariables }