diff --git a/CLAUDE.md b/CLAUDE.md
index 13565a9..c8da73c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -312,6 +312,19 @@ The library uses a **visitor pattern** for processing Word documents, enabling:
{{/foreach}}
```
+**Named iteration variable syntax** (for accessing parent scope in nested loops):
+```
+{{#foreach item in CollectionName}}
+ {{item.PropertyName}}
+{{/foreach}}
+
+{{#foreach category in Categories}}
+ {{#foreach product in category.Products}}
+ {{category.Name}}: {{product.Name}} â Access parent loop variable
+ {{/foreach}}
+{{/foreach}}
+```
+
**Loop metadata:** `{{@index}}`, `{{@first}}`, `{{@last}}`, `{{@count}}`
### Markdown Syntax
diff --git a/README.md b/README.md
index baa148a..284da7f 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@ Templify is a focused .NET library built on the OpenXML SDK that enables dynamic
- ⨠Markdown formatting in variable values: `**bold**`, `*italic*`, `~~strikethrough~~`
- âŠī¸ Line breaks in variable values: `"Line 1\nLine 2"` renders as separate lines
- đ Conditional blocks: `{{#if}}...{{#elseif}}...{{#else}}...{{/if}}`
-- đ Loops and iterations: `{{#foreach collection}}...{{/foreach}}`
+- đ Loops and iterations: `{{#foreach collection}}...{{/foreach}}` or `{{#foreach item in collection}}...{{/foreach}}`
- đŗ Nested data structures with dot notation and array indexing
- đ¨ Automatic formatting preservation (bold, italic, fonts, colors)
- đ Full table support including row loops
diff --git a/TriasDev.Templify.Tests/ConditionalEvaluatorTests.cs b/TriasDev.Templify.Tests/ConditionalEvaluatorTests.cs
index 15e879a..a78d82d 100644
--- a/TriasDev.Templify.Tests/ConditionalEvaluatorTests.cs
+++ b/TriasDev.Templify.Tests/ConditionalEvaluatorTests.cs
@@ -866,7 +866,7 @@ public void Evaluate_DeepNestedPath_InLoopContext_WhenItemHasSamePath_ReturnsTru
object loopContext = Activator.CreateInstance(
loopContextType,
- new object[] { firstItem, 0, 1, "data.assets.items", null! })!;
+ new object[] { firstItem, 0, 1, "data.assets.items", null!, null! })!;
IEvaluationContext loopEvalContext = (IEvaluationContext)Activator.CreateInstance(
loopEvalContextType,
diff --git a/TriasDev.Templify.Tests/Integration/NamedIterationVariableIntegrationTests.cs b/TriasDev.Templify.Tests/Integration/NamedIterationVariableIntegrationTests.cs
new file mode 100644
index 0000000..cdd3c59
--- /dev/null
+++ b/TriasDev.Templify.Tests/Integration/NamedIterationVariableIntegrationTests.cs
@@ -0,0 +1,471 @@
+// Copyright (c) 2025 TriasDev GmbH & Co. KG
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+
+using TriasDev.Templify.Core;
+using TriasDev.Templify.Tests.Helpers;
+
+namespace TriasDev.Templify.Tests.Integration;
+
+///
+/// Integration tests for named iteration variable syntax ({{#foreach item in Items}}).
+/// Tests the ability to explicitly reference loop variables and access parent scope.
+///
+public sealed class NamedIterationVariableIntegrationTests
+{
+ // Test data classes
+ private class Category
+ {
+ public string Name { get; set; } = "";
+ public List Products { get; set; } = new List();
+ }
+
+ private class Product
+ {
+ public string Name { get; set; } = "";
+ public decimal Price { get; set; }
+ }
+
+ private class Order
+ {
+ public string OrderId { get; set; } = "";
+ public string CustomerName { get; set; } = "";
+ public List Items { get; set; } = new List();
+ }
+
+ private class LineItem
+ {
+ public string Product { get; set; } = "";
+ public int Quantity { get; set; }
+ }
+
+ [Fact]
+ public void ProcessTemplate_NamedVariable_SimpleLoop()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("Products:");
+ builder.AddParagraph("{{#foreach product in Products}}");
+ builder.AddParagraph("- {{product.Name}}: {{product.Price}} EUR");
+ builder.AddParagraph("{{/foreach}}");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["Products"] = new List
+ {
+ new Product { Name = "Widget", Price = 19.99m },
+ new Product { Name = "Gadget", Price = 29.99m }
+ }
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ List paragraphs = verifier.GetAllParagraphTexts();
+
+ Assert.Equal(3, paragraphs.Count);
+ Assert.Equal("Products:", paragraphs[0]);
+ Assert.Contains("Widget", paragraphs[1]);
+ Assert.Matches(@"19[.,]99", paragraphs[1]); // Handle locale differences (period or comma)
+ Assert.Contains("Gadget", paragraphs[2]);
+ Assert.Matches(@"29[.,]99", paragraphs[2]); // Handle locale differences (period or comma)
+ }
+
+ [Fact]
+ public void ProcessTemplate_NamedVariable_NestedLoops_AccessParent()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("{{#foreach category in Categories}}");
+ builder.AddParagraph("Category: {{category.Name}}");
+ builder.AddParagraph("{{#foreach product in category.Products}}");
+ builder.AddParagraph(" - {{category.Name}}: {{product.Name}} ({{product.Price}} EUR)");
+ builder.AddParagraph("{{/foreach}}");
+ builder.AddParagraph("{{/foreach}}");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["Categories"] = new List
+ {
+ new Category
+ {
+ Name = "Electronics",
+ Products = new List
+ {
+ new Product { Name = "Phone", Price = 599.00m },
+ new Product { Name = "Tablet", Price = 399.00m }
+ }
+ },
+ new Category
+ {
+ Name = "Books",
+ Products = new List
+ {
+ new Product { Name = "C# Guide", Price = 49.99m }
+ }
+ }
+ }
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ List paragraphs = verifier.GetAllParagraphTexts();
+
+ // Expected: Category header + products for Electronics, Category header + products for Books
+ Assert.Contains(paragraphs, p => p.Contains("Category: Electronics"));
+ Assert.Contains(paragraphs, p => p.Contains("Electronics: Phone"));
+ Assert.Contains(paragraphs, p => p.Contains("Electronics: Tablet"));
+ Assert.Contains(paragraphs, p => p.Contains("Category: Books"));
+ Assert.Contains(paragraphs, p => p.Contains("Books: C# Guide"));
+ }
+
+ [Fact]
+ public void ProcessTemplate_NamedVariable_ImplicitSyntaxStillWorks()
+ {
+ // Arrange: Use named variable but also implicit syntax
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("{{#foreach product in Products}}");
+ builder.AddParagraph("Named: {{product.Name}}, Implicit: {{Name}}");
+ builder.AddParagraph("{{/foreach}}");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["Products"] = new List
+ {
+ new Product { Name = "Widget", Price = 19.99m }
+ }
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ List paragraphs = verifier.GetAllParagraphTexts();
+
+ Assert.Single(paragraphs);
+ Assert.Contains("Named: Widget", paragraphs[0]);
+ Assert.Contains("Implicit: Widget", paragraphs[0]);
+ }
+
+ [Fact]
+ public void ProcessTemplate_BackwardCompatibility_ImplicitSyntax()
+ {
+ // Arrange: Old syntax without named variable
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("{{#foreach Products}}");
+ builder.AddParagraph("{{Name}}: {{Price}} EUR");
+ builder.AddParagraph("{{/foreach}}");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["Products"] = new List
+ {
+ new Product { Name = "Widget", Price = 19.99m },
+ new Product { Name = "Gadget", Price = 29.99m }
+ }
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ List paragraphs = verifier.GetAllParagraphTexts();
+
+ Assert.Equal(2, paragraphs.Count);
+ Assert.Contains("Widget", paragraphs[0]);
+ Assert.Contains("Gadget", paragraphs[1]);
+ }
+
+ [Fact]
+ public void ProcessTemplate_NamedVariable_DirectReference()
+ {
+ // Arrange: Reference iteration variable directly (for primitive values)
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("{{#foreach name in Names}}");
+ builder.AddParagraph("- {{name}}");
+ builder.AddParagraph("{{/foreach}}");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["Names"] = new List { "Alice", "Bob", "Charlie" }
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ List paragraphs = verifier.GetAllParagraphTexts();
+
+ Assert.Equal(3, paragraphs.Count);
+ Assert.Equal("- Alice", paragraphs[0]);
+ Assert.Equal("- Bob", paragraphs[1]);
+ Assert.Equal("- Charlie", paragraphs[2]);
+ }
+
+ [Fact]
+ public void ProcessTemplate_NamedVariable_AccessGlobalFromNestedLoop()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("Company: {{CompanyName}}");
+ builder.AddParagraph("{{#foreach order in Orders}}");
+ builder.AddParagraph("Order {{order.OrderId}} for {{CompanyName}}:");
+ builder.AddParagraph("{{#foreach item in order.Items}}");
+ builder.AddParagraph(" - {{item.Product}} x{{item.Quantity}}");
+ builder.AddParagraph("{{/foreach}}");
+ builder.AddParagraph("{{/foreach}}");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["CompanyName"] = "Acme Corp",
+ ["Orders"] = new List
+ {
+ new Order
+ {
+ OrderId = "ORD-001",
+ Items = new List
+ {
+ new LineItem { Product = "Widget", Quantity = 5 }
+ }
+ }
+ }
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ List paragraphs = verifier.GetAllParagraphTexts();
+
+ Assert.Contains(paragraphs, p => p == "Company: Acme Corp");
+ Assert.Contains(paragraphs, p => p.Contains("Order ORD-001 for Acme Corp"));
+ Assert.Contains(paragraphs, p => p.Contains("Widget x5"));
+ }
+
+ [Fact]
+ public void ProcessTemplate_NamedVariable_MixedSyntax_NestedLoops()
+ {
+ // Arrange: Mix of named and implicit syntax
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("{{#foreach category in Categories}}");
+ builder.AddParagraph("{{category.Name}}:");
+ builder.AddParagraph("{{#foreach Products}}"); // Implicit syntax for inner loop
+ builder.AddParagraph(" - {{Name}} from {{category.Name}}");
+ builder.AddParagraph("{{/foreach}}");
+ builder.AddParagraph("{{/foreach}}");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["Categories"] = new List
+ {
+ new Category
+ {
+ Name = "Electronics",
+ Products = new List
+ {
+ new Product { Name = "Phone", Price = 599.00m }
+ }
+ }
+ }
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ List paragraphs = verifier.GetAllParagraphTexts();
+
+ Assert.Contains(paragraphs, p => p.Contains("Electronics:"));
+ Assert.Contains(paragraphs, p => p.Contains("Phone from Electronics"));
+ }
+
+ [Fact]
+ public void ProcessTemplate_NamedVariable_WithLoopMetadata()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("{{#foreach item in Items}}");
+ builder.AddParagraph("{{@index}}: {{item.Name}}{{#if @last}} (last){{/if}}");
+ builder.AddParagraph("{{/foreach}}");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["Items"] = new List
+ {
+ new Product { Name = "First", Price = 10m },
+ new Product { Name = "Second", Price = 20m },
+ new Product { Name = "Third", Price = 30m }
+ }
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ List paragraphs = verifier.GetAllParagraphTexts();
+
+ Assert.Equal(3, paragraphs.Count);
+ Assert.Equal("0: First", paragraphs[0]);
+ Assert.Equal("1: Second", paragraphs[1]);
+ Assert.Equal("2: Third (last)", paragraphs[2]);
+ }
+
+ [Fact]
+ public void ProcessTemplate_NamedVariable_SameNameInNestedLoops_InnerShadowsOuter()
+ {
+ // Arrange: Using the same variable name in nested loops - inner shadows outer
+ // This test documents that local scope takes precedence over parent scope
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("{{#foreach item in Outer}}");
+ builder.AddParagraph("Outer: {{item}}");
+ builder.AddParagraph("{{#foreach item in Inner}}");
+ builder.AddParagraph("Inner: {{item}}");
+ builder.AddParagraph("{{/foreach}}");
+ builder.AddParagraph("{{/foreach}}");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["Outer"] = new List { "A" },
+ ["Inner"] = new List { "X", "Y" }
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ List paragraphs = verifier.GetAllParagraphTexts();
+
+ // Inner loop shadows outer - {{item}} in inner loop resolves to inner value
+ Assert.Contains(paragraphs, p => p == "Outer: A");
+ Assert.Contains(paragraphs, p => p == "Inner: X");
+ Assert.Contains(paragraphs, p => p == "Inner: Y");
+ // Verify outer value is NOT leaked into inner loop
+ Assert.DoesNotContain(paragraphs, p => p == "Inner: A");
+ }
+
+ [Fact]
+ public void ProcessTemplate_NamedVariable_ReservedKeyword_In_ThrowsException()
+ {
+ // Arrange: "in" is a reserved keyword and cannot be used as iteration variable name
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("{{#foreach in in Items}}");
+ builder.AddParagraph("{{in}}");
+ builder.AddParagraph("{{/foreach}}");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["Items"] = new List { "A", "B" }
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act & Assert
+ InvalidOperationException exception = Assert.Throws(
+ () => processor.ProcessTemplate(templateStream, outputStream, data));
+
+ Assert.Contains("'in' is a reserved keyword", exception.Message);
+ }
+
+ [Fact]
+ public void ProcessTemplate_NamedVariable_MetadataPrefix_ThrowsException()
+ {
+ // Arrange: Variable names starting with @ are reserved for loop metadata
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("{{#foreach @item in Items}}");
+ builder.AddParagraph("{{@item}}");
+ builder.AddParagraph("{{/foreach}}");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["Items"] = new List { "A", "B" }
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act & Assert
+ InvalidOperationException exception = Assert.Throws(
+ () => processor.ProcessTemplate(templateStream, outputStream, data));
+
+ Assert.Contains("reserved for loop metadata", exception.Message);
+ }
+}
diff --git a/TriasDev.Templify.Tests/LoopContextPrimitiveTests.cs b/TriasDev.Templify.Tests/LoopContextPrimitiveTests.cs
index 68e0ef3..c5893b5 100644
--- a/TriasDev.Templify.Tests/LoopContextPrimitiveTests.cs
+++ b/TriasDev.Templify.Tests/LoopContextPrimitiveTests.cs
@@ -28,7 +28,7 @@ public void TryResolveVariable_WithDot_ReturnsPrimitiveValue()
{
// Arrange
List items = new List { "Item One", "Item Two", "Item Three" };
- object result = _createContextsMethod.Invoke(null, new object[] { items, "Items", null! })!;
+ object result = _createContextsMethod.Invoke(null, new object[] { items, "Items", null!, null! })!;
IList contexts = (IList)result;
object firstContext = contexts[0]!;
@@ -53,7 +53,7 @@ public void TryResolveVariable_WithThis_ReturnsPrimitiveValue()
{
// Arrange
List items = new List { 10, 20, 30 };
- object result = _createContextsMethod.Invoke(null, new object[] { items, "Numbers", null! })!;
+ object result = _createContextsMethod.Invoke(null, new object[] { items, "Numbers", null!, null! })!;
IList contexts = (IList)result;
object firstContext = contexts[0]!;
@@ -71,7 +71,7 @@ public void TryResolveVariable_WithDot_WorksWithDecimals()
{
// Arrange
List items = new List { 99.99m, 149.99m, 249.99m };
- object result = _createContextsMethod.Invoke(null, new object[] { items, "Prices", null! })!;
+ object result = _createContextsMethod.Invoke(null, new object[] { items, "Prices", null!, null! })!;
IList contexts = (IList)result;
object lastContext = contexts[2]!;
diff --git a/TriasDev.Templify.Tests/LoopContextTests.cs b/TriasDev.Templify.Tests/LoopContextTests.cs
index c118256..de976f2 100644
--- a/TriasDev.Templify.Tests/LoopContextTests.cs
+++ b/TriasDev.Templify.Tests/LoopContextTests.cs
@@ -38,7 +38,7 @@ public void CreateContexts_WithSimpleList_CreatesCorrectContexts()
string collectionName = "Items";
// Act
- object result = _createContextsMethod.Invoke(null, new object[] { items, collectionName, null! })!;
+ object result = _createContextsMethod.Invoke(null, new object[] { items, collectionName, null!, null! })!;
IList contexts = (IList)result;
// Assert
@@ -69,7 +69,7 @@ public void CreateContexts_WithEmptyCollection_ReturnsEmptyList()
string collectionName = "Items";
// Act
- object result = _createContextsMethod.Invoke(null, new object[] { items, collectionName, null! })!;
+ object result = _createContextsMethod.Invoke(null, new object[] { items, collectionName, null!, null! })!;
IList contexts = (IList)result;
// Assert
@@ -81,7 +81,7 @@ public void TryResolveVariable_WithMetadata_ReturnsCorrectValues()
{
// Arrange
List items = new List { "First", "Second" };
- object result = _createContextsMethod.Invoke(null, new object[] { items, "Items", null! })!;
+ object result = _createContextsMethod.Invoke(null, new object[] { items, "Items", null!, null! })!;
IList contexts = (IList)result;
object context = contexts[0]!;
@@ -118,7 +118,7 @@ public void TryResolveVariable_WithSimpleProperty_ReturnsValue()
{
new TestItem { Name = "Item1", Value = 100 }
};
- object result = _createContextsMethod.Invoke(null, new object[] { items, "Items", null! })!;
+ object result = _createContextsMethod.Invoke(null, new object[] { items, "Items", null!, null! })!;
IList contexts = (IList)result;
object context = contexts[0]!;
@@ -143,7 +143,7 @@ public void TryResolveVariable_WithNestedProperty_ReturnsValue()
Address = new Address { City = "Munich" }
}
};
- object result = _createContextsMethod.Invoke(null, new object[] { items, "Customers", null! })!;
+ object result = _createContextsMethod.Invoke(null, new object[] { items, "Customers", null!, null! })!;
IList contexts = (IList)result;
object context = contexts[0]!;
@@ -164,7 +164,7 @@ public void TryResolveVariable_WithInvalidProperty_ReturnsFalse()
{
new TestItem { Name = "Item1", Value = 100 }
};
- object result = _createContextsMethod.Invoke(null, new object[] { items, "Items", null! })!;
+ object result = _createContextsMethod.Invoke(null, new object[] { items, "Items", null!, null! })!;
IList contexts = (IList)result;
object context = contexts[0]!;
diff --git a/TriasDev.Templify.Tests/LoopEvaluationContextTests.cs b/TriasDev.Templify.Tests/LoopEvaluationContextTests.cs
index a606e2d..e00e76d 100644
--- a/TriasDev.Templify.Tests/LoopEvaluationContextTests.cs
+++ b/TriasDev.Templify.Tests/LoopEvaluationContextTests.cs
@@ -307,6 +307,7 @@ private static object CreateLoopContext(
index,
count,
collectionName,
+ null, // iterationVariableName
parent)!;
}
diff --git a/TriasDev.Templify.Tests/Visitors/CompositeVisitorTests.cs b/TriasDev.Templify.Tests/Visitors/CompositeVisitorTests.cs
index 82e1208..586c0d3 100644
--- a/TriasDev.Templify.Tests/Visitors/CompositeVisitorTests.cs
+++ b/TriasDev.Templify.Tests/Visitors/CompositeVisitorTests.cs
@@ -286,6 +286,7 @@ private static LoopBlock CreateTestLoopBlock()
return new LoopBlock(
collectionName: "Items",
+ iterationVariableName: null,
contentElements: content,
startMarker: startMarker,
endMarker: endMarker,
diff --git a/TriasDev.Templify.Tests/Visitors/ConditionalVisitorTests.cs b/TriasDev.Templify.Tests/Visitors/ConditionalVisitorTests.cs
index 6dc4cf0..d5bc0e4 100644
--- a/TriasDev.Templify.Tests/Visitors/ConditionalVisitorTests.cs
+++ b/TriasDev.Templify.Tests/Visitors/ConditionalVisitorTests.cs
@@ -342,6 +342,7 @@ private static LoopBlock CreateTestLoopBlock()
return new LoopBlock(
collectionName: "Items",
+ iterationVariableName: null,
contentElements: content,
startMarker: startMarker,
endMarker: endMarker,
diff --git a/TriasDev.Templify.Tests/Visitors/LoopVisitorTests.cs b/TriasDev.Templify.Tests/Visitors/LoopVisitorTests.cs
index 5a19fca..5c99072 100644
--- a/TriasDev.Templify.Tests/Visitors/LoopVisitorTests.cs
+++ b/TriasDev.Templify.Tests/Visitors/LoopVisitorTests.cs
@@ -31,6 +31,7 @@ public void VisitLoop_EmptyCollection_RemovesLoopBlock()
LoopBlock loop = new LoopBlock(
collectionName: "Items",
+ iterationVariableName: null,
contentElements: new List { content },
startMarker: startMarker,
endMarker: endMarker,
@@ -67,6 +68,7 @@ public void VisitLoop_MissingCollection_RemovesLoopBlock()
LoopBlock loop = new LoopBlock(
collectionName: "Items",
+ iterationVariableName: null,
contentElements: new List { content },
startMarker: startMarker,
endMarker: endMarker,
@@ -100,6 +102,7 @@ public void VisitLoop_NonCollectionVariable_ThrowsInvalidOperationException()
LoopBlock loop = new LoopBlock(
collectionName: "Items",
+ iterationVariableName: null,
contentElements: new List { content },
startMarker: startMarker,
endMarker: endMarker,
@@ -137,6 +140,7 @@ public void VisitLoop_SimpleCollection_ExpandsLoopContent()
LoopBlock loop = new LoopBlock(
collectionName: "Items",
+ iterationVariableName: null,
contentElements: new List { content },
startMarker: startMarker,
endMarker: endMarker,
@@ -176,6 +180,7 @@ public void VisitLoop_MultipleContentElements_ClonesAll()
LoopBlock loop = new LoopBlock(
collectionName: "Items",
+ iterationVariableName: null,
contentElements: new List { content1, content2 },
startMarker: startMarker,
endMarker: endMarker,
@@ -221,6 +226,7 @@ public void VisitLoop_CreatesLoopEvaluationContext()
LoopBlock loop = new LoopBlock(
collectionName: "Items",
+ iterationVariableName: null,
contentElements: new List { content },
startMarker: startMarker,
endMarker: endMarker,
@@ -258,6 +264,7 @@ public void VisitLoop_ProcessesNestedConstructsViaDocumentWalker()
LoopBlock loop = new LoopBlock(
collectionName: "Items",
+ iterationVariableName: null,
contentElements: new List { content },
startMarker: startMarker,
endMarker: endMarker,
@@ -297,6 +304,7 @@ public void VisitLoop_SingleItem_CreatesOneIteration()
LoopBlock loop = new LoopBlock(
collectionName: "Items",
+ iterationVariableName: null,
contentElements: new List { content },
startMarker: startMarker,
endMarker: endMarker,
diff --git a/TriasDev.Templify.Tests/Visitors/PlaceholderVisitorTests.cs b/TriasDev.Templify.Tests/Visitors/PlaceholderVisitorTests.cs
index 779997f..a839b6c 100644
--- a/TriasDev.Templify.Tests/Visitors/PlaceholderVisitorTests.cs
+++ b/TriasDev.Templify.Tests/Visitors/PlaceholderVisitorTests.cs
@@ -354,6 +354,7 @@ private static LoopBlock CreateTestLoopBlock()
return new LoopBlock(
collectionName: "Items",
+ iterationVariableName: null,
contentElements: content,
startMarker: startMarker,
endMarker: endMarker,
diff --git a/TriasDev.Templify.Tests/Visitors/TemplateElementTests.cs b/TriasDev.Templify.Tests/Visitors/TemplateElementTests.cs
index 6264512..5e84353 100644
--- a/TriasDev.Templify.Tests/Visitors/TemplateElementTests.cs
+++ b/TriasDev.Templify.Tests/Visitors/TemplateElementTests.cs
@@ -204,6 +204,7 @@ private static LoopBlock CreateTestLoopBlock()
return new LoopBlock(
collectionName: "Items",
+ iterationVariableName: null,
contentElements: content,
startMarker: startMarker,
endMarker: endMarker,
diff --git a/TriasDev.Templify/Core/TemplateValidator.cs b/TriasDev.Templify/Core/TemplateValidator.cs
index 5a42519..5776ec9 100644
--- a/TriasDev.Templify/Core/TemplateValidator.cs
+++ b/TriasDev.Templify/Core/TemplateValidator.cs
@@ -220,8 +220,8 @@ private static void ValidateMissingVariables(
bool warnOnEmptyLoopCollections)
{
ValueResolver resolver = new ValueResolver();
- Stack<(string CollectionName, HashSet Properties)> loopStack =
- new Stack<(string CollectionName, HashSet Properties)>();
+ Stack<(string CollectionName, string? IterationVariableName, HashSet Properties)> loopStack =
+ new Stack<(string CollectionName, string? IterationVariableName, HashSet Properties)>();
// Note: We reuse allPlaceholders from steps 1-4 (conditionals, loops, table loops, regular placeholders).
// ValidatePlaceholdersInScope will add any additional placeholders found during recursive processing.
@@ -288,7 +288,7 @@ private static string NormalizeQuotes(string text)
///
private static void ValidatePlaceholdersInScope(
IReadOnlyList elements,
- Stack<(string CollectionName, HashSet Properties)> loopStack,
+ Stack<(string CollectionName, string? IterationVariableName, HashSet Properties)> loopStack,
Dictionary data,
HashSet allPlaceholders,
HashSet missingVariables,
@@ -370,7 +370,7 @@ private static void ValidatePlaceholdersInScope(
///
private static void ProcessLoopForValidation(
LoopBlock loop,
- Stack<(string CollectionName, HashSet Properties)> loopStack,
+ Stack<(string CollectionName, string? IterationVariableName, HashSet Properties)> loopStack,
Dictionary data,
HashSet allPlaceholders,
HashSet missingVariables,
@@ -420,7 +420,7 @@ private static void ProcessLoopForValidation(
}
// Recurse into loop content with aggregated properties as scope
- loopStack.Push((loop.CollectionName, aggregatedProperties));
+ loopStack.Push((loop.CollectionName, loop.IterationVariableName, aggregatedProperties));
ValidatePlaceholdersInScope(loop.ContentElements, loopStack, data, allPlaceholders, missingVariables, warnings, errors, resolver, warnOnEmptyLoopCollections);
loopStack.Pop();
}
@@ -430,9 +430,9 @@ private static void ProcessLoopForValidation(
///
private static bool IsLoopScopedProperty(
string name,
- Stack<(string CollectionName, HashSet Properties)> loopStack)
+ Stack<(string CollectionName, string? IterationVariableName, HashSet Properties)> loopStack)
{
- foreach ((string _, HashSet properties) in loopStack)
+ foreach ((string _, string? _, HashSet properties) in loopStack)
{
if (properties.Contains(name))
{
@@ -521,14 +521,39 @@ private static HashSet AggregatePropertiesFromCollection(object collecti
///
private static bool CanResolveInScope(
string placeholder,
- Stack<(string CollectionName, HashSet Properties)> loopStack,
+ Stack<(string CollectionName, string? IterationVariableName, HashSet Properties)> loopStack,
Dictionary data,
ValueResolver resolver)
{
// Try loop scopes (innermost first - stack iteration goes from top to bottom)
- foreach ((string _, HashSet properties) in loopStack)
+ foreach ((string _, string? iterationVariableName, HashSet properties) in loopStack)
{
- // Direct property match
+ // Check if accessing via named iteration variable (e.g., "item" or "item.Name")
+ if (iterationVariableName != null)
+ {
+ // Direct reference to iteration variable (e.g., {{item}})
+ if (placeholder == iterationVariableName)
+ {
+ return true;
+ }
+
+ // Property access via iteration variable (e.g., {{item.Name}})
+ // Cache prefix to avoid repeated string concatenation
+ string iterationVariablePrefix = iterationVariableName + ".";
+ if (placeholder.StartsWith(iterationVariablePrefix, StringComparison.Ordinal))
+ {
+ string propertyPath = placeholder.Substring(iterationVariablePrefix.Length);
+ // Extract root property from the path
+ int nextDotIndex = propertyPath.IndexOf('.');
+ string rootProperty = nextDotIndex > 0 ? propertyPath.Substring(0, nextDotIndex) : propertyPath;
+ if (properties.Contains(rootProperty))
+ {
+ return true;
+ }
+ }
+ }
+
+ // Direct property match (implicit syntax)
if (properties.Contains(placeholder))
{
return true;
diff --git a/TriasDev.Templify/Loops/LoopBlock.cs b/TriasDev.Templify/Loops/LoopBlock.cs
index c049572..e2348d7 100644
--- a/TriasDev.Templify/Loops/LoopBlock.cs
+++ b/TriasDev.Templify/Loops/LoopBlock.cs
@@ -7,7 +7,8 @@ namespace TriasDev.Templify.Loops;
///
/// Represents a parsed loop block in the document template.
-/// Supports {{#foreach CollectionName}}...{{/foreach}} syntax.
+/// Supports {{#foreach CollectionName}}...{{/foreach}} syntax
+/// and {{#foreach item in CollectionName}}...{{/foreach}} for named iteration variables.
///
internal sealed class LoopBlock
{
@@ -16,6 +17,13 @@ internal sealed class LoopBlock
///
public string CollectionName { get; }
+ ///
+ /// Gets the name of the iteration variable, or null if using implicit syntax.
+ /// For {{#foreach item in Items}}, this is "item".
+ /// For {{#foreach Items}}, this is null (implicit).
+ ///
+ public string? IterationVariableName { get; }
+
///
/// Gets the OpenXML elements that make up the loop content.
/// These elements will be cloned for each item in the collection.
@@ -44,6 +52,7 @@ internal sealed class LoopBlock
public LoopBlock(
string collectionName,
+ string? iterationVariableName,
IReadOnlyList contentElements,
OpenXmlElement startMarker,
OpenXmlElement endMarker,
@@ -51,6 +60,7 @@ public LoopBlock(
LoopBlock? emptyBlock = null)
{
CollectionName = collectionName ?? throw new ArgumentNullException(nameof(collectionName));
+ IterationVariableName = iterationVariableName;
ContentElements = contentElements ?? throw new ArgumentNullException(nameof(contentElements));
StartMarker = startMarker ?? throw new ArgumentNullException(nameof(startMarker));
EndMarker = endMarker ?? throw new ArgumentNullException(nameof(endMarker));
diff --git a/TriasDev.Templify/Loops/LoopContext.cs b/TriasDev.Templify/Loops/LoopContext.cs
index 5a5d8f3..ab75201 100644
--- a/TriasDev.Templify/Loops/LoopContext.cs
+++ b/TriasDev.Templify/Loops/LoopContext.cs
@@ -32,11 +32,24 @@ internal sealed class LoopContext
///
public string CollectionName { get; }
+ ///
+ /// Gets the name of the iteration variable, or null if using implicit syntax.
+ /// For {{#foreach item in Items}}, this is "item".
+ /// For {{#foreach Items}}, this is null (implicit).
+ ///
+ public string? IterationVariableName { get; }
+
///
/// Gets the parent loop context (for nested loops).
///
public LoopContext? Parent { get; }
+ ///
+ /// Cached prefix for iteration variable property access (e.g., "item.").
+ /// Null if using implicit syntax.
+ ///
+ private readonly string? _iterationVariablePrefix;
+
///
/// Gets whether this is the first item in the collection.
///
@@ -52,12 +65,15 @@ public LoopContext(
int index,
int count,
string collectionName,
+ string? iterationVariableName = null,
LoopContext? parent = null)
{
CurrentItem = currentItem ?? throw new ArgumentNullException(nameof(currentItem));
Index = index;
Count = count;
CollectionName = collectionName ?? throw new ArgumentNullException(nameof(collectionName));
+ IterationVariableName = iterationVariableName;
+ _iterationVariablePrefix = iterationVariableName != null ? iterationVariableName + "." : null;
Parent = parent;
}
@@ -67,6 +83,7 @@ public LoopContext(
public static IReadOnlyList CreateContexts(
IEnumerable collection,
string collectionName,
+ string? iterationVariableName = null,
LoopContext? parent = null)
{
if (collection == null)
@@ -83,7 +100,7 @@ public static IReadOnlyList CreateContexts(
List contexts = new List(items.Count);
for (int i = 0; i < items.Count; i++)
{
- contexts.Add(new LoopContext(items[i], i, items.Count, collectionName, parent));
+ contexts.Add(new LoopContext(items[i], i, items.Count, collectionName, iterationVariableName, parent));
}
return contexts;
@@ -91,8 +108,24 @@ public static IReadOnlyList CreateContexts(
///
/// Tries to resolve a variable in this context or parent contexts.
- /// Supports direct property access ({{Name}}) and metadata ({{@index}}).
+ /// Supports direct property access ({{Name}}), named iteration variable access ({{item.Name}}),
+ /// and metadata ({{@index}}).
///
+ ///
+ /// Variable resolution follows this precedence order (first match wins):
+ ///
+ /// - Loop metadata (@index, @first, @last, @count)
+ /// - Named iteration variable direct reference (e.g., {{item}} when using "item in Items")
+ /// - Named iteration variable property access (e.g., {{item.Name}})
+ /// - Current item property (implicit syntax, e.g., {{Name}})
+ /// - Parent loop context (recursive, for nested loop variable access)
+ ///
+ ///
+ /// This means local scope always takes precedence over parent scope. If the current item
+ /// has a property with the same name as a parent loop's iteration variable, the current
+ /// item's property will be resolved first.
+ ///
+ ///
public bool TryResolveVariable(string variableName, out object? value)
{
// Check for loop metadata variables
@@ -101,13 +134,31 @@ public bool TryResolveVariable(string variableName, out object? value)
return TryResolveMetadata(variableName, out value);
}
- // Try to resolve from current item first
+ // Check if accessing via named iteration variable (e.g., "item" or "item.Name")
+ if (IterationVariableName != null)
+ {
+ // Direct reference to iteration variable (e.g., {{item}})
+ if (variableName == IterationVariableName)
+ {
+ value = CurrentItem;
+ return true;
+ }
+
+ // Property access via iteration variable (e.g., {{item.Name}})
+ if (variableName.StartsWith(_iterationVariablePrefix!, StringComparison.Ordinal))
+ {
+ string propertyPath = variableName.Substring(_iterationVariablePrefix!.Length);
+ return TryResolveFromCurrentItem(propertyPath, out value);
+ }
+ }
+
+ // Try to resolve from current item (implicit syntax - backward compatible)
if (TryResolveFromCurrentItem(variableName, out value))
{
return true;
}
- // Try parent context
+ // Try parent context (for nested loop variable access like {{category.Name}} from inner loop)
if (Parent != null && Parent.TryResolveVariable(variableName, out value))
{
return true;
diff --git a/TriasDev.Templify/Loops/LoopDetector.cs b/TriasDev.Templify/Loops/LoopDetector.cs
index cd62e03..bf5f282 100644
--- a/TriasDev.Templify/Loops/LoopDetector.cs
+++ b/TriasDev.Templify/Loops/LoopDetector.cs
@@ -10,14 +10,26 @@ namespace TriasDev.Templify.Loops;
///
/// Detects and parses loop blocks in Word documents.
-/// Supports {{#foreach CollectionName}}...{{/foreach}} syntax.
+/// Supports {{#foreach CollectionName}}...{{/foreach}} syntax
+/// and {{#foreach item in CollectionName}}...{{/foreach}} for named iteration variables.
///
internal static class LoopDetector
{
+ // Note: The @? in the regex allows capturing invalid variable names starting with @
+ // so we can provide a helpful validation error message instead of silently not matching.
private static readonly Regex _foreachStartPattern = new Regex(
- @"\{\{#foreach\s+([\w.]+)\}\}",
+ @"\{\{#foreach\s+(?:(@?\w+)\s+in\s+)?([\w.]+)\}\}",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
+ ///
+ /// Reserved variable names that cannot be used as iteration variable names.
+ /// These conflict with loop metadata syntax.
+ ///
+ private static readonly HashSet _reservedVariableNames = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "in" // Reserved keyword in loop syntax
+ };
+
private static readonly Regex _foreachEndPattern = new Regex(
@"\{\{/foreach\}\}",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
@@ -30,6 +42,31 @@ internal static class LoopDetector
@"\{\{/empty\}\}",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
+ ///
+ /// Validates an iteration variable name and throws if invalid.
+ ///
+ /// The variable name to validate.
+ /// The collection name for error messages.
+ /// Thrown when the variable name is invalid.
+ private static void ValidateIterationVariableName(string variableName, string collectionName)
+ {
+ // Check for reserved names (like "in")
+ if (_reservedVariableNames.Contains(variableName))
+ {
+ throw new InvalidOperationException(
+ $"Invalid iteration variable name '{variableName}' in '{{{{#foreach {variableName} in {collectionName}}}}}'. " +
+ $"'{variableName}' is a reserved keyword.");
+ }
+
+ // Check for metadata prefix (@)
+ if (variableName.StartsWith("@", StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ $"Invalid iteration variable name '{variableName}' in '{{{{#foreach {variableName} in {collectionName}}}}}'. " +
+ $"Iteration variable names cannot start with '@' as this is reserved for loop metadata.");
+ }
+ }
+
///
/// Detects all loop blocks in the document body.
///
@@ -77,7 +114,18 @@ internal static IReadOnlyList DetectLoopsInElements(List 0
+ ? foreachMatch.Groups[1].Value
+ : null;
+ string collectionName = foreachMatch.Groups[2].Value;
+
+ // Validate iteration variable name if provided
+ if (iterationVariableName != null)
+ {
+ ValidateIterationVariableName(iterationVariableName, collectionName);
+ }
// Find the matching end marker
int endIndex = FindMatchingEnd(elements, i);
@@ -97,6 +145,7 @@ internal static IReadOnlyList DetectLoopsInElements(List DetectTableRowLoops(Table table)
Match foreachMatch = _foreachStartPattern.Match(text);
if (foreachMatch.Success)
{
- string collectionName = foreachMatch.Groups[1].Value;
+ // Group 1: optional iteration variable (e.g., "item" from "item in Items")
+ // Group 2: collection name (e.g., "Items")
+ string? iterationVariableName = foreachMatch.Groups[1].Success && foreachMatch.Groups[1].Length > 0
+ ? foreachMatch.Groups[1].Value
+ : null;
+ string collectionName = foreachMatch.Groups[2].Value;
+
+ // Validate iteration variable name if provided
+ if (iterationVariableName != null)
+ {
+ ValidateIterationVariableName(iterationVariableName, collectionName);
+ }
// Check if this specific loop is contained in a single cell
// If so, skip it - it will be processed when the cell content is walked
@@ -270,6 +330,7 @@ internal static IReadOnlyList DetectTableRowLoops(Table table)
// Create loop block for table row loop
LoopBlock loopBlock = new LoopBlock(
collectionName,
+ iterationVariableName,
contentRows,
rows[i], // Start marker row
rows[endIndex], // End marker row
diff --git a/TriasDev.Templify/Loops/LoopProcessor.cs b/TriasDev.Templify/Loops/LoopProcessor.cs
index 4e6f9dd..68bb362 100644
--- a/TriasDev.Templify/Loops/LoopProcessor.cs
+++ b/TriasDev.Templify/Loops/LoopProcessor.cs
@@ -406,6 +406,7 @@ private int ProcessNestedLoops(
return new LoopBlock(
collectionName,
+ iterationVariableName: null,
contentElements,
elements[startIndex],
elements[endIndex],
diff --git a/TriasDev.Templify/Visitors/LoopVisitor.cs b/TriasDev.Templify/Visitors/LoopVisitor.cs
index dcfef17..ab4116e 100644
--- a/TriasDev.Templify/Visitors/LoopVisitor.cs
+++ b/TriasDev.Templify/Visitors/LoopVisitor.cs
@@ -77,6 +77,7 @@ public void VisitLoop(LoopBlock loop, IEvaluationContext context)
IReadOnlyList contexts = LoopContext.CreateContexts(
collection,
loop.CollectionName,
+ loop.IterationVariableName,
parent: null);
// Handle empty collection
diff --git a/docs/for-template-authors/loops.md b/docs/for-template-authors/loops.md
index 6fa749c..f91b2e5 100644
--- a/docs/for-template-authors/loops.md
+++ b/docs/for-template-authors/loops.md
@@ -4,6 +4,8 @@ Loops let you repeat content for each item in a list. They're essential for crea
## Basic Loop Syntax
+### Implicit Syntax (Simple)
+
```
{{#foreach ArrayName}}
Content to repeat for each item
@@ -12,6 +14,16 @@ Loops let you repeat content for each item in a list. They're essential for crea
The content between `{{#foreach}}` and `{{/foreach}}` will be repeated once for each item in the array.
+### Named Iteration Variable Syntax (Explicit)
+
+```
+{{#foreach item in ArrayName}}
+ {{item.Property}}
+{{/foreach}}
+```
+
+This syntax gives the current item an explicit name (`item`), which you can use to access its properties. This is especially useful in [nested loops](#named-iteration-variables) where you need to access parent loop variables.
+
## Simple Lists
### Looping Through Text Items
@@ -590,7 +602,7 @@ Category: Electronics
Item: Mouse
```
-â ī¸ **Important:** Inside the inner loop, there is **no way** to access the outer `Name` because it's shadowed by the inner `Name`.
+â ī¸ **Important:** Inside the inner loop, there is **no way** to access the outer `Name` using implicit syntax because it's shadowed by the inner `Name`. However, you can use [named iteration variables](#named-iteration-variables) to solve this problem.
### Best Practice: Use Unique Property Names
@@ -635,6 +647,69 @@ Category: {{CategoryName}}
{{/foreach}}
```
+### Named Iteration Variables
+
+An alternative to renaming properties is to use **named iteration variables**. This syntax lets you give each loop item an explicit name, making it possible to access parent loop variables even when property names conflict.
+
+**Syntax:**
+```
+{{#foreach variableName in CollectionName}}
+ {{variableName.Property}}
+{{/foreach}}
+```
+
+**Example with shadowed names:**
+
+**JSON:**
+```json
+{
+ "Categories": [
+ {
+ "Name": "Electronics",
+ "Items": [
+ { "Name": "Laptop" },
+ { "Name": "Mouse" }
+ ]
+ }
+ ]
+}
+```
+
+**Template using named iteration variables:**
+```
+{{#foreach category in Categories}}
+Category: {{category.Name}}
+{{#foreach item in category.Items}}
+ Item: {{item.Name}}
+ Category: {{category.Name}} â Parent accessible via named variable!
+{{/foreach}}
+{{/foreach}}
+```
+
+**Output:**
+```
+Category: Electronics
+ Item: Laptop
+ Category: Electronics
+ Item: Mouse
+ Category: Electronics
+```
+
+**Key benefits:**
+- Access parent loop variables even when names conflict
+- More explicit and readable templates
+- Both `{{item.Name}}` (explicit) and `{{Name}}` (implicit) work within the loop
+
+**Mixed syntax example:**
+```
+{{#foreach category in Categories}}
+{{category.Name}}: â Named variable
+{{#foreach Items}} â Implicit syntax for inner loop
+ - {{Name}} ({{category.Name}}) â Implicit + parent named variable
+{{/foreach}}
+{{/foreach}}
+```
+
### Accessing Global Variables in Nested Loops
Global variables remain accessible at any nesting depth (unless shadowed):
@@ -729,7 +804,7 @@ The `{{#if IsPremium}}` condition checks the parent category's property from wit
| Property only on parent item | â
Found after checking current |
| Property only in global context | â
Found after checking all loop levels |
| Same property name at multiple levels | â ī¸ Innermost wins (shadowing) |
-| Need to access shadowed property | â Not possible - use unique names |
+| Need to access shadowed property | â
Use [named iteration variables](#named-iteration-variables) or unique property names |
## Empty Arrays