Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 86 additions & 4 deletions docs/wiki/Template-Expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,26 @@ Use `{{variable}}` syntax to insert data values into text and properties:
# Combined path and index
- type: text
content: "{{orders[0].items[2].name}}"

# Computed key access (dynamic key from variable)
- type: text
content: "{{translations[lang]}}"

# String literal key
- type: text
content: "{{translations[\"en\"]}}"

# Chained access
- type: text
content: "{{sections[current].title}}"

# Nested computed access
- type: text
content: "{{dict[keys[0]]}}"

# Expression as key
- type: text
content: "Item: {{arr[base + offset]}}"
```

Variables can be used in **all** element properties -- including typed properties like numbers (`opacity`, `maxLines`, `size`), booleans (`wrap`, `showText`), and enums (`align`, `display`, `position`). When a typed property contains `{{`, the value is preserved as an expression during parsing, resolved at render time, and then parsed into the target type.
Expand Down Expand Up @@ -243,7 +263,8 @@ Operators are evaluated in this order (highest to lowest):

| Precedence | Operators |
|------------|-----------|
| 1 (highest) | Logical NOT (`!x`), Unary minus (`-x`) |
| 0 (highest) | Index access (`[]`), Member access (`.`) |
| 1 | Logical NOT (`!x`), Unary minus (`-x`) |
| 2 | Multiplication, Division (`*`, `/`) |
| 3 | Addition, Subtraction (`+`, `-`) |
| 4 | Comparison (`==`, `!=`, `<`, `>`, `<=`, `>=`) |
Expand Down Expand Up @@ -364,13 +385,14 @@ Conditions support full expressions including comparison operators, logical NOT,
{{#each arrayPath}}...{{/each}}
```

Iterates over an array. Inside the loop body, the current item's properties are accessible directly. Loop variables:
Iterates over an array or object. Inside the loop body, the current item's properties are accessible directly. Loop variables:

| Variable | Type | Description |
|----------|------|-------------|
| `@index` | number | 0-based iteration index |
| `@first` | bool | `true` for the first item |
| `@last` | bool | `true` for the last item |
| `@key` | string | Key name when iterating over an object (null for arrays) |

```yaml
- type: text
Expand All @@ -379,6 +401,20 @@ Iterates over an array. Inside the loop body, the current item's properties are
# Output with items=[{name:"A"},{name:"B"},{name:"C"}]: "A, B, C."
```

```yaml
# Iterate over object key-value pairs
- type: text
content: "{{#each specs}}{{@key}}: {{.}}, {{/each}}"

# Output with specs={"Color":"Red","Size":"XL"}: "Color: Red, Size: XL, "

# Access nested properties during object iteration
- type: text
content: "{{#each people}}{{@key}} is {{age}}, {{/each}}"

# Output with people={"alice":{"age":30},"bob":{"age":25}}: "alice is 30, bob is 25, "
```

### Nesting

Text blocks can be nested. The maximum nesting depth is controlled by `ResourceLimits.MaxTemplateNestingDepth` (default: 100).
Expand Down Expand Up @@ -443,7 +479,7 @@ var data = new ObjectValue

## Loops (type: each)

The `each` element iterates over an array in the data, creating child elements for each item.
The `each` element iterates over an array or object in the data, creating child elements for each item.

```yaml
- type: each
Expand All @@ -458,7 +494,7 @@ The `each` element iterates over an array in the data, creating child elements f

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `array` | string | Yes | Path to array in data (e.g., `"items"`, `"order.lines"`) |
| `array` | string | Yes | Path to array or object in data (e.g., `"items"`, `"order.lines"`) |
| `as` | string | No | Variable name for each item (default: items are accessible at root) |
| `children` | element[] | Yes | Template elements to render per item |

Expand All @@ -471,6 +507,7 @@ Inside `each` children, these special variables are available:
| `{{@index}}` | int | Zero-based index of current item |
| `{{@first}}` | bool | `true` for the first item |
| `{{@last}}` | bool | `true` for the last item |
| `{{@key}}` | string | Key name when iterating over an object (`null` for arrays) |

### Loop Examples

Expand Down Expand Up @@ -534,6 +571,51 @@ Inside `each` children, these special variables are available:
content: "{{line.qty}} x {{line.unitPrice}}"
```

**Dictionary iteration (object key-value pairs):**

```yaml
# Data: {"specs": {"Color": "Red", "Size": "XL", "Material": "Cotton"}}

- type: each
array: specs
as: val
children:
- type: flex
direction: row
children:
- type: text
content: "{{@key}}:"
- type: text
content: "{{val}}"
```

**Cross-dictionary lookup with @key:**

```yaml
# Data: {"labels": {"name": "Name", "price": "Price"}, "values": {"name": "Widget", "price": "$9.99"}}

- type: each
array: labels
as: label
children:
- type: text
content: "{{label}}: {{values[@key]}}"
```

**Nested object values:**

```yaml
# Data: {"sections": {"header": {"title": "Hello", "color": "#000"}}}

- type: each
array: sections
as: section
children:
- type: text
content: "{{@key}}: {{section.title}}"
color: "{{section.color}}"
```

---

## Conditionals (type: if)
Expand Down
16 changes: 15 additions & 1 deletion src/FlexRender.Core/TemplateEngine/ExpressionEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,18 @@ public static TemplateValue Resolve(string path, TemplateContext context)
return ResolveLoopVariable(path, context);
}

return ResolvePath(path, context.CurrentScope);
// Try current scope first, then walk up parent scopes
var scopes = context.Scopes;
for (var i = scopes.Count - 1; i >= 0; i--)
{
var result = ResolvePath(path, scopes[i]);
if (result is not NullValue)
{
return result;
}
}

return NullValue.Instance;
}

