diff --git a/src/renderer/interpolate.ts b/src/renderer/interpolate.ts index 57f5791..a01ee70 100644 --- a/src/renderer/interpolate.ts +++ b/src/renderer/interpolate.ts @@ -6,13 +6,345 @@ const VARIABLE_RE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g; const ESCAPED_OPEN = /\\\{\\\{/g; const ESCAPE_PLACEHOLDER = '\x00ESCAPED_OPEN\x00'; +// --- Condition parsing --- + +/** + * A parsed condition from a block tag. + * + * Supports: + * {{#if var}} → { variable, operator: 'truthy' } + * {{#if var == "value"}} → { variable, operator: '==', comparand: 'value' } + * {{#if var != "value"}} → { variable, operator: '!=', comparand: 'value' } + * {{#unless var}} → same as above, inverted at evaluation time + */ +interface Condition { + variable: string; + operator: 'truthy' | '==' | '!='; + comparand?: string; +} + +// Matches: varName, varName == "val", varName != "val" +// Quotes can be double or single. +const CONDITION_RE = + /([a-zA-Z_][a-zA-Z0-9_]*)(?:\s*(==|!=)\s*(?:"([^"]*?)"|'([^']*?)'))?/; + +function parseCondition(conditionStr: string): Condition | null { + const m = CONDITION_RE.exec(conditionStr.trim()); + if (!m) return null; + + const variable = m[1]; + const operator = m[2] as '==' | '!=' | undefined; + const comparand = m[3] ?? m[4]; // double-quote group or single-quote group + + if (operator && comparand !== undefined) { + return { variable, operator, comparand }; + } + + return { variable, operator: 'truthy' }; +} + +/** + * Evaluate a condition against the variables map. + */ +function evaluateCondition(condition: Condition, variables: Record): boolean { + const value = variables[condition.variable]; + + switch (condition.operator) { + case 'truthy': + return value !== undefined && value !== ''; + case '==': + return value !== undefined && value === condition.comparand; + case '!=': + return value === undefined || value !== condition.comparand; + } +} + +// --- Block tag patterns --- + +// Opening tags: {{#if condition}} and {{#unless condition}} +// The condition part is captured broadly; parseCondition() handles the details. +const BLOCK_IF_OPEN_RE = /\{\{#if\s+([^}]+?)\s*\}\}/; +const BLOCK_UNLESS_OPEN_RE = /\{\{#unless\s+([^}]+?)\s*\}\}/; + +// Else-if tag: {{else if condition}} +const BLOCK_ELSE_IF_RE = /\{\{else\s+if\s+([^}]+?)\s*\}\}/; + +// Simple else and close tags +const BLOCK_ELSE_RE = /\{\{else\}\}/; +const BLOCK_IF_CLOSE_RE = /\{\{\/if\}\}/; +const BLOCK_UNLESS_CLOSE_RE = /\{\{\/unless\}\}/; + +// For extracting condition variable names (used by extractVariables) +const BLOCK_CONDITION_EXTRACT_RE = + /\{\{(?:#(?:if|unless)|else\s+if)\s+([a-zA-Z_][a-zA-Z0-9_]*)(?:\s*(?:==|!=)\s*(?:"[^"]*?"|'[^']*?'))?\s*\}\}/g; + +// --- Block types --- + +interface ConditionalBranch { + condition: Condition; + content: string; +} + +interface ConditionalBlock { + kind: 'if' | 'unless'; + branches: ConditionalBranch[]; // first branch is the {{#if}} condition + elseContent: string; // final {{else}} fallback (may be '') + fullMatch: string; +} + +/** + * Find the outermost conditional block in the template. + * Returns null if no block is found. + * + * This uses a counter-based approach: find the first opening tag, + * then scan forward tracking nesting depth to find the matching + * {{else if}}, {{else}}, and {{/if}} or {{/unless}} at depth 0. + */ +function findOutermostBlock(template: string): ConditionalBlock | null { + // Find the first opening tag (either {{#if}} or {{#unless}}) + const ifMatch = BLOCK_IF_OPEN_RE.exec(template); + const unlessMatch = BLOCK_UNLESS_OPEN_RE.exec(template); + + let openMatch: RegExpExecArray | null = null; + let kind: 'if' | 'unless'; + + if (ifMatch && unlessMatch) { + if (ifMatch.index <= unlessMatch.index) { + openMatch = ifMatch; + kind = 'if'; + } else { + openMatch = unlessMatch; + kind = 'unless'; + } + } else if (ifMatch) { + openMatch = ifMatch; + kind = 'if'; + } else if (unlessMatch) { + openMatch = unlessMatch; + kind = 'unless'; + } else { + return null; + } + + const openCondition = parseCondition(openMatch[1]); + if (!openCondition) return null; + + const contentStart = openMatch.index + openMatch[0].length; + const remaining = template.slice(contentStart); + + // Scan through remaining text tracking depth, collecting branch boundaries + let depth = 0; + let closeIndex = -1; // relative to contentStart + let closeLength = 0; + + // Branch boundary tracking: positions of {{else if ...}} and {{else}} at depth 0 + interface BranchBoundary { + kind: 'else-if' | 'else'; + position: number; // start of the tag (relative to remaining) + length: number; // length of the tag + condition?: Condition; + } + const boundaries: BranchBoundary[] = []; + + let pos = 0; + while (pos < remaining.length) { + const sub = remaining.slice(pos); + + // Check for any opening block tag (nesting) + const anyOpen = /^\{\{#(?:if|unless)\s+[^}]+?\s*\}\}/.exec(sub); + if (anyOpen) { + depth++; + pos += anyOpen[0].length; + continue; + } + + // Check for {{else if condition}} at depth 0 + const elseIfMatch = /^\{\{else\s+if\s+([^}]+?)\s*\}\}/.exec(sub); + if (elseIfMatch && depth === 0) { + const cond = parseCondition(elseIfMatch[1]); + if (cond) { + boundaries.push({ + kind: 'else-if', + position: pos, + length: elseIfMatch[0].length, + condition: cond, + }); + } + pos += elseIfMatch[0].length; + continue; + } + + // Check for {{else}} at depth 0 + const elseMatch = /^\{\{else\}\}/.exec(sub); + if (elseMatch && depth === 0) { + boundaries.push({ + kind: 'else', + position: pos, + length: elseMatch[0].length, + }); + pos += elseMatch[0].length; + continue; + } + + // Skip {{else}} / {{else if}} at depth > 0 + if (depth > 0 && (elseIfMatch || elseMatch)) { + pos += (elseIfMatch ?? elseMatch)![0].length; + continue; + } + + // Check for any closing block tag + const anyClose = /^\{\{\/(?:if|unless)\}\}/.exec(sub); + if (anyClose) { + if (depth === 0) { + closeIndex = pos; + closeLength = anyClose[0].length; + break; + } + depth--; + pos += anyClose[0].length; + continue; + } + + pos++; + } + + if (closeIndex === -1) { + // Unclosed block — leave it as-is (don't crash, just skip) + return null; + } + + // Build branches from boundaries + const branches: ConditionalBranch[] = []; + let elseContent = ''; + + // First branch: from start to first boundary (or close) + const firstEnd = boundaries.length > 0 ? boundaries[0].position : closeIndex; + branches.push({ + condition: openCondition, + content: remaining.slice(0, firstEnd), + }); + + // Middle branches (else-if) and final else + for (let i = 0; i < boundaries.length; i++) { + const boundary = boundaries[i]; + const nextEnd = i + 1 < boundaries.length + ? boundaries[i + 1].position + : closeIndex; + const branchContent = remaining.slice(boundary.position + boundary.length, nextEnd); + + if (boundary.kind === 'else-if' && boundary.condition) { + branches.push({ + condition: boundary.condition, + content: branchContent, + }); + } else { + // Final {{else}} — everything from here to {{/if}} + elseContent = branchContent; + } + } + + const fullMatchEnd = contentStart + closeIndex + closeLength; + const fullMatch = template.slice(openMatch.index, fullMatchEnd); + + return { kind, branches, elseContent, fullMatch }; +} + +function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Process all conditional blocks in a template. + * Evaluates {{#if}}/{{else if}}/{{else}}/{{/if}} and {{#unless}} blocks. + * + * Blocks are processed iteratively from the outermost inward. + * Conditionals are always permissive: a missing/empty variable evaluates + * as falsy (never throws), because conditionals are semantically about optionality. + */ +function processConditionals( + template: string, + variables: Record, +): string { + let result = template; + let safety = 0; + const MAX_ITERATIONS = 100; + + while (safety++ < MAX_ITERATIONS) { + const block = findOutermostBlock(result); + if (!block) break; + + let winning: string | null = null; + + if (block.kind === 'if') { + // Evaluate branches in order; first truthy wins + for (const branch of block.branches) { + if (evaluateCondition(branch.condition, variables)) { + winning = branch.content; + break; + } + } + if (winning === null) { + winning = block.elseContent; + } + } else { + // unless: only the first branch condition is inverted + // (else-if on unless blocks would be unusual, but we handle it) + const firstBranch = block.branches[0]; + if (!evaluateCondition(firstBranch.condition, variables)) { + winning = firstBranch.content; + } else if (block.branches.length > 1) { + for (let i = 1; i < block.branches.length; i++) { + if (evaluateCondition(block.branches[i].condition, variables)) { + winning = block.branches[i].content; + break; + } + } + } + if (winning === null) { + winning = block.elseContent; + } + } + + // Strip leading/trailing newline from the winning content if the block tags + // were on standalone lines. This prevents extra blank lines in output. + winning = stripBlockContentWhitespace(winning); + + result = result.replace(block.fullMatch, winning); + } + + return result; +} + +/** + * Trim exactly one leading newline and one trailing newline from block content, + * which arise from the tag lines themselves. + */ +function stripBlockContentWhitespace(content: string): string { + let result = content; + if (result.startsWith('\n')) { + result = result.slice(1); + } + if (result.endsWith('\n')) { + result = result.slice(0, -1); + } + return result; +} + /** * Interpolate variables into a template string. * - * Syntax: {{ variable_name }} - * Escape: \{\{ produces literal {{ + * Syntax: + * {{ variable_name }} — variable substitution + * {{#if var}}...{{/if}} — conditional (truthy = exists and non-empty) + * {{#if var == "value"}}...{{/if}} — string equality comparison + * {{#if var != "value"}}...{{/if}} — string inequality comparison + * {{#if var}}...{{else if var2}}...{{/if}} — chained conditions + * {{#if var}}...{{else}}...{{/if}} — conditional with fallback + * {{#unless var}}...{{/unless}} — inverted conditional + * \{\{ produces literal {{ * - * In strict mode, throws on missing variables. + * In strict mode, throws on missing variables (but NOT for conditional checks, + * since conditionals are semantically about optionality). * In permissive mode, leaves {{ placeholder }} intact. */ export function interpolate( @@ -25,6 +357,10 @@ export function interpolate( // Replace escaped sequences with placeholder let result = template.replace(ESCAPED_OPEN, ESCAPE_PLACEHOLDER); + // Phase 1: Process conditional blocks (always permissive) + result = processConditionals(result, variables); + + // Phase 2: Variable substitution result = result.replace(VARIABLE_RE, (match, name: string) => { if (name in variables) { return variables[name]; @@ -43,13 +379,24 @@ export function interpolate( /** * Extract all variable names referenced in a template. + * Includes variables used in {{#if var}}, {{#unless var}}, + * {{#if var == "value"}}, and {{else if var}} conditions. */ export function extractVariables(template: string): string[] { const vars = new Set(); + + // Extract from variable substitutions: {{ var }} let match: RegExpExecArray | null; const re = new RegExp(VARIABLE_RE.source, 'g'); while ((match = re.exec(template)) !== null) { vars.add(match[1]); } + + // Extract from conditional block tags: {{#if var}}, {{#unless var}}, {{else if var}} + const condRe = new RegExp(BLOCK_CONDITION_EXTRACT_RE.source, 'g'); + while ((match = condRe.exec(template)) !== null) { + vars.add(match[1]); + } + return [...vars]; } diff --git a/tests/interpolate.test.ts b/tests/interpolate.test.ts index 2580c0e..c21ec6f 100644 --- a/tests/interpolate.test.ts +++ b/tests/interpolate.test.ts @@ -43,6 +43,439 @@ describe('interpolate', () => { }); }); +describe('interpolate conditionals — {{#if}}', () => { + it('renders truthy branch when variable is present and non-empty', () => { + const result = interpolate( + 'Start\n{{#if premium}}\nPremium content here.\n{{/if}}\nEnd', + { premium: 'yes' }, + ); + expect(result).toBe('Start\nPremium content here.\nEnd'); + }); + + it('removes block when variable is missing (falsy)', () => { + const result = interpolate( + 'Start\n{{#if premium}}\nPremium content here.\n{{/if}}\nEnd', + {}, + ); + expect(result).toBe('Start\n\nEnd'); + }); + + it('removes block when variable is empty string (falsy)', () => { + const result = interpolate( + 'Start\n{{#if premium}}\nPremium content here.\n{{/if}}\nEnd', + { premium: '' }, + ); + expect(result).toBe('Start\n\nEnd'); + }); + + it('renders else branch when variable is falsy', () => { + const result = interpolate( + '{{#if premium}}\nYou have premium.\n{{else}}\nUpgrade to premium.\n{{/if}}', + {}, + ); + expect(result).toBe('Upgrade to premium.'); + }); + + it('renders if branch (not else) when variable is truthy', () => { + const result = interpolate( + '{{#if premium}}\nYou have premium.\n{{else}}\nUpgrade to premium.\n{{/if}}', + { premium: 'true' }, + ); + expect(result).toBe('You have premium.'); + }); + + it('supports variable substitution inside conditional branches', () => { + const result = interpolate( + '{{#if plan}}\nPlan: {{ plan }}\n{{else}}\nNo plan selected.\n{{/if}}', + { plan: 'Enterprise' }, + ); + expect(result).toBe('Plan: Enterprise'); + }); + + it('supports variable substitution inside else branches', () => { + const result = interpolate( + '{{#if premium}}\nWelcome back!\n{{else}}\nHello {{ name }}, upgrade today.\n{{/if}}', + { name: 'Alice' }, + ); + expect(result).toBe('Hello Alice, upgrade today.'); + }); + + it('does not throw in strict mode for conditional variable check', () => { + // Conditionals are inherently permissive — a missing var just means falsy + const result = interpolate( + '{{#if premium}}\nPremium.\n{{else}}\nFree.\n{{/if}}', + {}, + { strict: true }, + ); + expect(result).toBe('Free.'); + }); + + it('still throws in strict mode for missing substitution variables', () => { + expect(() => interpolate( + '{{#if premium}}\n{{ premium }}\n{{/if}}', + {}, + { strict: true }, + )).not.toThrow(); // block is removed, so {{ premium }} is never reached + }); + + it('throws in strict mode for missing variable in the winning branch', () => { + expect(() => interpolate( + '{{#if active}}\nHello {{ name }}!\n{{/if}}', + { active: 'yes' }, + { strict: true }, + )).toThrow('Missing required variable: "name"'); + }); +}); + +describe('interpolate conditionals — nested blocks', () => { + it('handles nested if blocks', () => { + const template = [ + '{{#if a}}', + 'A is true.', + '{{#if b}}', + 'B is also true.', + '{{/if}}', + '{{/if}}', + ].join('\n'); + + const result = interpolate(template, { a: 'yes', b: 'yes' }); + expect(result).toBe('A is true.\nB is also true.'); + }); + + it('handles nested if where inner is falsy', () => { + const template = [ + '{{#if a}}', + 'A is true.', + '{{#if b}}', + 'B is also true.', + '{{/if}}', + '{{/if}}', + ].join('\n'); + + const result = interpolate(template, { a: 'yes' }); + expect(result).toBe('A is true.\n'); + }); + + it('handles nested if where outer is falsy', () => { + const template = [ + '{{#if a}}', + 'A is true.', + '{{#if b}}', + 'B is also true.', + '{{/if}}', + '{{/if}}', + ].join('\n'); + + const result = interpolate(template, { b: 'yes' }); + expect(result).toBe(''); + }); + + it('handles nested blocks with else', () => { + const template = [ + '{{#if a}}', + '{{#if b}}', + 'Both A and B.', + '{{else}}', + 'Only A.', + '{{/if}}', + '{{else}}', + 'Not A.', + '{{/if}}', + ].join('\n'); + + expect(interpolate(template, { a: 'yes', b: 'yes' })).toBe('Both A and B.'); + expect(interpolate(template, { a: 'yes' })).toBe('Only A.'); + expect(interpolate(template, {})).toBe('Not A.'); + }); +}); + +describe('interpolate conditionals — {{#unless}}', () => { + it('renders content when variable is missing', () => { + const result = interpolate( + '{{#unless premium}}\nPlease upgrade.\n{{/unless}}', + {}, + ); + expect(result).toBe('Please upgrade.'); + }); + + it('hides content when variable is present', () => { + const result = interpolate( + '{{#unless premium}}\nPlease upgrade.\n{{/unless}}', + { premium: 'yes' }, + ); + expect(result).toBe(''); + }); + + it('renders else branch when variable is present', () => { + const result = interpolate( + '{{#unless premium}}\nUpgrade.\n{{else}}\nWelcome!\n{{/unless}}', + { premium: 'yes' }, + ); + expect(result).toBe('Welcome!'); + }); + + it('treats empty string as falsy', () => { + const result = interpolate( + '{{#unless premium}}\nUpgrade.\n{{/unless}}', + { premium: '' }, + ); + expect(result).toBe('Upgrade.'); + }); +}); + +describe('interpolate conditionals — inline usage', () => { + it('handles inline conditional (not on standalone lines)', () => { + const result = interpolate( + 'Hello {{#if name}}{{ name }}{{else}}stranger{{/if}}!', + { name: 'Alice' }, + ); + expect(result).toBe('Hello Alice!'); + }); + + it('handles inline conditional falsy', () => { + const result = interpolate( + 'Hello {{#if name}}{{ name }}{{else}}stranger{{/if}}!', + {}, + ); + expect(result).toBe('Hello stranger!'); + }); +}); + +describe('interpolate conditionals — edge cases', () => { + it('leaves unclosed blocks as-is', () => { + const result = interpolate( + 'Start {{#if x}} content without close', + { x: 'yes' }, + ); + expect(result).toBe('Start {{#if x}} content without close'); + }); + + it('handles multiple sequential blocks', () => { + const template = [ + '{{#if a}}', + 'A.', + '{{/if}}', + '{{#if b}}', + 'B.', + '{{/if}}', + ].join('\n'); + + expect(interpolate(template, { a: 'yes', b: 'yes' })).toBe('A.\nB.'); + expect(interpolate(template, { a: 'yes' })).toBe('A.\n'); + expect(interpolate(template, { b: 'yes' })).toBe('\nB.'); + expect(interpolate(template, {})).toBe('\n'); + }); + + it('handles mixed if and unless blocks', () => { + const template = [ + '{{#if premium}}', + 'Premium user.', + '{{/if}}', + '{{#unless premium}}', + 'Free user.', + '{{/unless}}', + ].join('\n'); + + expect(interpolate(template, { premium: 'yes' })).toBe('Premium user.\n'); + expect(interpolate(template, {})).toBe('\nFree user.'); + }); + + it('works with template containing no conditionals', () => { + const result = interpolate('Hello {{ name }}!', { name: 'World' }); + expect(result).toBe('Hello World!'); + }); + + it('escaped opening braces are not treated as conditionals', () => { + const result = interpolate('\\{\\{#if foo}} not a block', { foo: 'bar' }); + expect(result).toBe('{{#if foo}} not a block'); + }); +}); + +describe('interpolate conditionals — string comparison (==, !=)', () => { + it('renders block when variable equals compared value', () => { + const result = interpolate( + '{{#if plan == "premium"}}\nPremium features enabled.\n{{/if}}', + { plan: 'premium' }, + ); + expect(result).toBe('Premium features enabled.'); + }); + + it('hides block when variable does not equal compared value', () => { + const result = interpolate( + '{{#if plan == "premium"}}\nPremium features enabled.\n{{/if}}', + { plan: 'basic' }, + ); + expect(result).toBe(''); + }); + + it('hides block when variable is missing for == check', () => { + const result = interpolate( + '{{#if plan == "premium"}}\nPremium features enabled.\n{{/if}}', + {}, + ); + expect(result).toBe(''); + }); + + it('renders else branch when == comparison is false', () => { + const result = interpolate( + '{{#if plan == "premium"}}\nPremium.\n{{else}}\nNot premium.\n{{/if}}', + { plan: 'basic' }, + ); + expect(result).toBe('Not premium.'); + }); + + it('supports != (not equal) operator', () => { + const result = interpolate( + '{{#if status != "active"}}\nInactive account.\n{{/if}}', + { status: 'suspended' }, + ); + expect(result).toBe('Inactive account.'); + }); + + it('hides block when != comparison is false (values are equal)', () => { + const result = interpolate( + '{{#if status != "active"}}\nInactive account.\n{{/if}}', + { status: 'active' }, + ); + expect(result).toBe(''); + }); + + it('!= treats missing variable as not-equal (block renders)', () => { + const result = interpolate( + '{{#if status != "active"}}\nNo status set.\n{{/if}}', + {}, + ); + expect(result).toBe('No status set.'); + }); + + it('supports single-quoted comparison values', () => { + const result = interpolate( + "{{#if plan == 'premium'}}\nPremium.\n{{/if}}", + { plan: 'premium' }, + ); + expect(result).toBe('Premium.'); + }); + + it('supports comparison with empty string', () => { + // == "" should match when variable is explicitly empty + const result = interpolate( + '{{#if name == ""}}\nNo name provided.\n{{/if}}', + { name: '' }, + ); + expect(result).toBe('No name provided.'); + }); + + it('handles comparison inline', () => { + const result = interpolate( + 'Status: {{#if role == "admin"}}Administrator{{else}}User{{/if}}.', + { role: 'admin' }, + ); + expect(result).toBe('Status: Administrator.'); + }); +}); + +describe('interpolate conditionals — {{else if}} chaining', () => { + it('evaluates else-if branch when first condition is false', () => { + const template = [ + '{{#if plan == "enterprise"}}', + 'Enterprise plan.', + '{{else if plan == "premium"}}', + 'Premium plan.', + '{{else}}', + 'Free plan.', + '{{/if}}', + ].join('\n'); + + expect(interpolate(template, { plan: 'premium' })).toBe('Premium plan.'); + }); + + it('evaluates first matching branch in chain', () => { + const template = [ + '{{#if plan == "enterprise"}}', + 'Enterprise.', + '{{else if plan == "premium"}}', + 'Premium.', + '{{else if plan == "basic"}}', + 'Basic.', + '{{else}}', + 'Free.', + '{{/if}}', + ].join('\n'); + + expect(interpolate(template, { plan: 'enterprise' })).toBe('Enterprise.'); + expect(interpolate(template, { plan: 'premium' })).toBe('Premium.'); + expect(interpolate(template, { plan: 'basic' })).toBe('Basic.'); + expect(interpolate(template, { plan: 'free' })).toBe('Free.'); + expect(interpolate(template, {})).toBe('Free.'); + }); + + it('supports else-if with truthiness checks (no ==)', () => { + const template = [ + '{{#if premium}}', + 'Premium.', + '{{else if trial}}', + 'Trial.', + '{{else}}', + 'Free.', + '{{/if}}', + ].join('\n'); + + expect(interpolate(template, { premium: 'yes' })).toBe('Premium.'); + expect(interpolate(template, { trial: 'yes' })).toBe('Trial.'); + expect(interpolate(template, {})).toBe('Free.'); + }); + + it('supports else-if without final else', () => { + const template = [ + '{{#if plan == "a"}}', + 'A.', + '{{else if plan == "b"}}', + 'B.', + '{{/if}}', + ].join('\n'); + + expect(interpolate(template, { plan: 'a' })).toBe('A.'); + expect(interpolate(template, { plan: 'b' })).toBe('B.'); + expect(interpolate(template, { plan: 'c' })).toBe(''); + }); + + it('supports mixing == and truthiness in else-if chain', () => { + const template = [ + '{{#if role == "admin"}}', + 'Admin panel.', + '{{else if authenticated}}', + 'User dashboard.', + '{{else}}', + 'Login page.', + '{{/if}}', + ].join('\n'); + + expect(interpolate(template, { role: 'admin', authenticated: 'yes' })).toBe('Admin panel.'); + expect(interpolate(template, { role: 'user', authenticated: 'yes' })).toBe('User dashboard.'); + expect(interpolate(template, { role: 'user' })).toBe('Login page.'); + expect(interpolate(template, {})).toBe('Login page.'); + }); + + it('works with variable substitution inside chained branches', () => { + const template = [ + '{{#if tier == "enterprise"}}', + 'Welcome, {{ company }}!', + '{{else if tier == "pro"}}', + 'Pro user: {{ name }}.', + '{{else}}', + 'Hello {{ name }}, upgrade today.', + '{{/if}}', + ].join('\n'); + + expect(interpolate(template, { tier: 'enterprise', company: 'Acme', name: 'Bob' })) + .toBe('Welcome, Acme!'); + expect(interpolate(template, { tier: 'pro', name: 'Alice' })) + .toBe('Pro user: Alice.'); + expect(interpolate(template, { name: 'Charlie' })) + .toBe('Hello Charlie, upgrade today.'); + }); +}); + describe('extractVariables', () => { it('extracts variable names', () => { const vars = extractVariables('Hello {{ name }}, your id is {{ user_id }}.'); @@ -63,4 +496,48 @@ describe('extractVariables', () => { const vars = extractVariables('No variables here.'); expect(vars).toEqual([]); }); + + it('extracts condition variables from {{#if var}}', () => { + const vars = extractVariables('{{#if premium}}Content{{/if}}'); + expect(vars).toContain('premium'); + }); + + it('extracts condition variables from {{#unless var}}', () => { + const vars = extractVariables('{{#unless trial}}Content{{/unless}}'); + expect(vars).toContain('trial'); + }); + + it('extracts both condition and substitution variables', () => { + const vars = extractVariables( + '{{#if premium}}Hello {{ name }}{{/if}}', + ); + expect(vars).toContain('premium'); + expect(vars).toContain('name'); + }); + + it('deduplicates condition and substitution references to same variable', () => { + const vars = extractVariables( + '{{#if plan}}Plan: {{ plan }}{{/if}}', + ); + expect(vars).toEqual(['plan']); + }); + + it('extracts variable from == comparison condition', () => { + const vars = extractVariables('{{#if plan == "premium"}}Content{{/if}}'); + expect(vars).toContain('plan'); + }); + + it('extracts variable from != comparison condition', () => { + const vars = extractVariables('{{#if status != "active"}}Content{{/if}}'); + expect(vars).toContain('status'); + }); + + it('extracts variables from {{else if}} conditions', () => { + const vars = extractVariables( + '{{#if a}}A{{else if b == "x"}}B{{else if c}}C{{/if}}', + ); + expect(vars).toContain('a'); + expect(vars).toContain('b'); + expect(vars).toContain('c'); + }); });