// ── Template variable interpolation ──────────────────────────────────────── // // ENGAGEMENT.md §4.6.2's security posture, as code: "variable interpolation is // HTML-escaped by default with no raw-HTML variable type in v1. A module supplies // data; it does not supply markup." // // The token grammar is deliberately the smallest thing that works: `{{ name }}`, // a bare declared variable name, and NOTHING else. No filters, no conditionals, // no loops, no dotted paths. Three reasons: // // - A template is operator-authored data rendered by the server. Every construct // added here is a construct an operator can get wrong and a construct someone // has to sandbox. // - §4.3 makes the trigger declaration the source of truth for what a template // may reference, and a save-time check names the offending variable. That check // can only be exact if a token is a name — `{{ user.profile.email }}` is not a // declared variable, it is an expression over one. // - Repetition is a BLOCK (`email.itemList`), not a template construct, so the // one place a template needs "for each" already has a typed, validated home. // // A token whose variable has no value at render time becomes the empty string and // is reported in `missing`. It does not become "undefined", which is the failure // §4.3's versioning paragraph is about — a renamed variable rendering as the word // undefined in a person's inbox. // `{{ name }}` / `{{name}}`. Leading letter, then letters/digits/underscore — // the same shape §4.3's declarations use. const TOKEN_RE = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g /** Escape text for interpolation into HTML. Same table as utils/htmlShell.js. */ function htmlEscape(s) { return String(s).replace( /[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]), ) } /** * Every distinct variable name a string references, in first-appearance order. * This is what the save-time check (Phase 5b) walks to find undeclared variables. * @param {unknown} str * @returns {string[]} */ function scanTokens(str) { if (typeof str !== 'string') return [] const found = [] for (const m of str.matchAll(TOKEN_RE)) { if (!found.includes(m[1])) found.push(m[1]) } return found } /** * Substitute declared variables into a string. * * @param {unknown} str * @param {Record} values * @param {{ escape?: boolean, missing?: Set }} [opts] * `escape` (default true) HTML-escapes each value — pass false ONLY for the * plain-text part, where there is no markup to escape into and `&` in a * person's inbox is a bug. `missing` collects names with no value. * @returns {string} */ function interpolate(str, values, opts = {}) { if (typeof str !== 'string' || str === '') return '' const escape = opts.escape !== false const missing = opts.missing || null return str.replace(TOKEN_RE, (_match, name) => { const value = values ? values[name] : undefined if (value === undefined || value === null) { if (missing) missing.add(name) return '' } const asString = typeof value === 'string' ? value : String(value) return escape ? htmlEscape(asString) : asString }) } module.exports = { TOKEN_RE, htmlEscape, scanTokens, interpolate }