private static TemplateValue ResolveLoopVariable(string path, TemplateContext context)
Expand All @@ -68,6 +79,9 @@ private static TemplateValue ResolveLoopVariable(string path, TemplateContext co
: NullValue.Instance,
"@first" => new BoolValue(context.IsFirst),
"@last" => new BoolValue(context.IsLast),
"@key" => context.LoopKey is not null
? new StringValue(context.LoopKey)
: NullValue.Instance,
_ => NullValue.Instance
};
}
Expand Down
9 changes: 9 additions & 0 deletions src/FlexRender.Core/TemplateEngine/InlineExpression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,12 @@ public sealed record LogicalOrExpression(InlineExpression Left, InlineExpression
/// <param name="Left">The left expression.</param>
/// <param name="Right">The right expression.</param>
public sealed record LogicalAndExpression(InlineExpression Left, InlineExpression Right) : InlineExpression;

/// <summary>
/// A computed index/key access expression (e.g., <c>dict[lang]</c>, <c>arr[idx]</c>).
/// Evaluates <see cref="Index"/> and uses the result as a key (for <see cref="ObjectValue"/>)
/// or numeric index (for <see cref="ArrayValue"/>).
/// </summary>
/// <param name="Target">The expression being indexed (the object or array).</param>
/// <param name="Index">The expression whose result is used as the key or index.</param>
public sealed record IndexAccessExpression(InlineExpression Target, InlineExpression Index) : InlineExpression;
25 changes: 25 additions & 0 deletions src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ public TemplateValue Evaluate(InlineExpression expression, TemplateContext conte
FilterExpression filter => EvaluateFilter(filter, context),
NegateExpression neg => EvaluateNegate(neg, context),
NotExpression not => EvaluateNot(not, context),
IndexAccessExpression indexAccess => EvaluateIndexAccess(indexAccess, context),
_ => NullValue.Instance
};
}
Expand Down Expand Up @@ -243,6 +244,30 @@ private static bool CompareResult(int cmp, ComparisonOperator op)
};
}

private TemplateValue EvaluateIndexAccess(IndexAccessExpression expr, TemplateContext context)
{
var obj = Evaluate(expr.Target, context);
var index = Evaluate(expr.Index, context);

return (obj, index) switch
{
(ObjectValue objVal, StringValue strKey) => objVal[strKey.Value],
(ObjectValue objVal, NumberValue numKey) => objVal[numKey.Value.ToString("G", CultureInfo.InvariantCulture)],
(ArrayValue arrVal, NumberValue numIdx) => EvaluateArrayIndex(arrVal, numIdx),
_ => NullValue.Instance
};
}

private static TemplateValue EvaluateArrayIndex(ArrayValue array, NumberValue index)
{
var idx = (int)Math.Truncate(index.Value);
if (idx < 0 || idx >= array.Count || idx > ExpressionEvaluator.MaxArrayIndex)
{
return NullValue.Instance;
}
return array[idx];
}

