/** * Template variables (Confluence expansion T-142). * * A template's `variables` column is a JSON list of typed variable * definitions: * * [{ name, label, type: "text"|"number"|"date"|"select", options?, * required?, defaultValue? }] * * Instantiation substitutes `{{name}}` tokens in the snapshot's block text * with the provided values. The substitution is PLAIN-TEXT ONLY (SECURITY_ * REVIEW §2 "Templates & variables"): values are never interpreted as HTML, * code, or block structure — they round-trip through the same Zod block * content schemas as any editor input. */ export const TEMPLATE_VAR_TYPES = ["text", "number", "date", "select"] as const; export type TemplateVarType = (typeof TEMPLATE_VAR_TYPES)[number]; export interface TemplateVar { name: string; // token name; [a-zA-Z0-9_-]{1,50} label: string; // form label type: TemplateVarType; options?: string[]; // type === "select" only required?: boolean; defaultValue?: string; } export const TEMPLATE_VAR_NAME_RE = /^[A-Za-z0-9_-]{1,50}$/; /** Validate a `variables` definition list (stored as JSON). */ export function validateTemplateVars(vars: unknown): { ok: true; vars: TemplateVar[] } | { ok: false; errors: string[] } { const errors: string[] = []; if (!Array.isArray(vars)) return { ok: false, errors: ["variables must be an array"] }; const out: TemplateVar[] = []; const seen = new Set(); for (const v of vars) { if (typeof v !== "object" || v === null) { errors.push("each variable must be an object"); continue; } const o = v as Record; const name = typeof o.name === "string" ? o.name : ""; if (!TEMPLATE_VAR_NAME_RE.test(name)) { errors.push(`variable name "${name}" must match ${TEMPLATE_VAR_NAME_RE}`); continue; } if (seen.has(name)) { errors.push(`duplicate variable name "${name}"`); continue; } seen.add(name); if (typeof o.label !== "string" || o.label.length === 0 || o.label.length > 100) { errors.push(`variable "${name}" needs a label (1-100 chars)`); continue; } if (!TEMPLATE_VAR_TYPES.includes(o.type as TemplateVarType)) { errors.push(`variable "${name}" has unknown type ${String(o.type)}`); continue; } if (o.type === "select") { if (!Array.isArray(o.options) || o.options.length === 0 || o.options.length > 50) { errors.push(`select variable "${name}" needs 1-50 options`); continue; } if (!o.options.every((x) => typeof x === "string" && x.length <= 100)) { errors.push(`select variable "${name}" options must be short strings`); continue; } } out.push({ name, label: o.label, type: o.type as TemplateVarType, ...(o.type === "select" && Array.isArray(o.options) ? { options: o.options as string[] } : {}), ...(typeof o.required === "boolean" ? { required: o.required } : {}), ...(typeof o.defaultValue === "string" && o.defaultValue.length <= 2000 ? { defaultValue: o.defaultValue } : {}) }); } return errors.length === 0 ? { ok: true, vars: out } : { ok: false, errors }; } /** * Validate + coerce user-supplied instantiation values against the * definition list. Returns the resolved map (name → string value, defaults * applied, `null`/empty for optional-missing). */ export function resolveTemplateValues( vars: TemplateVar[], input: Record ): { ok: true; values: Record } | { ok: false; errors: string[] } { const errors: string[] = []; const values: Record = {}; for (const v of vars) { let raw = input[v.name]; if (raw === undefined || raw === null || raw === "") raw = v.defaultValue; if (raw === undefined || raw === null || raw === "") { if (v.required) errors.push(`"${v.name}" is required`); else values[v.name] = ""; continue; } switch (v.type) { case "number": { const n = typeof raw === "number" ? raw : Number(raw); if (!Number.isFinite(n)) errors.push(`"${v.name}" must be a number`); else values[v.name] = String(n); break; } case "date": { if (typeof raw !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(raw)) { errors.push(`"${v.name}" must be an ISO date (YYYY-MM-DD)`); } else values[v.name] = raw; break; } case "select": { if (typeof raw !== "string" || !(v.options ?? []).includes(raw)) { errors.push(`"${v.name}" must be one of: ${(v.options ?? []).join(", ")}`); } else values[v.name] = raw; break; } case "text": default: { if (typeof raw !== "string") errors.push(`"${v.name}" must be a string`); else if (raw.length > 2000) errors.push(`"${v.name}" is too long (max 2000)`); else values[v.name] = raw; } } } // Unknown keys are ignored (never an error — forward-compatible). return errors.length === 0 ? { ok: true, values } : { ok: false, errors }; } /** * Substitute `{{name}}` tokens with values (plain-text replace, no escaping * needed because the result goes through the block content Zod schemas and * is rendered as text, never HTML). A token without a value resolves to "". */ export function substituteTemplateTokens(text: string, values: Record): string { return text.replace(/\{\{\s*([A-Za-z0-9_-]{1,50})\s*\}\}/g, (whole, name: string) => values[name] ?? ""); } /** * Walk a template snapshot (the shape produced by `captureSubtree`) and * substitute tokens in every text-bearing field. Returns a NEW object — * the input is not mutated. */ export function substituteSnapshot( snapshot: unknown, values: Record ): { page: { title: string; icon: string | null; description: string | null }; blocks: unknown[]; children: { title: string; blocks: unknown[] }[] } { const walkText = (s: unknown): unknown => (typeof s === "string" ? substituteTemplateTokens(s, values) : s); const walkBlocks = (blocks: unknown): unknown[] => Array.isArray(blocks) ? blocks.map((b) => { if (typeof b !== "object" || b === null) return b; const o = b as Record; const content = typeof o.content === "object" && o.content !== null ? Object.fromEntries(Object.entries(o.content as Record).map(([k, v]) => [k, walkText(v)])) : o.content; return { ...o, content }; }) : []; const raw = (snapshot ?? {}) as { page?: { title?: string; icon?: string | null; description?: string | null }; blocks?: unknown; children?: { title?: string; blocks?: unknown }[]; }; return { page: { title: substituteTemplateTokens(raw.page?.title ?? "", values), icon: raw.page?.icon ?? null, description: raw.page?.description != null ? substituteTemplateTokens(raw.page.description, values) : null }, blocks: walkBlocks(raw.blocks), children: (raw.children ?? []).map((c) => ({ title: substituteTemplateTokens(c.title ?? "", values), blocks: walkBlocks(c.blocks) })) }; }