From 76cab663a59951785a44da65d292bc9ffbe803b7 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 24 Feb 2026 12:44:18 +0300 Subject: [PATCH 01/10] feat(ast): add LoopKey to TemplateContext for dictionary iteration --- .../TemplateEngine/TemplateContext.cs | 17 ++++++++++ .../TemplateEngine/TemplateContextTests.cs | 32 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/FlexRender.Core/TemplateEngine/TemplateContext.cs b/src/FlexRender.Core/TemplateEngine/TemplateContext.cs index 74bad3c..340907f 100644 --- a/src/FlexRender.Core/TemplateEngine/TemplateContext.cs +++ b/src/FlexRender.Core/TemplateEngine/TemplateContext.cs @@ -27,6 +27,11 @@ public sealed class TemplateContext /// public bool IsLast { get; private set; } + /// + /// Gets the current loop key when iterating over an ObjectValue, or null if not in an object loop. + /// + public string? LoopKey { get; private set; } + /// /// Initializes a new context with root data. /// @@ -93,6 +98,17 @@ public void SetLoopVariables(int index, int count) IsLast = index == count - 1; } + /// + /// Sets the loop key for the current object iteration. + /// + /// The current key. Must not be null. + /// Thrown when is null. + public void SetLoopKey(string key) + { + ArgumentNullException.ThrowIfNull(key); + LoopKey = key; + } + /// /// Clears all loop variables. /// @@ -101,5 +117,6 @@ public void ClearLoopVariables() LoopIndex = null; IsFirst = false; IsLast = false; + LoopKey = null; } } diff --git a/tests/FlexRender.Tests/TemplateEngine/TemplateContextTests.cs b/tests/FlexRender.Tests/TemplateEngine/TemplateContextTests.cs index 8ad2505..b657ea3 100644 --- a/tests/FlexRender.Tests/TemplateEngine/TemplateContextTests.cs +++ b/tests/FlexRender.Tests/TemplateEngine/TemplateContextTests.cs @@ -161,4 +161,36 @@ public void SetLoopVariables_ValidParameters_Succeeds() Assert.Null(exception); } + + // LoopKey Tests + [Fact] + public void LoopKey_Default_IsNull() + { + var context = new TemplateContext(new ObjectValue()); + Assert.Null(context.LoopKey); + } + + [Fact] + public void SetLoopKey_SetsValue() + { + var context = new TemplateContext(new ObjectValue()); + context.SetLoopKey("myKey"); + Assert.Equal("myKey", context.LoopKey); + } + + [Fact] + public void ClearLoopVariables_ClearsLoopKey() + { + var context = new TemplateContext(new ObjectValue()); + context.SetLoopKey("myKey"); + context.ClearLoopVariables(); + Assert.Null(context.LoopKey); + } + + [Fact] + public void SetLoopKey_Null_ThrowsArgumentNullException() + { + var context = new TemplateContext(new ObjectValue()); + Assert.Throws(() => context.SetLoopKey(null!)); + } } From 77029264719dca0157f05af14801b13fe2cf98c0 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 24 Feb 2026 12:47:37 +0300 Subject: [PATCH 02/10] feat(ast): add @key loop variable to ExpressionEvaluator --- .../TemplateEngine/ExpressionEvaluator.cs | 3 ++ .../ExpressionEvaluatorTests.cs | 38 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/FlexRender.Core/TemplateEngine/ExpressionEvaluator.cs b/src/FlexRender.Core/TemplateEngine/ExpressionEvaluator.cs index fcb1f9a..6ac21ae 100644 --- a/src/FlexRender.Core/TemplateEngine/ExpressionEvaluator.cs +++ b/src/FlexRender.Core/TemplateEngine/ExpressionEvaluator.cs @@ -68,6 +68,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 }; } diff --git a/tests/FlexRender.Tests/TemplateEngine/ExpressionEvaluatorTests.cs b/tests/FlexRender.Tests/TemplateEngine/ExpressionEvaluatorTests.cs index db68111..aefbbbd 100644 --- a/tests/FlexRender.Tests/TemplateEngine/ExpressionEvaluatorTests.cs +++ b/tests/FlexRender.Tests/TemplateEngine/ExpressionEvaluatorTests.cs @@ -326,4 +326,42 @@ public void Resolve_CurrentScope_ReturnsScope() Assert.Same(data, result); } + + [Fact] + public void Resolve_LoopKey_ReturnsStringValue() + { + var data = new ObjectValue { ["x"] = "test" }; + var context = new TemplateContext(data); + context.SetLoopVariables(0, 3); + context.SetLoopKey("myKey"); + + var result = ExpressionEvaluator.Resolve("@key", context); + + var str = Assert.IsType(result); + Assert.Equal("myKey", str.Value); + } + + [Fact] + public void Resolve_LoopKey_OutsideLoop_ReturnsNullValue() + { + var data = new ObjectValue { ["x"] = "test" }; + var context = new TemplateContext(data); + + var result = ExpressionEvaluator.Resolve("@key", context); + + Assert.IsType(result); + } + + [Fact] + public void Resolve_LoopKey_InArrayLoop_ReturnsNullValue() + { + var data = new ObjectValue { ["x"] = "test" }; + var context = new TemplateContext(data); + context.SetLoopVariables(0, 3); + // LoopKey not set — simulates array iteration + + var result = ExpressionEvaluator.Resolve("@key", context); + + Assert.IsType(result); + } } From 31031a5b2c9e7602c6625bfbdb365c47deb0a148 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 24 Feb 2026 12:51:46 +0300 Subject: [PATCH 03/10] feat(ast): add ObjectValue iteration to TemplateExpander --- .../TemplateEngine/TemplateExpander.cs | 94 ++++++++---- .../TemplateEngine/TemplateExpanderTests.cs | 144 ++++++++++++++++++ 2 files changed, 211 insertions(+), 27 deletions(-) diff --git a/src/FlexRender.Core/TemplateEngine/TemplateExpander.cs b/src/FlexRender.Core/TemplateEngine/TemplateExpander.cs index 426637f..5bb2f41 100644 --- a/src/FlexRender.Core/TemplateEngine/TemplateExpander.cs +++ b/src/FlexRender.Core/TemplateEngine/TemplateExpander.cs @@ -137,45 +137,85 @@ private IEnumerable ExpandEach(EachElement each, TemplateContex var arrayValue = ExpressionEvaluator.Resolve(each.ArrayPath, context); - if (arrayValue is not ArrayValue array) + if (arrayValue is ArrayValue array) { - yield break; - } + // Existing array iteration logic + var count = array.Count; + for (var i = 0; i < count; i++) + { + var item = array[i]; - var count = array.Count; - for (var i = 0; i < count; i++) - { - var item = array[i]; + // Push item scope + if (each.ItemVariable != null) + { + // Create a new scope with the item variable + var scopeData = new ObjectValue + { + [each.ItemVariable] = item + }; + context.PushScope(scopeData); + } + else + { + context.PushScope(item); + } - // Push item scope - if (each.ItemVariable != null) - { - // Create a new scope with the item variable - var scopeData = new ObjectValue + context.SetLoopVariables(i, count); + + // Expand children + var expandedChildren = ExpandElements(each.ItemTemplate, context, childDepth); + + // Pop scope + context.ClearLoopVariables(); + context.PopScope(); + + foreach (var child in expandedChildren) { - [each.ItemVariable] = item - }; - context.PushScope(scopeData); + yield return child; + } } - else + } + else if (arrayValue is ObjectValue obj) + { + // Object iteration: iterate over key-value pairs with @key support + var keys = obj.Keys.ToList(); + var count = keys.Count; + for (var i = 0; i < count; i++) { - context.PushScope(item); - } + var key = keys[i]; + var value = obj[key]; + + // Push item scope + if (each.ItemVariable != null) + { + var scopeData = new ObjectValue + { + [each.ItemVariable] = value + }; + context.PushScope(scopeData); + } + else + { + context.PushScope(value); + } - context.SetLoopVariables(i, count); + context.SetLoopVariables(i, count); + context.SetLoopKey(key); - // Expand children - var expandedChildren = ExpandElements(each.ItemTemplate, context, childDepth); + // Expand children + var expandedChildren = ExpandElements(each.ItemTemplate, context, childDepth); - // Pop scope - context.ClearLoopVariables(); - context.PopScope(); + // Pop scope + context.ClearLoopVariables(); + context.PopScope(); - foreach (var child in expandedChildren) - { - yield return child; + foreach (var child in expandedChildren) + { + yield return child; + } } } + // else: not array or object -- yield nothing (implicit yield break) } private void ValidateNestedDepth(IReadOnlyList elements, int depth) diff --git a/tests/FlexRender.Tests/TemplateEngine/TemplateExpanderTests.cs b/tests/FlexRender.Tests/TemplateEngine/TemplateExpanderTests.cs index 7685fa7..9e2cea8 100644 --- a/tests/FlexRender.Tests/TemplateEngine/TemplateExpanderTests.cs +++ b/tests/FlexRender.Tests/TemplateEngine/TemplateExpanderTests.cs @@ -698,4 +698,148 @@ public void Expand_TextWithMissingVariable_ReplacesWithEmpty() var text = Assert.IsType(result.Elements[0]); Assert.Equal("Hello ", text.Content); } + + // === ObjectValue Iteration Tests === + + [Fact] + public void Expand_EachWithObjectValue_IteratesKeyValuePairs() + { + var data = new ObjectValue + { + ["specs"] = new ObjectValue + { + ["Color"] = new StringValue("Red"), + ["Size"] = new StringValue("XL") + } + }; + var textTemplate = new TextElement { Content = "{{@key}}: {{.}}" }; + var each = new EachElement(new List { textTemplate }) + { + ArrayPath = "specs" + }; + var template = CreateTemplate(each); + + var result = _expander.Expand(template, data); + + Assert.Equal(2, result.Elements.Count); + var first = Assert.IsType(result.Elements[0]); + Assert.Equal("Color: Red", first.Content.Value); + var second = Assert.IsType(result.Elements[1]); + Assert.Equal("Size: XL", second.Content.Value); + } + + [Fact] + public void Expand_EachWithObjectValue_WithAsVariable_BindsValue() + { + var data = new ObjectValue + { + ["specs"] = new ObjectValue + { + ["Color"] = new StringValue("Red") + } + }; + var textTemplate = new TextElement { Content = "{{@key}}={{val}}" }; + var each = new EachElement(new List { textTemplate }) + { + ArrayPath = "specs", + ItemVariable = "val" + }; + var template = CreateTemplate(each); + + var result = _expander.Expand(template, data); + + Assert.Single(result.Elements); + var text = Assert.IsType(result.Elements[0]); + Assert.Equal("Color=Red", text.Content.Value); + } + + [Fact] + public void Expand_EachWithObjectValue_IndexFirstLast() + { + var data = new ObjectValue + { + ["items"] = new ObjectValue + { + ["a"] = new StringValue("1"), + ["b"] = new StringValue("2"), + ["c"] = new StringValue("3") + } + }; + var textTemplate = new TextElement { Content = "{{@index}}-{{@first}}-{{@last}}" }; + var each = new EachElement(new List { textTemplate }) + { + ArrayPath = "items" + }; + var template = CreateTemplate(each); + + var result = _expander.Expand(template, data); + + Assert.Equal(3, result.Elements.Count); + Assert.Equal("0-true-false", ((TextElement)result.Elements[0]).Content.Value); + Assert.Equal("1-false-false", ((TextElement)result.Elements[1]).Content.Value); + Assert.Equal("2-false-true", ((TextElement)result.Elements[2]).Content.Value); + } + + [Fact] + public void Expand_EachWithObjectValue_NestedValues() + { + var data = new ObjectValue + { + ["sections"] = new ObjectValue + { + ["header"] = new ObjectValue { ["title"] = new StringValue("Hello") } + } + }; + var textTemplate = new TextElement { Content = "{{@key}}: {{val.title}}" }; + var each = new EachElement(new List { textTemplate }) + { + ArrayPath = "sections", + ItemVariable = "val" + }; + var template = CreateTemplate(each); + + var result = _expander.Expand(template, data); + + Assert.Single(result.Elements); + var text = Assert.IsType(result.Elements[0]); + Assert.Equal("header: Hello", text.Content.Value); + } + + [Fact] + public void Expand_EachWithEmptyObjectValue_ReturnsEmpty() + { + var data = new ObjectValue + { + ["specs"] = new ObjectValue() + }; + var textTemplate = new TextElement { Content = "{{@key}}" }; + var each = new EachElement(new List { textTemplate }) + { + ArrayPath = "specs" + }; + var template = CreateTemplate(each); + + var result = _expander.Expand(template, data); + + Assert.Empty(result.Elements); + } + + [Fact] + public void Expand_EachWithStringValue_ReturnsEmpty() + { + var data = new ObjectValue + { + ["specs"] = new StringValue("not iterable") + }; + var textTemplate = new TextElement { Content = "{{.}}" }; + var each = new EachElement(new List { textTemplate }) + { + ArrayPath = "specs" + }; + var template = CreateTemplate(each); + + var result = _expander.Expand(template, data); + + Assert.Empty(result.Elements); + } } From 140097a3e2a461cc1cd7e5c87c7bf5725fdd1eaa Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 24 Feb 2026 12:55:01 +0300 Subject: [PATCH 04/10] feat(ast): add ObjectValue iteration to inline {{#each}} blocks --- .../TemplateEngine/TemplateProcessor.cs | 16 ++++ .../TemplateProcessorIntegrationTests.cs | 79 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/src/FlexRender.Core/TemplateEngine/TemplateProcessor.cs b/src/FlexRender.Core/TemplateEngine/TemplateProcessor.cs index eaa0a66..8b60e48 100644 --- a/src/FlexRender.Core/TemplateEngine/TemplateProcessor.cs +++ b/src/FlexRender.Core/TemplateEngine/TemplateProcessor.cs @@ -256,6 +256,22 @@ private int ProcessEachBlock(List tokens, int startIndex, Templ context.PopScope(); } } + else if (arrayValue is ObjectValue obj) + { + var keys = obj.Keys.ToList(); + for (var i = 0; i < keys.Count; i++) + { + context.PushScope(obj[keys[i]]); + context.SetLoopVariables(i, keys.Count); + context.SetLoopKey(keys[i]); + + // bodyTokens is reused across iterations - ProcessTokens only reads the list + result.Append(ProcessTokens(bodyTokens, context, newDepth)); + + context.ClearLoopVariables(); + context.PopScope(); + } + } return endIndex + 1; } diff --git a/tests/FlexRender.Tests/TemplateEngine/TemplateProcessorIntegrationTests.cs b/tests/FlexRender.Tests/TemplateEngine/TemplateProcessorIntegrationTests.cs index dee6d62..ceee858 100644 --- a/tests/FlexRender.Tests/TemplateEngine/TemplateProcessorIntegrationTests.cs +++ b/tests/FlexRender.Tests/TemplateEngine/TemplateProcessorIntegrationTests.cs @@ -270,4 +270,83 @@ public void ProcessIfBlock_NullCoalesceWithSingleQuoteFallback_Works() Assert.Equal("Welcome guest", result); } + + /// + /// Verifies that inline {{#each}} iterates over ObjectValue key-value pairs, + /// exposing @key and . (current scope) for each entry. + /// + [Fact] + public void Process_InlineEachOverObject_IteratesKeyValuePairs() + { + var data = new ObjectValue + { + ["specs"] = new ObjectValue + { + ["Color"] = new StringValue("Red"), + ["Size"] = new StringValue("XL") + } + }; + + var result = _processor.Process("{{#each specs}}{{@key}}:{{.}} {{/each}}", data); + + Assert.Equal("Color:Red Size:XL ", result); + } + + /// + /// Verifies that inline {{#each}} over an ObjectValue allows accessing nested + /// properties on each value when values are themselves objects. + /// + [Fact] + public void Process_InlineEachOverObject_NestedProperty() + { + var data = new ObjectValue + { + ["people"] = new ObjectValue + { + ["alice"] = new ObjectValue { ["age"] = new NumberValue(30) }, + ["bob"] = new ObjectValue { ["age"] = new NumberValue(25) } + } + }; + + var result = _processor.Process("{{#each people}}{{@key}}={{age}} {{/each}}", data); + + Assert.Equal("alice=30 bob=25 ", result); + } + + /// + /// Verifies that inline {{#each}} over an empty ObjectValue produces no output. + /// + [Fact] + public void Process_InlineEachOverObject_Empty_ReturnsEmpty() + { + var data = new ObjectValue + { + ["obj"] = new ObjectValue() + }; + + var result = _processor.Process("before{{#each obj}}{{@key}}{{/each}}after", data); + + Assert.Equal("beforeafter", result); + } + + /// + /// Verifies that inline {{#each}} over an ObjectValue correctly sets + /// @index, @first, and @last loop variables. + /// + [Fact] + public void Process_InlineEachOverObject_IndexFirstLast() + { + var data = new ObjectValue + { + ["items"] = new ObjectValue + { + ["a"] = new StringValue("1"), + ["b"] = new StringValue("2") + } + }; + + var result = _processor.Process("{{#each items}}{{@index}}{{@first}}{{@last}} {{/each}}", data); + + Assert.Equal("0truefalse 1falsetrue ", result); + } } From 4b238ad6ac357fa54c7b6947ddb511838f40bb96 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 24 Feb 2026 12:59:03 +0300 Subject: [PATCH 05/10] feat(ast): trim keys in SetLoopKey to ensure clean @key values --- .../TemplateEngine/TemplateContext.cs | 2 +- .../TemplateEngine/TemplateContextTests.cs | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/FlexRender.Core/TemplateEngine/TemplateContext.cs b/src/FlexRender.Core/TemplateEngine/TemplateContext.cs index 340907f..77a5e8e 100644 --- a/src/FlexRender.Core/TemplateEngine/TemplateContext.cs +++ b/src/FlexRender.Core/TemplateEngine/TemplateContext.cs @@ -106,7 +106,7 @@ public void SetLoopVariables(int index, int count) public void SetLoopKey(string key) { ArgumentNullException.ThrowIfNull(key); - LoopKey = key; + LoopKey = key.Trim(); } /// diff --git a/tests/FlexRender.Tests/TemplateEngine/TemplateContextTests.cs b/tests/FlexRender.Tests/TemplateEngine/TemplateContextTests.cs index b657ea3..7034492 100644 --- a/tests/FlexRender.Tests/TemplateEngine/TemplateContextTests.cs +++ b/tests/FlexRender.Tests/TemplateEngine/TemplateContextTests.cs @@ -193,4 +193,28 @@ public void SetLoopKey_Null_ThrowsArgumentNullException() var context = new TemplateContext(new ObjectValue()); Assert.Throws(() => context.SetLoopKey(null!)); } + + [Fact] + public void SetLoopKey_TrimsWhitespace() + { + var context = new TemplateContext(new ObjectValue()); + context.SetLoopKey(" myKey "); + Assert.Equal("myKey", context.LoopKey); + } + + [Fact] + public void SetLoopKey_TrimsLeadingWhitespace() + { + var context = new TemplateContext(new ObjectValue()); + context.SetLoopKey(" myKey"); + Assert.Equal("myKey", context.LoopKey); + } + + [Fact] + public void SetLoopKey_TrimsTrailingWhitespace() + { + var context = new TemplateContext(new ObjectValue()); + context.SetLoopKey("myKey "); + Assert.Equal("myKey", context.LoopKey); + } } From 92784956212b75bfd411abf2045c2e282ca1dc5d Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 24 Feb 2026 13:02:59 +0300 Subject: [PATCH 06/10] feat(ast): trim keys in ObjectValue for consistent key handling --- src/FlexRender.Core/Values/ObjectValue.cs | 8 ++-- .../Values/ObjectValueTests.cs | 44 +++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/FlexRender.Core/Values/ObjectValue.cs b/src/FlexRender.Core/Values/ObjectValue.cs index 657c38c..f14d6e9 100644 --- a/src/FlexRender.Core/Values/ObjectValue.cs +++ b/src/FlexRender.Core/Values/ObjectValue.cs @@ -16,8 +16,8 @@ public sealed class ObjectValue : TemplateValue /// Thrown when is null on set. public TemplateValue this[string key] { - get => _properties.GetValueOrDefault(key, NullValue.Instance); - set => _properties[key] = value ?? throw new ArgumentNullException(nameof(value)); + get => _properties.GetValueOrDefault(key.Trim(), NullValue.Instance); + set => _properties[key.Trim()] = value ?? throw new ArgumentNullException(nameof(value)); } /// @@ -35,7 +35,7 @@ public TemplateValue this[string key] /// /// The key to check. /// True if the property exists; otherwise, false. - public bool ContainsKey(string key) => _properties.ContainsKey(key); + public bool ContainsKey(string key) => _properties.ContainsKey(key.Trim()); /// /// Tries to get the value of a property. @@ -45,7 +45,7 @@ public TemplateValue this[string key] /// True if the property was found; otherwise, false. public bool TryGetValue(string key, out TemplateValue? value) { - return _properties.TryGetValue(key, out value); + return _properties.TryGetValue(key.Trim(), out value); } /// diff --git a/tests/FlexRender.Tests/Values/ObjectValueTests.cs b/tests/FlexRender.Tests/Values/ObjectValueTests.cs index dafcd35..2f87480 100644 --- a/tests/FlexRender.Tests/Values/ObjectValueTests.cs +++ b/tests/FlexRender.Tests/Values/ObjectValueTests.cs @@ -194,4 +194,48 @@ public void ToString_ReturnsObjectRepresentation() Assert.Contains("name", result); Assert.Contains("test", result); } + + [Fact] + public void Indexer_Set_TrimsKey() + { + var obj = new ObjectValue(); + obj[" name "] = new StringValue("value"); + Assert.Equal("value", ((StringValue)obj["name"]).Value); + } + + [Fact] + public void Indexer_Get_TrimsKey() + { + var obj = new ObjectValue(); + obj["name"] = new StringValue("value"); + var result = obj[" name "]; + Assert.IsType(result); + Assert.Equal("value", ((StringValue)result).Value); + } + + [Fact] + public void ContainsKey_TrimsKey() + { + var obj = new ObjectValue(); + obj["name"] = new StringValue("value"); + Assert.True(obj.ContainsKey(" name ")); + } + + [Fact] + public void TryGetValue_TrimsKey() + { + var obj = new ObjectValue(); + obj["name"] = new StringValue("value"); + Assert.True(obj.TryGetValue(" name ", out var value)); + Assert.Equal("value", ((StringValue)value!).Value); + } + + [Fact] + public void Keys_ReturnsTrimmedKeys() + { + var obj = new ObjectValue(); + obj[" name "] = new StringValue("value"); + Assert.Contains("name", obj.Keys); + Assert.DoesNotContain(" name ", obj.Keys); + } } From 5462cea6c7d3cb01e86535ba905322943d254080 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 24 Feb 2026 13:12:39 +0300 Subject: [PATCH 07/10] feat(ast): add computed key access [expr] to expression system Add IndexAccessExpression AST node, Pratt parser postfix operator for bracket notation, and evaluator support for dynamic key/index access on ObjectValue and ArrayValue. --- .../TemplateEngine/InlineExpression.cs | 9 + .../InlineExpressionEvaluator.cs | 25 +++ .../TemplateEngine/InlineExpressionParser.cs | 72 ++++++- .../InlineExpressionEvaluatorTests.cs | 180 ++++++++++++++++++ .../InlineExpressionParserTests.cs | 102 ++++++++++ 5 files changed, 385 insertions(+), 3 deletions(-) diff --git a/src/FlexRender.Core/TemplateEngine/InlineExpression.cs b/src/FlexRender.Core/TemplateEngine/InlineExpression.cs index 27895e9..d59df83 100644 --- a/src/FlexRender.Core/TemplateEngine/InlineExpression.cs +++ b/src/FlexRender.Core/TemplateEngine/InlineExpression.cs @@ -138,3 +138,12 @@ public sealed record LogicalOrExpression(InlineExpression Left, InlineExpression /// The left expression. /// The right expression. public sealed record LogicalAndExpression(InlineExpression Left, InlineExpression Right) : InlineExpression; + +/// +/// A computed index/key access expression (e.g., dict[lang], arr[idx]). +/// Evaluates and uses the result as a key (for ) +/// or numeric index (for ). +/// +/// The expression being indexed (the object or array). +/// The expression whose result is used as the key or index. +public sealed record IndexAccessExpression(InlineExpression Target, InlineExpression Index) : InlineExpression; diff --git a/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs b/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs index f2fece7..f56283e 100644 --- a/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs +++ b/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs @@ -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 }; } @@ -225,6 +226,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); diff --git a/src/FlexRender.Core/TemplateEngine/InlineExpressionParser.cs b/src/FlexRender.Core/TemplateEngine/InlineExpressionParser.cs index 6f24c62..4cabdee 100644 --- a/src/FlexRender.Core/TemplateEngine/InlineExpressionParser.cs +++ b/src/FlexRender.Core/TemplateEngine/InlineExpressionParser.cs @@ -7,7 +7,8 @@ namespace FlexRender.TemplateEngine; /// /// Pratt parser for inline expressions within {{...}} blocks. /// Supports arithmetic operators, comparison operators, logical NOT, logical OR (||), -/// logical AND (&&), null coalesce, filter pipes, and parenthesized grouping. +/// logical AND (&&), null coalesce, filter pipes, computed index access ([]), +/// and parenthesized grouping. /// /// /// Operator precedence (lowest to highest): @@ -20,6 +21,7 @@ namespace FlexRender.TemplateEngine; /// +, - (add, subtract) /// *, / (multiply, divide) /// Unary - (negation), ! (logical NOT) +/// [] (computed index/key access), . (member access after index) /// () (grouping) /// /// @@ -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('-')) @@ -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, @@ -603,7 +618,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; @@ -659,10 +674,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; @@ -686,6 +751,7 @@ private enum Precedence Comparison = 5, Additive = 6, Multiplicative = 7, - Unary = 8 + Unary = 8, + Postfix = 9 } } diff --git a/tests/FlexRender.Tests/Expressions/InlineExpressionEvaluatorTests.cs b/tests/FlexRender.Tests/Expressions/InlineExpressionEvaluatorTests.cs index f9a6353..eb000ed 100644 --- a/tests/FlexRender.Tests/Expressions/InlineExpressionEvaluatorTests.cs +++ b/tests/FlexRender.Tests/Expressions/InlineExpressionEvaluatorTests.cs @@ -430,4 +430,184 @@ public void Evaluate_UnknownFilter_ThrowsTemplateEngineException() Assert.Throws( () => Evaluator.Evaluate(ast, context)); } + + // === Index Access (Computed Key) === + + [Fact] + public void Evaluate_IndexAccess_StringKey_ReturnsValue() + { + var data = new ObjectValue + { + ["dict"] = new ObjectValue { ["en"] = new StringValue("Hello"), ["ru"] = new StringValue("Привет") }, + ["lang"] = new StringValue("ru") + }; + var result = Evaluate("dict[lang]", data); + var str = Assert.IsType(result); + Assert.Equal("Привет", str.Value); + } + + [Fact] + public void Evaluate_IndexAccess_StringLiteral_ReturnsValue() + { + var data = new ObjectValue + { + ["dict"] = new ObjectValue { ["key"] = new StringValue("value") } + }; + var result = Evaluate("dict[\"key\"]", data); + var str = Assert.IsType(result); + Assert.Equal("value", str.Value); + } + + [Fact] + public void Evaluate_IndexAccess_NumericIndex_ReturnsArrayItem() + { + var data = new ObjectValue + { + ["arr"] = new ArrayValue(new List { new StringValue("a"), new StringValue("b"), new StringValue("c") }), + ["idx"] = new NumberValue(1) + }; + var result = Evaluate("arr[idx]", data); + var str = Assert.IsType(result); + Assert.Equal("b", str.Value); + } + + [Fact] + public void Evaluate_IndexAccess_MissingKey_ReturnsNull() + { + var data = new ObjectValue + { + ["dict"] = new ObjectValue { ["a"] = new StringValue("1") }, + ["key"] = new StringValue("missing") + }; + var result = Evaluate("dict[key]", data); + Assert.IsType(result); + } + + [Fact] + public void Evaluate_IndexAccess_NullKey_ReturnsNull() + { + var data = new ObjectValue + { + ["dict"] = new ObjectValue { ["a"] = new StringValue("1") } + }; + var result = Evaluate("dict[key]", data); + Assert.IsType(result); + } + + [Fact] + public void Evaluate_IndexAccess_BoolKey_ReturnsNull() + { + var data = new ObjectValue + { + ["dict"] = new ObjectValue { ["a"] = new StringValue("1") }, + ["flag"] = new BoolValue(true) + }; + var result = Evaluate("dict[flag]", data); + Assert.IsType(result); + } + + [Fact] + public void Evaluate_IndexAccess_NumberKeyOnObject_ConvertsToString() + { + var data = new ObjectValue + { + ["dict"] = new ObjectValue { ["42"] = new StringValue("answer") }, + ["num"] = new NumberValue(42) + }; + var result = Evaluate("dict[num]", data); + var str = Assert.IsType(result); + Assert.Equal("answer", str.Value); + } + + [Fact] + public void Evaluate_IndexAccess_StringKeyOnArray_ReturnsNull() + { + var data = new ObjectValue + { + ["arr"] = new ArrayValue(new List { new StringValue("a") }), + ["key"] = new StringValue("notAnIndex") + }; + var result = Evaluate("arr[key]", data); + Assert.IsType(result); + } + + [Fact] + public void Evaluate_IndexAccess_OnStringValue_ReturnsNull() + { + var data = new ObjectValue + { + ["str"] = new StringValue("hello"), + ["key"] = new StringValue("x") + }; + var result = Evaluate("str[key]", data); + Assert.IsType(result); + } + + [Fact] + public void Evaluate_IndexAccess_Chained_Works() + { + var data = new ObjectValue + { + ["sections"] = new ObjectValue + { + ["header"] = new ObjectValue { ["title"] = new StringValue("Hello") } + }, + ["current"] = new StringValue("header") + }; + var result = Evaluate("sections[current].title", data); + var str = Assert.IsType(result); + Assert.Equal("Hello", str.Value); + } + + [Fact] + public void Evaluate_IndexAccess_Nested_Works() + { + var data = new ObjectValue + { + ["dict"] = new ObjectValue { ["en"] = new StringValue("Hello") }, + ["keys"] = new ArrayValue(new List { new StringValue("en"), new StringValue("ru") }) + }; + var result = Evaluate("dict[keys[0]]", data); + var str = Assert.IsType(result); + Assert.Equal("Hello", str.Value); + } + + [Fact] + public void Evaluate_IndexAccess_WithArithmeticExpression_Works() + { + var data = new ObjectValue + { + ["arr"] = new ArrayValue(new List { new StringValue("zero"), new StringValue("one"), new StringValue("two") }), + ["base"] = new NumberValue(1), + ["offset"] = new NumberValue(1) + }; + var result = Evaluate("arr[base + offset]", data); + var str = Assert.IsType(result); + Assert.Equal("two", str.Value); + } + + [Fact] + public void Evaluate_IndexAccess_FractionalIndex_TruncatesToInt() + { + var data = new ObjectValue + { + ["arr"] = new ArrayValue(new List { new StringValue("zero"), new StringValue("one") }), + ["idx"] = new NumberValue(1.7m) + }; + var result = Evaluate("arr[idx]", data); + var str = Assert.IsType(result); + Assert.Equal("one", str.Value); + } + + [Fact] + public void Evaluate_IndexAccess_NegativeIndex_ReturnsNull() + { + var data = new ObjectValue + { + ["arr"] = new ArrayValue(new List { new StringValue("a") }), + ["idx"] = new NumberValue(-1) + }; + var result = Evaluate("arr[idx]", data); + Assert.IsType(result); + } } diff --git a/tests/FlexRender.Tests/Expressions/InlineExpressionParserTests.cs b/tests/FlexRender.Tests/Expressions/InlineExpressionParserTests.cs index 2aacb40..55838d2 100644 --- a/tests/FlexRender.Tests/Expressions/InlineExpressionParserTests.cs +++ b/tests/FlexRender.Tests/Expressions/InlineExpressionParserTests.cs @@ -953,4 +953,106 @@ public void Parse_TruePrefix_IsPath() } #endregion + + #region Index Access (Computed Key) + + [Fact] + public void Parse_VariableKeyAccess_ProducesIndexAccess() + { + var result = InlineExpressionParser.Parse("dict[lang]"); + var access = Assert.IsType(result); + var obj = Assert.IsType(access.Target); + Assert.Equal("dict", obj.Path); + var idx = Assert.IsType(access.Index); + Assert.Equal("lang", idx.Path); + } + + [Fact] + public void Parse_StringLiteralKey_ProducesIndexAccess() + { + var result = InlineExpressionParser.Parse("dict[\"key\"]"); + var access = Assert.IsType(result); + Assert.IsType(access.Target); + var idx = Assert.IsType(access.Index); + Assert.Equal("key", idx.Value); + } + + [Fact] + public void Parse_NumericKeyInFullParser_ProducesIndexAccess() + { + // Force through full parser by adding a variable key expression context + var result = InlineExpressionParser.Parse("arr[0]"); + // This should go through fast path and produce PathExpression + // Only when [non-digit] is present does it go through full parser + var path = Assert.IsType(result); + Assert.Equal("arr[0]", path.Path); + } + + [Fact] + public void Parse_ExpressionKey_ProducesIndexAccess() + { + var result = InlineExpressionParser.Parse("dict[a + b]"); + var access = Assert.IsType(result); + Assert.IsType(access.Target); + Assert.IsType(access.Index); + } + + [Fact] + public void Parse_FilterKey_ProducesIndexAccess() + { + var result = InlineExpressionParser.Parse("dict[key | lower]"); + var access = Assert.IsType(result); + Assert.IsType(access.Target); + Assert.IsType(access.Index); + } + + [Fact] + public void Parse_NestedBrackets_ProducesNestedIndexAccess() + { + var result = InlineExpressionParser.Parse("dict[arr[0]]"); + var outer = Assert.IsType(result); + Assert.IsType(outer.Target); + var inner = Assert.IsType(outer.Index); + Assert.IsType(inner.Target); + Assert.IsType(inner.Index); + } + + [Fact] + public void Parse_ChainedAccess_Works() + { + var result = InlineExpressionParser.Parse("obj[k].prop"); + // obj[k] -> IndexAccessExpression, then .prop -> IndexAccessExpression with StringLiteral + var outer = Assert.IsType(result); + var inner = Assert.IsType(outer.Target); + Assert.IsType(inner.Target); + Assert.IsType(inner.Index); + var propKey = Assert.IsType(outer.Index); + Assert.Equal("prop", propKey.Value); + } + + [Fact] + public void Parse_LoopVarKey_Works() + { + var result = InlineExpressionParser.Parse("dict[@key]"); + var access = Assert.IsType(result); + Assert.IsType(access.Target); + var idx = Assert.IsType(access.Index); + Assert.Equal("@key", idx.Path); + } + + [Fact] + public void Parse_SimpleNumericIndex_StillWorksThroughFastPath() + { + var result = InlineExpressionParser.Parse("items[0].price"); + var path = Assert.IsType(result); + Assert.Equal("items[0].price", path.Path); + } + + [Fact] + public void Parse_MissingCloseBracket_ThrowsTemplateEngineException() + { + Assert.Throws(() => InlineExpressionParser.Parse("dict[lang")); + } + + #endregion } From f5dceb32fcd2d8ee092fdf907ac76cda8ee6bf17 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 24 Feb 2026 13:20:59 +0300 Subject: [PATCH 08/10] test: add end-to-end integration tests for dictionary iteration with computed key access Add scope walking to ExpressionEvaluator.Resolve so variables from parent scopes (e.g. root data) are accessible inside each loops. This enables patterns like {{values[@key]}} where @key comes from the loop and values lives at root level. --- .../TemplateEngine/ExpressionEvaluator.cs | 13 +++++++- .../TemplateEngine/TemplateContext.cs | 15 +++++++++ .../TemplateEngine/TemplateExpanderTests.cs | 33 +++++++++++++++++++ .../TemplateProcessorIntegrationTests.cs | 26 +++++++++++++++ 4 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/FlexRender.Core/TemplateEngine/ExpressionEvaluator.cs b/src/FlexRender.Core/TemplateEngine/ExpressionEvaluator.cs index 6ac21ae..e444495 100644 --- a/src/FlexRender.Core/TemplateEngine/ExpressionEvaluator.cs +++ b/src/FlexRender.Core/TemplateEngine/ExpressionEvaluator.cs @@ -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) diff --git a/src/FlexRender.Core/TemplateEngine/TemplateContext.cs b/src/FlexRender.Core/TemplateEngine/TemplateContext.cs index 77a5e8e..bc1f188 100644 --- a/src/FlexRender.Core/TemplateEngine/TemplateContext.cs +++ b/src/FlexRender.Core/TemplateEngine/TemplateContext.cs @@ -12,6 +12,18 @@ public sealed class TemplateContext /// public TemplateValue CurrentScope => _scopeStack.Peek(); + /// + /// Gets a read-only view of the scope stack for scope walking during variable resolution. + /// The first element is the current (innermost) scope, the last is the root. + /// + internal IReadOnlyList Scopes => _scopeList; + + /// + /// Cached list view of the scope stack for efficient scope walking. + /// Kept in sync with the stack via PushScope/PopScope. + /// + private readonly List _scopeList = []; + /// /// Gets the current loop index, or null if not in a loop. /// @@ -41,6 +53,7 @@ public TemplateContext(TemplateValue rootData) { ArgumentNullException.ThrowIfNull(rootData); _scopeStack.Push(rootData); + _scopeList.Add(rootData); } /// @@ -52,6 +65,7 @@ public void PushScope(TemplateValue scope) { ArgumentNullException.ThrowIfNull(scope); _scopeStack.Push(scope); + _scopeList.Add(scope); } /// @@ -65,6 +79,7 @@ public void PopScope() throw new InvalidOperationException("Cannot pop the root scope."); } _scopeStack.Pop(); + _scopeList.RemoveAt(_scopeList.Count - 1); } /// diff --git a/tests/FlexRender.Tests/TemplateEngine/TemplateExpanderTests.cs b/tests/FlexRender.Tests/TemplateEngine/TemplateExpanderTests.cs index 9e2cea8..d8ae226 100644 --- a/tests/FlexRender.Tests/TemplateEngine/TemplateExpanderTests.cs +++ b/tests/FlexRender.Tests/TemplateEngine/TemplateExpanderTests.cs @@ -842,4 +842,37 @@ public void Expand_EachWithStringValue_ReturnsEmpty() Assert.Empty(result.Elements); } + + [Fact] + public void Expand_EachOverObject_WithComputedKeyAccess() + { + // Scenario: iterate over labels dict, look up values from another dict by @key + var data = new ObjectValue + { + ["labels"] = new ObjectValue + { + ["name"] = new StringValue("Name"), + ["price"] = new StringValue("Price") + }, + ["values"] = new ObjectValue + { + ["name"] = new StringValue("Widget"), + ["price"] = new StringValue("$9.99") + } + }; + + var textTemplate = new TextElement { Content = "{{label}}: {{values[@key]}}" }; + var each = new EachElement(new List { textTemplate }) + { + ArrayPath = "labels", + ItemVariable = "label" + }; + var template = CreateTemplate(each); + + var result = _expander.Expand(template, data); + + Assert.Equal(2, result.Elements.Count); + Assert.Equal("Name: Widget", ((TextElement)result.Elements[0]).Content.Value); + Assert.Equal("Price: $9.99", ((TextElement)result.Elements[1]).Content.Value); + } } diff --git a/tests/FlexRender.Tests/TemplateEngine/TemplateProcessorIntegrationTests.cs b/tests/FlexRender.Tests/TemplateEngine/TemplateProcessorIntegrationTests.cs index ceee858..d2a524b 100644 --- a/tests/FlexRender.Tests/TemplateEngine/TemplateProcessorIntegrationTests.cs +++ b/tests/FlexRender.Tests/TemplateEngine/TemplateProcessorIntegrationTests.cs @@ -349,4 +349,30 @@ public void Process_InlineEachOverObject_IndexFirstLast() Assert.Equal("0truefalse 1falsetrue ", result); } + + /// + /// Verifies that inline {{#each}} over an ObjectValue supports computed key access + /// to look up values from another dictionary using @key. + /// + [Fact] + public void Process_InlineEachOverObject_WithComputedKeyAccess() + { + var data = new ObjectValue + { + ["labels"] = new ObjectValue + { + ["name"] = new StringValue("Name"), + ["price"] = new StringValue("Price") + }, + ["values"] = new ObjectValue + { + ["name"] = new StringValue("Widget"), + ["price"] = new StringValue("$9.99") + } + }; + + var result = _processor.Process("{{#each labels}}{{.}}: {{values[@key]}} {{/each}}", data); + + Assert.Equal("Name: Widget Price: $9.99 ", result); + } } From 900351ad2813a51a0619443e428e12295b826b52 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 24 Feb 2026 13:24:51 +0300 Subject: [PATCH 09/10] docs: fix inverted XML doc comment on TemplateContext.Scopes --- src/FlexRender.Core/TemplateEngine/TemplateContext.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FlexRender.Core/TemplateEngine/TemplateContext.cs b/src/FlexRender.Core/TemplateEngine/TemplateContext.cs index bc1f188..9458761 100644 --- a/src/FlexRender.Core/TemplateEngine/TemplateContext.cs +++ b/src/FlexRender.Core/TemplateEngine/TemplateContext.cs @@ -14,7 +14,7 @@ public sealed class TemplateContext /// /// Gets a read-only view of the scope stack for scope walking during variable resolution. - /// The first element is the current (innermost) scope, the last is the root. + /// The first element (index 0) is the root scope, the last element is the current (innermost) scope. /// internal IReadOnlyList Scopes => _scopeList; From 0b8c0ef939a98e7846dcb8307505050ad2ac29e7 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Tue, 24 Feb 2026 13:27:49 +0300 Subject: [PATCH 10/10] docs: add dictionary iteration and computed key access to wiki --- docs/wiki/Template-Expressions.md | 90 +++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 4 deletions(-) diff --git a/docs/wiki/Template-Expressions.md b/docs/wiki/Template-Expressions.md index 4bfb0a3..8c575f7 100644 --- a/docs/wiki/Template-Expressions.md +++ b/docs/wiki/Template-Expressions.md @@ -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. @@ -222,7 +242,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 (`==`, `!=`, `<`, `>`, `<=`, `>=`) | @@ -338,13 +359,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 @@ -353,6 +375,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). @@ -417,7 +453,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 @@ -432,7 +468,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 | @@ -445,6 +481,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 @@ -508,6 +545,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)