private BoolValue EvaluateNot(NotExpression expr, TemplateContext context)
{
var operand = Evaluate(expr.Operand, context);
Expand Down
72 changes: 69 additions & 3 deletions src/FlexRender.Core/TemplateEngine/InlineExpressionParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ namespace FlexRender.TemplateEngine;
/// <summary>
/// Pratt parser for inline expressions within <c>{{...}}</c> blocks.
/// Supports arithmetic operators, comparison operators, logical NOT, logical OR (<c>||</c>),
/// logical AND (<c>&amp;&amp;</c>), null coalesce, filter pipes, and parenthesized grouping.
/// logical AND (<c>&amp;&amp;</c>), null coalesce, filter pipes, computed index access (<c>[]</c>),
/// and parenthesized grouping.
/// </summary>
/// <remarks>
/// <para>Operator precedence (lowest to highest):</para>
Expand All @@ -20,6 +21,7 @@ namespace FlexRender.TemplateEngine;
/// <item><c>+</c>, <c>-</c> (add, subtract)</item>
/// <item><c>*</c>, <c>/</c> (multiply, divide)</item>
/// <item>Unary <c>-</c> (negation), <c>!</c> (logical NOT)</item>
/// <item><c>[]</c> (computed index/key access), <c>.</c> (member access after index)</item>
/// <item><c>()</c> (grouping)</item>
/// </list>
/// <para>
Expand Down Expand Up @@ -94,6 +96,17 @@ public static bool NeedsFullParsing(string content)
// and rely on the regex for simple path detection below
}

// Check for computed key access: [non-digit] requires full parsing
// Simple numeric indices like items[0] do not need full parsing
if (content.Contains('['))
{
var bracketIdx = content.IndexOf('[');
if (bracketIdx + 1 < content.Length && !char.IsDigit(content[bracketIdx + 1]))
{
return true;
}
}

// Check if it's a simple path (no operators)
// Minus in paths is not an operator: "my-var" is a valid path
if (content.Contains('-'))
Expand Down Expand Up @@ -326,6 +339,8 @@ private InlineExpression ParseInfix(InlineExpression left, Precedence precedence
'-' => ParseArithmetic(left, ArithmeticOperator.Subtract),
'*' => ParseArithmetic(left, ArithmeticOperator.Multiply),
'/' => ParseArithmetic(left, ArithmeticOperator.Divide),
'[' => ParseIndexAccess(left),
'.' => ParseMemberAccess(left),
_ => throw new TemplateEngineException(
$"Unexpected operator '{c}'",
position: _pos,
Expand Down Expand Up @@ -641,7 +656,7 @@ private InlineExpression ParsePath()
{
var c = _input[_pos];

if (char.IsLetterOrDigit(c) || c == '.' || c == '_' || c == '[' || c == ']' || c == '@')
if (char.IsLetterOrDigit(c) || c == '.' || c == '_' || c == '@')
{
_pos++;
continue;
Expand Down Expand Up @@ -697,10 +712,60 @@ private InlineExpression ParsePath()
'>' => (Precedence.Comparison, false),
'+' or '-' => (Precedence.Additive, false),
'*' or '/' => (Precedence.Multiplicative, false),
'[' => (Precedence.Postfix, false),
'.' when _pos > 0 => (Precedence.Postfix, false),
_ => (Precedence.None, false)
};
}

private IndexAccessExpression ParseIndexAccess(InlineExpression left)
{
_pos++; // skip [
var index = ParseExpression(Precedence.None);
SkipWhitespace();
if (_pos >= _input.Length || _input[_pos] != ']')
{
throw new TemplateEngineException(
"Missing closing bracket ']'",
position: _pos,
expression: _input);
}
_pos++; // skip ]
return new IndexAccessExpression(left, index);
}

private IndexAccessExpression ParseMemberAccess(InlineExpression left)
{
_pos++; // skip .
var start = _pos;
while (_pos < _input.Length)
{
var c = _input[_pos];
if (char.IsLetterOrDigit(c) || c == '_' || c == '@')
{
_pos++;
continue;
}
// Allow hyphens in property names (same rules as ParsePath)
if (c == '-' && _pos + 1 < _input.Length && !char.IsWhiteSpace(_input[_pos + 1])
&& _pos > start && !char.IsWhiteSpace(_input[_pos - 1]))
{
_pos++;
continue;
}
break;
}
if (_pos == start)
{
throw new TemplateEngineException(
"Expected property name after '.'",
position: _pos,
expression: _input);
}
var propName = _input[start.._pos];
return new IndexAccessExpression(left, new StringLiteral(propName));
}

private bool IsDoubleChar(char c)
{
return _pos + 1 < _input.Length && _input[_pos] == c && _input[_pos + 1] == c;
Expand All @@ -724,6 +789,7 @@ private enum Precedence
Comparison = 5,
Additive = 6,
Multiplicative = 7,
Unary = 8
Unary = 8,
Postfix = 9
}
}
Loading