/** * Pure template parser — input → output, zero I/O. * * Handles variable insertion (`[type:name]`) and conditional blocks * (`[if]`/`[else]`/`[endif]`, including negated `[if !...]` and nesting). * Unmatched control tags are emitted as literal text (the tag passes through * unchanged; surrounding content is parsed normally). * * Variable resolution rules (driven by the `vars` map): * - value is a string → insert it * - value is `null` (not existing) → insert empty string * - key absent from the map (unknown type) → insert empty string * * Conditional existence: a variable "exists" iff it is present in the map AND * its value is a string (not `null`). A `null` value or an absent key both mean * "does not exist". */ // ─── Token model ───────────────────────────────────────────────────────────── interface TextToken { readonly kind: "text"; readonly value: string; } interface VarToken { readonly kind: "var"; readonly key: string; readonly raw: string; } interface IfToken { readonly kind: "if"; readonly key: string; readonly negated: boolean; readonly raw: string; } interface ElseToken { readonly kind: "else"; readonly raw: string; } interface EndifToken { readonly kind: "endif"; readonly raw: string; } type Token = TextToken | VarToken | IfToken | ElseToken | EndifToken; // ─── Node model (AST) ──────────────────────────────────────────────────────── interface TextNode { readonly kind: "text"; readonly value: string; } interface VarNode { readonly kind: "var"; readonly key: string; } interface IfNode { kind: "if"; key: string; negated: boolean; thenBranch: Node[]; else: Node[] | null; matched: boolean; readonly raw: string; } type Node = TextNode | VarNode | IfNode; // ─── Tokenizer ─────────────────────────────────────────────────────────────── /** * Classify the content between `[` and `]` into a control/variable token, or * `null` when it is not a recognized tag (then it stays literal text). */ function classifyTag(content: string): Token | null { const trimmed = content.trim(); if (trimmed === "endif") return { kind: "endif", raw: `[${content}]` }; if (trimmed === "else") return { kind: "else", raw: `[${content}]` }; // `[if type:name]` / `[if !type:name]` const ifMatch = /^if\s+(!?)(\w+:.*)$/.exec(trimmed); if (ifMatch) { const negated = (ifMatch[1] ?? "") === "!"; const key = ifMatch[2] ?? ""; return { kind: "if", key, negated, raw: `[${content}]` }; } // `[type:name]` — variable insertion (any `word:rest`) const varMatch = /^(\w+:.*)$/.exec(trimmed); if (varMatch) return { kind: "var", key: trimmed, raw: `[${content}]` }; return null; } /** * Split a template into a flat token stream: text, variable, and control tags. * A `[` with no closing `]`, or whose content is not a recognized tag, is kept * as literal text. */ function tokenize(template: string): Token[] { const tokens: Token[] = []; let buf = ""; let i = 0; const n = template.length; const flush = (): void => { if (buf.length > 0) { tokens.push({ kind: "text", value: buf }); buf = ""; } }; while (i < n) { const ch = template[i]; if (ch === undefined) break; if (ch === "[") { const close = template.indexOf("]", i + 1); if (close === -1) { buf += "["; i++; continue; } const content = template.slice(i + 1, close); const tag = classifyTag(content); if (tag !== null) { flush(); tokens.push(tag); i = close + 1; continue; } buf += "["; i++; } else { buf += ch; i++; } } flush(); return tokens; } // ─── Parser (token stream → AST) ───────────────────────────────────────────── const EMPTY: readonly Node[] = Object.freeze([]) as readonly Node[]; /** * Build an AST from tokens using an explicit stack. `if`/`else`/`endif` are * matched here: an `if` left open at end-of-input is marked unmatched, and a * stray `else`/`endif` (no open `if`) becomes a literal text node. */ function parse(tokens: readonly Token[]): Node[] { const root: Node[] = []; const stack: IfNode[] = []; let current: Node[] = root; for (const tok of tokens) { switch (tok.kind) { case "text": current.push({ kind: "text", value: tok.value }); break; case "var": current.push({ kind: "var", key: tok.key }); break; case "if": { const node: IfNode = { kind: "if", key: tok.key, negated: tok.negated, thenBranch: [], else: null, matched: true, raw: tok.raw, }; current.push(node); stack.push(node); current = node.thenBranch; break; } case "else": { const top = stack[stack.length - 1]; if (top !== undefined && top.else === null) { top.else = []; current = top.else; } else { // stray else (no open if, or if already has an else) → literal current.push({ kind: "text", value: tok.raw }); } break; } case "endif": { const top = stack.pop(); if (top === undefined) { // stray endif → literal current.push({ kind: "text", value: tok.raw }); break; } const parent = stack[stack.length - 1]; current = parent === undefined ? root : (parent.else ?? parent.thenBranch); break; } } } // Any `if` still on the stack never found its `endif` → unmatched. for (const node of stack) node.matched = false; return root; } // ─── Renderer (AST → string) ──────────────────────────────────────────────── function variableExists(key: string, vars: ReadonlyMap): boolean { return vars.has(key) && vars.get(key) !== null; } function render(nodes: readonly Node[], vars: ReadonlyMap): string { let out = ""; for (const node of nodes) { switch (node.kind) { case "text": out += node.value; break; case "var": out += vars.get(node.key) ?? ""; break; case "if": { if (node.matched) { const exists = variableExists(node.key, vars); const takeThen = node.negated ? !exists : exists; const branch = takeThen ? node.thenBranch : (node.else ?? EMPTY); out += render(branch, vars); } else { // Unmatched `if` → the tag is literal text; content still renders. out += node.raw; out += render(node.thenBranch, vars); if (node.else !== null) { out += "[else]"; out += render(node.else, vars); } } break; } } } return out; } // ─── Public API ────────────────────────────────────────────────────────────── /** * Resolve a template against a variable map → the final string. * * - `[type:name]` → the resolved value, or empty string when absent/null. * - `[if type:name] ... [endif]` → renders when the variable exists (string). * - `[if !type:name]` → renders when it does NOT exist. * - `[else]` provides the fallback branch. * - Unmatched `[if]`/`[endif]` tags pass through as literal text. */ export function parseTemplate(template: string, vars: ReadonlyMap): string { const tokens = tokenize(template); const ast = parse(tokens); return render(ast, vars); } /** * Collect every variable key referenced by a template — from both insertion * (`[type:name]`) and conditionals (`[if type:name]` / `[if !type:name]`). * Used by the resolver to know which dynamic `file:` variables to read. * Returns unique keys in first-seen order. */ export function extractVariables(template: string): string[] { const tokens = tokenize(template); const seen = new Set(); const keys: string[] = []; for (const tok of tokens) { if (tok.kind === "var" || tok.kind === "if") { if (!seen.has(tok.key)) { seen.add(tok.key); keys.push(tok.key); } } } return keys; }