Skip to content

feat: add Mustache-style conditional blocks ({{#if}}/{{else if}}/{{else}}/{{/if}})#18

Open
igneusz wants to merge 1 commit into
PredictabilityAtScale:mainfrom
igneusz:feat/conditional-blocks
Open

feat: add Mustache-style conditional blocks ({{#if}}/{{else if}}/{{else}}/{{/if}})#18
igneusz wants to merge 1 commit into
PredictabilityAtScale:mainfrom
igneusz:feat/conditional-blocks

Conversation

@igneusz
Copy link
Copy Markdown

@igneusz igneusz commented May 6, 2026

Summary

Adds conditional template processing to the interpolation engine, enabling dynamic prompt content based on runtime variables.

Syntax

```markdown
{{#if var}}...{{/if}} — truthiness (exists and non-empty)
{{#if var == "value"}}...{{/if}} — string equality
{{#if var != "value"}}...{{/if}} — string inequality
{{#if a}}...{{else if b}}...{{else}}...{{/if}} — chained conditions
{{#unless var}}...{{/unless}} — inverted conditional
```

Design decisions

  • Pre-pass architecture: Conditionals are processed before variable substitution in interpolate(), so existing {{ var }} syntax is 100% backward compatible
  • Always permissive: Missing variables evaluate as falsy — conditionals never throw, even in strict mode, because they are semantically about optionality
  • Zero dependencies: Hand-written recursive parser (~280 lines), no Mustache/Handlebars library needed
  • Compiled JSON safe: Raw template strings are preserved in compiled .json artifacts; conditionals are evaluated at render time by provider adapters

What changed

File Change
src/renderer/interpolate.ts Conditional block parser, condition evaluator (truthy/==/!=), {{else if}} chain support
tests/interpolate.test.ts 48 new tests covering all syntax variants, nesting, edge cases

Test results

  • All 198 original tests pass unchanged
  • 48 new tests added (246 total)
  • tsc --noEmit clean

…se}}/{{/if}})

Adds conditional template processing to the interpolation engine:

- {{#if var}} / {{/if}} — truthiness check (exists and non-empty)
- {{#if var == "value"}} — string equality comparison
- {{#if var != "value"}} — string inequality comparison
- {{else if condition}} — chained conditions
- {{else}} — fallback branch
- {{#unless var}} — inverted conditional

Conditionals run as a pre-pass before variable substitution, so the
existing {{ var }} syntax is fully backward compatible. Conditionals
are always permissive (missing var = falsy, never throws in strict mode).

extractVariables() now also returns variable names used in conditions.

48 new tests added (246 total, all passing). Zero new dependencies.
Copilot AI review requested due to automatic review settings May 6, 2026 04:44
@igneusz
Copy link
Copy Markdown
Author

igneusz commented May 6, 2026

Added support for if/else Mustache clause for prompts with dynamic rules (saves creating a bunch of small includes). Confirmed working on my project currently in development.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Mustache-style conditional blocks to the existing interpolation engine, enabling templates to include/exclude content at render time based on runtime variables while keeping {{ var }} substitution backward compatible via a conditional pre-pass.

Changes:

  • Implemented a conditional-block parser/evaluator supporting {{#if}}, {{else if}}, {{else}}, {{/if}}, and {{#unless}}.
  • Updated interpolate() to process conditionals before variable substitution (with permissive conditional evaluation even in strict mode).
  • Expanded test coverage with many new cases for conditionals, nesting, chaining, and variable extraction.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 8 comments.

File Description
src/renderer/interpolate.ts Adds conditional parsing/evaluation and integrates a pre-pass into interpolation; expands variable extraction to include conditional variables.
tests/interpolate.test.ts Adds extensive test coverage for conditional rendering behavior and conditional variable extraction.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +28 to +44
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' };
}
Comment on lines +195 to +206
// 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;
}
Comment on lines +161 to +187
// 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;
}
// were on standalone lines. This prevents extra blank lines in output.
winning = stripBlockContentWhitespace(winning);

result = result.replace(block.fullMatch, winning);
Comment on lines +69 to +76
// 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\}\}/;

Comment on lines +268 to +275
let result = template;
let safety = 0;
const MAX_ITERATIONS = 100;

while (safety++ < MAX_ITERATIONS) {
const block = findOutermostBlock(result);
if (!block) break;

Comment thread tests/interpolate.test.ts
Comment on lines +113 to +119
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
});
Comment on lines +149 to +156
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++;
@igneusz
Copy link
Copy Markdown
Author

igneusz commented May 6, 2026

@copilot apply changes based on the comments in this thread

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants