From 8bd7123b405b1dc1828b13b6468768d45f81ae62 Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Wed, 5 Aug 2026 22:22:57 -0400 Subject: [PATCH] feat: support System.Text.Json JsonElement in templates (issue #591) Untyped objects deserialized via System.Text.Json.JsonSerializer.Deserialize produce JsonElement, which previously had no member access, iteration, or truthiness support, unlike Newtonsoft's JObject/JToken. Adds a JsonElementObjectDescriptorProvider (member accessor + iterator) and teaches HandlebarsUtils.IsFalsy JSON truthiness semantics so {{#if}}/{{#unless}} behave correctly on JsonElement values. Also reorganizes tests: per-issue test files (source/Handlebars.Test/Issues/*.cs) are folded into the capability-based test files that already cover that behavior, and AGENTS.md documents the convention going forward so future test additions land in the right place instead of spawning new issue-numbered files. Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 18 ++ .../Handlebars.Test/BasicIntegrationTests.cs | 60 ++++++ .../CustomConfigurationTests.cs | 77 +++++++ source/Handlebars.Test/DynamicTests.cs | 123 ++++++++++++ .../HandlebarsSpecCoverageTests.cs | 70 +++++++ source/Handlebars.Test/HelperTests.cs | 188 +++++++++++++++++ .../Handlebars.Test/Issues/Issue285Tests.cs | 104 ---------- .../Handlebars.Test/Issues/Issue434Tests.cs | 20 -- .../Handlebars.Test/Issues/Issue455Tests.cs | 24 --- .../Handlebars.Test/Issues/Issue458Tests.cs | 41 ---- .../Handlebars.Test/Issues/Issue459Tests.cs | 18 -- .../Handlebars.Test/Issues/Issue543Tests.cs | 26 --- .../Handlebars.Test/Issues/Issue559Tests.cs | 44 ---- .../Handlebars.Test/Issues/Issue581Tests.cs | 120 ----------- .../Handlebars.Test/Issues/Issue582Tests.cs | 101 ---------- .../Handlebars.Test/Issues/Issue595Tests.cs | 160 --------------- .../Handlebars.Test/Issues/Issue614Tests.cs | 190 ------------------ source/Handlebars.Test/PartialTests.cs | 175 ++++++++++++++++ .../HandlebarsConfigurationAdapter.cs | 1 + source/Handlebars/Handlebars.csproj | 1 + source/Handlebars/HandlebarsUtils.cs | 32 ++- .../Iterators/JsonElementIterator.cs | 140 +++++++++++++ .../JsonElementMemberAccessor.cs | 33 +++ .../JsonElementObjectDescriptorProvider.cs | 46 +++++ 24 files changed, 962 insertions(+), 850 deletions(-) create mode 100644 AGENTS.md delete mode 100644 source/Handlebars.Test/Issues/Issue285Tests.cs delete mode 100644 source/Handlebars.Test/Issues/Issue434Tests.cs delete mode 100644 source/Handlebars.Test/Issues/Issue455Tests.cs delete mode 100644 source/Handlebars.Test/Issues/Issue458Tests.cs delete mode 100644 source/Handlebars.Test/Issues/Issue459Tests.cs delete mode 100644 source/Handlebars.Test/Issues/Issue543Tests.cs delete mode 100644 source/Handlebars.Test/Issues/Issue559Tests.cs delete mode 100644 source/Handlebars.Test/Issues/Issue581Tests.cs delete mode 100644 source/Handlebars.Test/Issues/Issue582Tests.cs delete mode 100644 source/Handlebars.Test/Issues/Issue595Tests.cs delete mode 100644 source/Handlebars.Test/Issues/Issue614Tests.cs create mode 100644 source/Handlebars/Iterators/JsonElementIterator.cs create mode 100644 source/Handlebars/MemberAccessors/JsonElementMemberAccessor.cs create mode 100644 source/Handlebars/ObjectDescriptors/JsonElementObjectDescriptorProvider.cs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..8d42529e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +# Agent instructions + +## Test organization + +Name test files and classes after the **capability or use case** under test (e.g. +`PartialTests`, `DynamicTests`, `HelperTests`, `HandlebarsSpecCoverageTests`, +`CustomConfigurationTests`) — not after the GitHub issue that prompted them. + +- Do not create `IssueNNNTests.cs` files or an `Issues/` folder. +- When a test is added because of a bug report, put it in the existing capability + file that owns that behavior. If none fits, create a new capability-named file + rather than an issue-numbered one. +- If the originating issue is worth referencing, link it in a `//` comment above + the test method or class — not in the file or class name. + +Issue numbers are meaningless once the bug is fixed; the capability is what +matters long-term, and grouping by capability keeps related coverage +discoverable together instead of scattered across one-off files. diff --git a/source/Handlebars.Test/BasicIntegrationTests.cs b/source/Handlebars.Test/BasicIntegrationTests.cs index 0f0ae3f5..0c64e9b3 100644 --- a/source/Handlebars.Test/BasicIntegrationTests.cs +++ b/source/Handlebars.Test/BasicIntegrationTests.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Dynamic; +using System.IO; using System.Linq; using System.Reflection; using HandlebarsDotNet.Compiler; @@ -2232,6 +2233,65 @@ public void HtmlEncoderCompatibilityIntegration_LateChangeConfig(bool useLegacyH Assert.Equal(expected, actual); } + [Fact] + public void BasicCompileAndRenderWithoutByRefDelegate() + { + // Validates the scenario that fails on Mono when byref delegates are used + var h = Handlebars.Create(); + var render = h.Compile("{{input}}"); + var result = render(new { input = 42 }); + Assert.Equal("42", result); + } + + [Fact] + public void BlockHelperCompileAndRenderWithoutByRefDelegate() + { + // Block helpers also exercise TemplateDelegate compilation + var h = Handlebars.Create(); + h.RegisterHelper("loud", (writer, options, context, arguments) => + { + options.Template(writer, context); + }); + var render = h.Compile("{{#loud}}hello{{/loud}}"); + var result = render(new { }); + Assert.Equal("hello", result); + } + + [Fact] + public void CompileWithTextReaderProducesOutput() + { + var h = Handlebars.Create(); + using var reader = new StringReader("Hello {{name}}!"); + var template = h.Compile(reader); + using var writer = new StringWriter(); + template(writer, new { name = "World" }, null); + Assert.Equal("Hello World!", writer.ToString()); + } + + [Fact] + public void SharedEnvironmentCompileAndRenderNeverSilentlyFails() + { + // Regression: broad exception swallowing in observable collection publish paths + // could prevent helper/template registrations from propagating in restricted + // runtime environments (e.g. .NET 8 Windows Service), causing silent empty output. + var h = Handlebars.Create(); + var shared = h.CreateSharedEnvironment(); + + var template = shared.Compile("Hello {{name}}!"); + var result = template(new { name = "World" }); + Assert.Equal("Hello World!", result); + } + + [Fact] + public void EmptyTemplateProducesEmptyStringNotNull() + { + var h = Handlebars.Create(); + var template = h.Compile(""); + var result = template(new { }); + Assert.NotNull(result); + Assert.Equal("", result); + } + [Fact] public void ChainedPathIteratorHelper() { diff --git a/source/Handlebars.Test/CustomConfigurationTests.cs b/source/Handlebars.Test/CustomConfigurationTests.cs index e16610c9..80da2dc4 100644 --- a/source/Handlebars.Test/CustomConfigurationTests.cs +++ b/source/Handlebars.Test/CustomConfigurationTests.cs @@ -61,6 +61,83 @@ public void SnakeCaseInputModelNaming() Assert.Equal(ExpectedOutput, output); } + [Fact] + public void UpperCamelCaseResolverDoesNotBreakEachIteration() + { + var template = HandlebarsInstance.Compile("{{#each items}}{{name}} {{/each}}"); + var data = new { items = new[] { new { name = "Alice" }, new { name = "Bob" } } }; + Assert.Equal("Alice Bob ", template(data)); + } + + [Fact] + public void UpperCamelCaseResolverDoesNotBreakEachWithList() + { + var template = HandlebarsInstance.Compile("{{#each items}}{{name}} {{/each}}"); + var data = new + { + items = new List + { + new { name = "Alice" }, + new { name = "Bob" } + } + }; + Assert.Equal("Alice Bob ", template(data)); + } + + [Fact] + public void UpperCamelCaseResolverDoesNotBreakEachWithAtIndex() + { + var template = HandlebarsInstance.Compile("{{#each items}}{{@index}}:{{name}} {{/each}}"); + var data = new { items = new[] { new { name = "Alice" }, new { name = "Bob" } } }; + Assert.Equal("0:Alice 1:Bob ", template(data)); + } + + [Fact] + public void UpperCamelCaseResolverDoesNotBreakEachWithAtFirst() + { + var template = HandlebarsInstance.Compile("{{#each items}}{{#if @first}}first:{{/if}}{{name}} {{/each}}"); + var data = new { items = new[] { new { name = "Alice" }, new { name = "Bob" } } }; + Assert.Equal("first:Alice Bob ", template(data)); + } + + [Fact] + public void UpperCamelCaseResolverWorksWithNestedPropertyAccess() + { + var template = HandlebarsInstance.Compile("{{#each items}}{{address.city}} {{/each}}"); + var data = new + { + items = new[] + { + new { address = new { city = "New York" } }, + new { address = new { city = "London" } } + } + }; + Assert.Equal("New York London ", template(data)); + } + + [Fact] + public void UpperCamelCaseResolverWorksWithStringArray() + { + var template = HandlebarsInstance.Compile("{{#each items}}{{this}} {{/each}}"); + var data = new { items = new[] { "Alice", "Bob" } }; + Assert.Equal("Alice Bob ", template(data)); + } + + [Fact] + public void UpperCamelCaseResolverWorksWithNestedEach() + { + var template = HandlebarsInstance.Compile("{{#each groups}}{{#each members}}{{name}} {{/each}}{{/each}}"); + var data = new + { + groups = new[] + { + new { members = new[] { new { name = "Alice" }, new { name = "Bob" } } }, + new { members = new[] { new { name = "Carol" } } } + } + }; + Assert.Equal("Alice Bob Carol ", template(data)); + } + #endregion #region Custom IOutputEncoding diff --git a/source/Handlebars.Test/DynamicTests.cs b/source/Handlebars.Test/DynamicTests.cs index c2930099..cc3ce01e 100644 --- a/source/Handlebars.Test/DynamicTests.cs +++ b/source/Handlebars.Test/DynamicTests.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Text.Json; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -52,6 +53,18 @@ public void DynamicObjectBasicIterationTest() Assert.Equal("foo: 1\nbar: hello world\n", output); } + [Fact] + public void DynamicObjectCaseSensitiveLookupWithSameSpellingVariables() + { + var h = Handlebars.Create(); + var template = h.Compile("{{TEST}} {{test}}"); + dynamic data = new ExpandoObject(); + data.TEST = "Upper"; + data.test = "Lower"; + var result = template(data); + Assert.Equal("Upper Lower", result); + } + [Fact] public void JsonTestIfTruthy() { @@ -288,6 +301,116 @@ public void WithParentIndexJsonNet(IHandlebars handlebars) Assert.Equal( makeFlat( expected ), makeFlat( result ) ); } + // System.Text.Json's JsonElement (the result of System.Text.Json.JsonSerializer.Deserialize) has no + // built-in support for member access/iteration in .NET, unlike Newtonsoft's JObject/JToken + // above. These tests mirror the JObject coverage to ensure feature parity between the two. + [Fact] + public void JsonElementResolvesNestedProperty() + { + var json = "{\"A\": {\"B\": \"b\"}}"; + object data = System.Text.Json.JsonSerializer.Deserialize(json)!; + + var template = Handlebars.Create().Compile("{{A.B}}"); + + Assert.Equal("b", template(data)); + } + + [Fact] + public void JsonElementRendersScalarPropertyValues() + { + var json = "{\"str\": \"hello\", \"num\": 42, \"nothing\": null}"; + object data = System.Text.Json.JsonSerializer.Deserialize(json)!; + + var template = Handlebars.Create().Compile("{{str}}|{{num}}|{{nothing}}"); + + Assert.Equal("hello|42|", template(data)); + } + + [Fact] + public void JsonElementIteratesArray() + { + var json = "{\"items\": [1, 2, 3]}"; + object data = System.Text.Json.JsonSerializer.Deserialize(json)!; + + var template = Handlebars.Create().Compile("{{#each items}}{{this}},{{/each}}"); + + Assert.Equal("1,2,3,", template(data)); + } + + [Fact] + public void JsonElementIteratesArrayOfObjects() + { + var json = "{\"items\": [{\"Name\": \"a\"}, {\"Name\": \"b\"}]}"; + object data = System.Text.Json.JsonSerializer.Deserialize(json)!; + + var template = Handlebars.Create().Compile("{{#each items}}{{Name}},{{/each}}"); + + Assert.Equal("a,b,", template(data)); + } + + [Fact] + public void JsonElementIteratesObjectProperties() + { + var json = "{\"A\": \"1\", \"B\": \"2\"}"; + object data = System.Text.Json.JsonSerializer.Deserialize(json)!; + + var template = Handlebars.Create().Compile("{{#each this}}{{@key}}={{this}} {{/each}}"); + + Assert.Equal("A=1 B=2 ", template(data)); + } + + [Theory] + [InlineData("{\"flag\": true}", "yes")] + [InlineData("{\"flag\": false}", "no")] + public void JsonElementBooleanRespectsTruthiness(string json, string expected) + { + object data = System.Text.Json.JsonSerializer.Deserialize(json)!; + + var template = Handlebars.Create().Compile("{{#if flag}}yes{{else}}no{{/if}}"); + + Assert.Equal(expected, template(data)); + } + + [Fact] + public void JsonElementTreatsEmptyStringNullAndZeroAsFalsy() + { + var json = "{\"empty\": \"\", \"nothing\": null, \"zero\": 0}"; + object data = System.Text.Json.JsonSerializer.Deserialize(json)!; + + var template = Handlebars.Create().Compile( + "{{#if empty}}T{{else}}F{{/if}}" + + "{{#if nothing}}T{{else}}F{{/if}}" + + "{{#if zero}}T{{else}}F{{/if}}" + ); + + Assert.Equal("FFF", template(data)); + } + + [Fact] + public void JsonElementTreatsEmptyArrayAndObjectAsFalsy() + { + var json = "{\"emptyArr\": [], \"emptyObj\": {}}"; + object data = System.Text.Json.JsonSerializer.Deserialize(json)!; + + var template = Handlebars.Create().Compile( + "{{#if emptyArr}}T{{else}}F{{/if}}" + + "{{#if emptyObj}}T{{else}}F{{/if}}" + ); + + Assert.Equal("FF", template(data)); + } + + [Fact] + public void JsonElementReturnsEmptyForMissingProperty() + { + var json = "{\"A\": \"a\"}"; + object data = System.Text.Json.JsonSerializer.Deserialize(json)!; + + var template = Handlebars.Create().Compile("{{Missing.Nested}}"); + + Assert.Equal("", template(data)); + } + #if NET452 || NET46 || NET461 || NET472 [Fact] diff --git a/source/Handlebars.Test/HandlebarsSpecCoverageTests.cs b/source/Handlebars.Test/HandlebarsSpecCoverageTests.cs index dda21df6..9c8b8d42 100644 --- a/source/Handlebars.Test/HandlebarsSpecCoverageTests.cs +++ b/source/Handlebars.Test/HandlebarsSpecCoverageTests.cs @@ -288,6 +288,76 @@ public void If_WhitespaceStringIsTruthy(IHandlebars hbs) Assert.Equal("yes", template(new { val = " " })); } + // ───────────────────────────────────────────────────────────── + // 5b. #if includeZero=true hash argument + // https://handlebarsjs.com/guide/builtin-helpers.html#if + // ───────────────────────────────────────────────────────────── + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void If_IncludeZeroTrue_ZeroInt_RendersBlock(IHandlebars hbs) + { + var template = hbs.Compile("{{#if value includeZero=true}}yes{{else}}no{{/if}}"); + Assert.Equal("yes", template(new { value = 0 })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void If_IncludeZeroTrue_ZeroDouble_RendersBlock(IHandlebars hbs) + { + var template = hbs.Compile("{{#if value includeZero=true}}yes{{else}}no{{/if}}"); + Assert.Equal("yes", template(new { value = 0.0 })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void If_IncludeZeroTrue_NonZeroInt_RendersBlock(IHandlebars hbs) + { + var template = hbs.Compile("{{#if value includeZero=true}}yes{{else}}no{{/if}}"); + Assert.Equal("yes", template(new { value = 1 })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void If_IncludeZeroFalse_ZeroInt_DoesNotRenderBlock(IHandlebars hbs) + { + var template = hbs.Compile("{{#if value includeZero=false}}yes{{else}}no{{/if}}"); + Assert.Equal("no", template(new { value = 0 })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void If_IncludeZeroTrue_NullValue_StillTreatedAsFalsy(IHandlebars hbs) + { + var template = hbs.Compile("{{#if value includeZero=true}}yes{{else}}no{{/if}}"); + Assert.Equal("no", template(new { value = (object?)null })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void If_IncludeZeroTrue_EmptyString_StillTreatedAsFalsy(IHandlebars hbs) + { + var template = hbs.Compile("{{#if value includeZero=true}}yes{{else}}no{{/if}}"); + Assert.Equal("no", template(new { value = string.Empty })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void If_IncludeZeroTrue_FalseBool_StillTreatedAsFalsy(IHandlebars hbs) + { + var template = hbs.Compile("{{#if value includeZero=true}}yes{{else}}no{{/if}}"); + Assert.Equal("no", template(new { value = false })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void If_IncludeZeroTrue_TrueBool_RendersBlock(IHandlebars hbs) + { + var template = hbs.Compile("{{#if value includeZero=true}}yes{{else}}no{{/if}}"); + Assert.Equal("yes", template(new { value = true })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void If_WithHashArgument_DoesNotCrash(IHandlebars hbs) + { + // Regression: passing any hash arg to #if previously threw + // InvalidOperationException: "Sequence contains more than one element". + var template = hbs.Compile("{{#if value includeZero=true}}yes{{/if}}"); + Assert.Equal("yes", template(new { value = 42 })); + } + // ───────────────────────────────────────────────────────────── // 6. #unless // ───────────────────────────────────────────────────────────── diff --git a/source/Handlebars.Test/HelperTests.cs b/source/Handlebars.Test/HelperTests.cs index e0be6733..a4c03841 100644 --- a/source/Handlebars.Test/HelperTests.cs +++ b/source/Handlebars.Test/HelperTests.cs @@ -965,6 +965,194 @@ public void BlockHelperWithCustomIndex() Assert.Equal("one's index is 0 two's index is 1 ", result); } + [Fact] + public void SubexpressionWriteSafeStringNotDoubleEncoded() + { + var h = Handlebars.Create(); + h.RegisterHelper("func2", (writer, ctx, args) => writer.WriteSafeString("bold")); + h.RegisterHelper("func1", (writer, ctx, args) => writer.WriteSafeString($"{args[0]}")); + var result = h.Compile("{{func1 (func2)}}")(new { }); + Assert.Equal("bold", result); + } + + [Fact] + public void StandaloneWriteSafeStringNotEncoded() + { + var h = Handlebars.Create(); + h.RegisterHelper("func2", (writer, ctx, args) => writer.WriteSafeString("bold")); + var result = h.Compile("{{func2}}")(new { }); + Assert.Equal("bold", result); + } + + [Fact] + public void WriteSafeStringConsistentRegardlessOfRegistrationOrder() + { + HandlebarsHelper link_to = (writer, context, parameters) => + writer.WriteSafeString($"{context["text"]}"); + + string source = "Click here: {{link_to}}"; + var data = new { url = "https://example.com", text = "Click" }; + + // Register BEFORE compile + var h1 = Handlebars.Create(); + h1.RegisterHelper("link_to", link_to); + var t1 = h1.Compile(source); + var result1 = t1(data); + + // Register AFTER compile + var h2 = Handlebars.Create(); + var t2 = h2.Compile(source); + h2.RegisterHelper("link_to", link_to); + var result2 = t2(data); + + // Both should produce identical, unescaped HTML + Assert.Equal(result1, result2); + Assert.Contains(" + { + receivedArgs.Add(arguments[0]); + return new[] { "attr1", "attr2" }; + }); + + var template = handlebars.Compile( + "{{#each Fields}}" + + "{{#with this as |field|}}" + + "{{#each (Getattributes field)}}" + + "{{this}} " + + "{{/each}}" + + "{{/with}}" + + "{{/each}}" + ); + + var data = new + { + Fields = new[] { "field1", "field2" } + }; + + // Should not throw NotSupportedException: TypeConverter cannot convert UndefinedBindingResult + var result = template(data); + + Assert.Equal("attr1 attr2 attr1 attr2 ", result); + + // Verify that the helper received the actual field values, not UndefinedBindingResult + Assert.Equal(2, receivedArgs.Count); + Assert.Equal("field1", receivedArgs[0]); + Assert.Equal("field2", receivedArgs[1]); + } + + [Fact] + public void BlockParamTypedAccessDoesNotThrowWhenPassedToHelper() + { + // Reproduces: System.NotSupportedException: TypeConverter cannot convert UndefinedBindingResult to string + // The bug manifests when the block param resolves to UndefinedBindingResult and the helper + // uses arguments.At() (typed access) which calls TypeConverter.ConvertTo. + var handlebars = Handlebars.Create(); + var receivedArgs = new List(); + + handlebars.RegisterHelper("Getattributes", (context, arguments) => + { + var fieldValue = arguments.At(0); + receivedArgs.Add(fieldValue); + return new[] { "attr1", "attr2" }; + }); + + var template = handlebars.Compile( + "{{#each Fields}}" + + "{{#with this as |field|}}" + + "{{#each (Getattributes field)}}" + + "{{this}} " + + "{{/each}}" + + "{{/with}}" + + "{{/each}}" + ); + + var data = new + { + Fields = new[] { "field1", "field2" } + }; + + var result = template(data); + + Assert.Equal("attr1 attr2 attr1 attr2 ", result); + Assert.Equal(2, receivedArgs.Count); + Assert.Equal("field1", receivedArgs[0]); + Assert.Equal("field2", receivedArgs[1]); + } + + [Fact] + public void BlockParamFromWithIsPassableToHelperInInnerEachAcrossPoolReuse() + { + // Uses more iterations to increase the chance of BindingContext pool reuse, + // which is what triggers the stale-data bug this guards against. + var handlebars = Handlebars.Create(); + var receivedArgs = new List(); + + handlebars.RegisterHelper("Getattributes", (context, arguments) => + { + var fieldValue = arguments.At(0); + receivedArgs.Add(fieldValue); + return new[] { "x", "y" }; + }); + + var template = handlebars.Compile( + "{{#each Fields}}" + + "{{#with this as |field|}}" + + "{{#each (Getattributes field)}}" + + "{{this}}" + + "{{/each}}" + + "{{/with}}" + + "{{/each}}" + ); + + var data = new + { + Fields = new[] { "a", "b", "c", "d", "e" } + }; + + var result = template(data); + + Assert.Equal("xyxyxyxyxy", result); + Assert.Equal(5, receivedArgs.Count); + Assert.Equal(new[] { "a", "b", "c", "d", "e" }, receivedArgs); + } + + [Fact] + public void BlockParamsInEachStressTestDoesNotThrow() + { + // 1000-iteration stress test to exercise BindingContext pool reuse. If the block param + // `field` ever resolves to UndefinedBindingResult due to pool reuse corruption, the helper + // would receive a wrong value (or throw NotSupportedException when typed access is used). + var h = Handlebars.Create(); + h.RegisterHelper("Getattributes", (context, args) => new[] { "attr1", "attr2" }); + + var template = h.Compile( + "{{#each Fields}}" + + "{{#with this as |field|}}" + + "{{#each (Getattributes field)}}" + + "{{this}} " + + "{{/each}}" + + "{{/with}}" + + "{{/each}}"); + + var data = new { Fields = new object[] { new { Name = "f1" }, new { Name = "f2" } } }; + + // Run many times to stress pool reuse + for (int i = 0; i < 1000; i++) + { + var result = template(data); + Assert.Contains("attr1", result); + Assert.Contains("attr2", result); + } + } + [Fact] public void BlockHelperThis() { diff --git a/source/Handlebars.Test/Issues/Issue285Tests.cs b/source/Handlebars.Test/Issues/Issue285Tests.cs deleted file mode 100644 index efa9d4f2..00000000 --- a/source/Handlebars.Test/Issues/Issue285Tests.cs +++ /dev/null @@ -1,104 +0,0 @@ -using Xunit; - -namespace HandlebarsDotNet.Test.Issues -{ - /// - /// Regression tests for GitHub issue #285: - /// Support the includeZero=true hash argument on the built-in #if helper, - /// matching Handlebars.js behaviour (https://handlebarsjs.com/guide/builtin-helpers.html#if). - /// - public class Issue285Tests - { - [Fact] - public void IfWithIncludeZeroTrue_ZeroInt_RendersBlock() - { - var source = "{{#if value includeZero=true}}yes{{else}}no{{/if}}"; - var template = Handlebars.Compile(source); - var result = template(new { value = 0 }); - Assert.Equal("yes", result); - } - - [Fact] - public void IfWithIncludeZeroTrue_ZeroDouble_RendersBlock() - { - var source = "{{#if value includeZero=true}}yes{{else}}no{{/if}}"; - var template = Handlebars.Compile(source); - var result = template(new { value = 0.0 }); - Assert.Equal("yes", result); - } - - [Fact] - public void IfWithIncludeZeroTrue_NonZeroInt_RendersBlock() - { - var source = "{{#if value includeZero=true}}yes{{else}}no{{/if}}"; - var template = Handlebars.Compile(source); - var result = template(new { value = 1 }); - Assert.Equal("yes", result); - } - - [Fact] - public void IfWithIncludeZeroFalse_ZeroInt_DoesNotRenderBlock() - { - var source = "{{#if value includeZero=false}}yes{{else}}no{{/if}}"; - var template = Handlebars.Compile(source); - var result = template(new { value = 0 }); - Assert.Equal("no", result); - } - - [Fact] - public void IfWithoutIncludeZero_ZeroInt_StillTreatedAsFalsy() - { - var source = "{{#if value}}yes{{else}}no{{/if}}"; - var template = Handlebars.Compile(source); - var result = template(new { value = 0 }); - Assert.Equal("no", result); - } - - [Fact] - public void IfWithIncludeZeroTrue_NullValue_StillTreatedAsFalsy() - { - var source = "{{#if value includeZero=true}}yes{{else}}no{{/if}}"; - var template = Handlebars.Compile(source); - var result = template(new { value = (object?)null }); - Assert.Equal("no", result); - } - - [Fact] - public void IfWithIncludeZeroTrue_EmptyString_StillTreatedAsFalsy() - { - var source = "{{#if value includeZero=true}}yes{{else}}no{{/if}}"; - var template = Handlebars.Compile(source); - var result = template(new { value = string.Empty }); - Assert.Equal("no", result); - } - - [Fact] - public void IfWithIncludeZeroTrue_FalseBool_StillTreatedAsFalsy() - { - var source = "{{#if value includeZero=true}}yes{{else}}no{{/if}}"; - var template = Handlebars.Compile(source); - var result = template(new { value = false }); - Assert.Equal("no", result); - } - - [Fact] - public void IfWithIncludeZeroTrue_TrueBool_RendersBlock() - { - var source = "{{#if value includeZero=true}}yes{{else}}no{{/if}}"; - var template = Handlebars.Compile(source); - var result = template(new { value = true }); - Assert.Equal("yes", result); - } - - [Fact] - public void IfWithHashArgument_DoesNotCrash() - { - // Regression test: passing any hash arg to #if previously threw - // InvalidOperationException: "Sequence contains more than one element". - var source = "{{#if value includeZero=true}}yes{{/if}}"; - var template = Handlebars.Compile(source); - var result = template(new { value = 42 }); - Assert.Equal("yes", result); - } - } -} diff --git a/source/Handlebars.Test/Issues/Issue434Tests.cs b/source/Handlebars.Test/Issues/Issue434Tests.cs deleted file mode 100644 index ffe875b1..00000000 --- a/source/Handlebars.Test/Issues/Issue434Tests.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Dynamic; -using Xunit; - -namespace HandlebarsDotNet.Test -{ - public class Issue434Tests - { - [Fact] - public void Issue434_CaseSensitiveLookupWithSameSpellingVariables() - { - var h = Handlebars.Create(); - var template = h.Compile("{{TEST}} {{test}}"); - dynamic data = new ExpandoObject(); - data.TEST = "Upper"; - data.test = "Lower"; - var result = template(data); - Assert.Equal("Upper Lower", result); - } - } -} diff --git a/source/Handlebars.Test/Issues/Issue455Tests.cs b/source/Handlebars.Test/Issues/Issue455Tests.cs deleted file mode 100644 index 17dad130..00000000 --- a/source/Handlebars.Test/Issues/Issue455Tests.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Xunit; - -namespace HandlebarsDotNet.Test -{ - public class Issue455Tests - { - // issue: https://github.com/Handlebars-Net/Handlebars.Net/issues/455 - [Fact] - public void Issue455_NamedArgsInPartialInsideNestedEach() - { - var h = Handlebars.Create(); - h.RegisterTemplate("myPartial", "{{arg1}}-{{arg2}} "); - var template = h.Compile( - "{{#each items}}{{#each nested}}{{> myPartial arg1=value1 arg2=value2}}{{/each}}{{/each}}"); - var data = new - { - items = new[] { new { nested = new[] { new { value1 = "A", value2 = "B" }, new { value1 = "C", value2 = "D" } } } } - }; - var result = template(data); - Assert.Contains("A-B", result); - Assert.Contains("C-D", result); - } - } -} diff --git a/source/Handlebars.Test/Issues/Issue458Tests.cs b/source/Handlebars.Test/Issues/Issue458Tests.cs deleted file mode 100644 index 1ad3585e..00000000 --- a/source/Handlebars.Test/Issues/Issue458Tests.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Xunit; - -namespace HandlebarsDotNet.Test -{ - public class Issue458Tests - { - [Fact] - public void Issue458_BasicCompileAndRender_NoByRefDelegate() - { - // Validates the scenario that fails on Mono when byref delegates are used - var h = Handlebars.Create(); - var render = h.Compile("{{input}}"); - var result = render(new { input = 42 }); - Assert.Equal("42", result); - } - - [Fact] - public void Issue458_BlockHelper_NoByRefDelegate() - { - // Block helpers also exercise TemplateDelegate compilation - var h = Handlebars.Create(); - h.RegisterHelper("loud", (writer, options, context, arguments) => - { - options.Template(writer, context); - }); - var render = h.Compile("{{#loud}}hello{{/loud}}"); - var result = render(new { }); - Assert.Equal("hello", result); - } - - [Fact] - public void Issue458_NestedTemplates_NoByRefDelegate() - { - // Nested template compilation exercises the expression tree lambda paths - var h = Handlebars.Create(); - var render = h.Compile("{{#each items}}{{this}},{{/each}}"); - var result = render(new { items = new[] { "a", "b", "c" } }); - Assert.Equal("a,b,c,", result); - } - } -} diff --git a/source/Handlebars.Test/Issues/Issue459Tests.cs b/source/Handlebars.Test/Issues/Issue459Tests.cs deleted file mode 100644 index 777a16ab..00000000 --- a/source/Handlebars.Test/Issues/Issue459Tests.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Xunit; - -namespace HandlebarsDotNet.Test -{ - public class Issue459Tests - { - [Fact] - public void Issue459_ConditionalInPartialSeesPassedContext() - { - var h = Handlebars.Create(); - h.RegisterTemplate("LinkToCompany", - "{{#if ClientCode}}IT EXISTS{{else}}IT IS NOT HERE{{/if}}"); - var template = h.Compile("{{> LinkToCompany Entity}}"); - var data = new { Entity = new { ClientCode = "TEST" } }; - Assert.Equal("IT EXISTS", template(data)); - } - } -} diff --git a/source/Handlebars.Test/Issues/Issue543Tests.cs b/source/Handlebars.Test/Issues/Issue543Tests.cs deleted file mode 100644 index 6c1ae250..00000000 --- a/source/Handlebars.Test/Issues/Issue543Tests.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Xunit; - -namespace HandlebarsDotNet.Test -{ - public class Issue543Tests - { - [Fact] - public void Subexpression_WriteSafeString_NotDoubleEncoded() - { - var h = Handlebars.Create(); - h.RegisterHelper("func2", (writer, ctx, args) => writer.WriteSafeString("bold")); - h.RegisterHelper("func1", (writer, ctx, args) => writer.WriteSafeString($"{args[0]}")); - var result = h.Compile("{{func1 (func2)}}")(new { }); - Assert.Equal("bold", result); - } - - [Fact] - public void Standalone_WriteSafeString_NotEncoded() - { - var h = Handlebars.Create(); - h.RegisterHelper("func2", (writer, ctx, args) => writer.WriteSafeString("bold")); - var result = h.Compile("{{func2}}")(new { }); - Assert.Equal("bold", result); - } - } -} diff --git a/source/Handlebars.Test/Issues/Issue559Tests.cs b/source/Handlebars.Test/Issues/Issue559Tests.cs deleted file mode 100644 index 2f0b2c8e..00000000 --- a/source/Handlebars.Test/Issues/Issue559Tests.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Xunit; -using Xunit.Abstractions; - -namespace HandlebarsDotNet.Test -{ - public class Issue559Tests - { - private readonly ITestOutputHelper _output; - - public Issue559Tests(ITestOutputHelper output) - { - _output = output; - } - - [Fact] - public void WriteSafeString_ConsistentRegardlessOfRegistrationOrder() - { - HandlebarsHelper link_to = (writer, context, parameters) => - writer.WriteSafeString($"{context["text"]}"); - - string source = "Click here: {{link_to}}"; - var data = new { url = "https://example.com", text = "Click" }; - - // Register BEFORE compile - var h1 = Handlebars.Create(); - h1.RegisterHelper("link_to", link_to); - var t1 = h1.Compile(source); - var result1 = t1(data); - - // Register AFTER compile - var h2 = Handlebars.Create(); - var t2 = h2.Compile(source); - h2.RegisterHelper("link_to", link_to); - var result2 = t2(data); - - _output.WriteLine($"result1 (before compile): {result1}"); - _output.WriteLine($"result2 (after compile): {result2}"); - - // Both should produce identical, unescaped HTML - Assert.Equal(result1, result2); - Assert.Contains(" - { - writer.WriteSafeString($"Hello {arguments[0]}"); - }); - - var template = h.Compile("{{greet name}}"); - var result = template(new { name = "World" }); - Assert.Equal("Hello World", result); - } - - [Fact] - public void RegisteredTemplate_IsResolvedAsPartial() - { - var h = Handlebars.Create(); - h.RegisterTemplate("greeting", "Hello {{name}}"); - - var template = h.Compile("{{> greeting}}"); - var result = template(new { name = "World" }); - Assert.Equal("Hello World", result); - } - - [Fact] - public void MultipleProperties_AreAllRendered() - { - var h = Handlebars.Create(); - var template = h.Compile("{{first}} {{last}}"); - var result = template(new { first = "John", last = "Doe" }); - Assert.Equal("John Doe", result); - } - - [Fact] - public void NestedObject_PropertyAccess_Succeeds() - { - var h = Handlebars.Create(); - var template = h.Compile("{{person.name}}"); - var result = template(new { person = new { name = "World" } }); - Assert.Equal("World", result); - } - - [Fact] - public void BlockHelper_IfElse_ProducesOutput() - { - var h = Handlebars.Create(); - var template = h.Compile("{{#if show}}yes{{else}}no{{/if}}"); - Assert.Equal("yes", template(new { show = true })); - Assert.Equal("no", template(new { show = false })); - } - - [Fact] - public void SharedEnvironment_CompileAndRender_NeverSilentlyFails() - { - var h = Handlebars.Create(); - var shared = h.CreateSharedEnvironment(); - - var template = shared.Compile("Hello {{name}}!"); - var result = template(new { name = "World" }); - Assert.Equal("Hello World!", result); - } - - [Fact] - public void EmptyTemplate_ProducesEmptyString_NotNull() - { - var h = Handlebars.Create(); - var template = h.Compile(""); - var result = template(new { }); - // An empty template should produce an empty string, not null - Assert.NotNull(result); - Assert.Equal("", result); - } - - [Fact] - public void StaticText_Template_ProducesOutput() - { - var h = Handlebars.Create(); - var template = h.Compile("static text only"); - var result = template(new { }); - Assert.Equal("static text only", result); - } - } -} diff --git a/source/Handlebars.Test/Issues/Issue582Tests.cs b/source/Handlebars.Test/Issues/Issue582Tests.cs deleted file mode 100644 index 5f5fb30d..00000000 --- a/source/Handlebars.Test/Issues/Issue582Tests.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System.Collections.Generic; -using Xunit; - -namespace HandlebarsDotNet.Test -{ - public class Issue582Tests - { - private static IHandlebars CreateHandlebars() - { - var config = new HandlebarsConfiguration - { - ExpressionNameResolver = new HandlebarsDotNet.Compiler.Resolvers.UpperCamelCaseExpressionNameResolver() - }; - return Handlebars.Create(config); - } - - [Fact] - public void Issue582_UpperCamelCaseResolverDoesNotBreakEachIteration() - { - var h = CreateHandlebars(); - var template = h.Compile("{{#each items}}{{name}} {{/each}}"); - var data = new { items = new[] { new { name = "Alice" }, new { name = "Bob" } } }; - Assert.Equal("Alice Bob ", template(data)); - } - - [Fact] - public void Issue582_UpperCamelCaseResolverDoesNotBreakEachWithList() - { - var h = CreateHandlebars(); - var template = h.Compile("{{#each items}}{{name}} {{/each}}"); - var data = new - { - items = new List - { - new { name = "Alice" }, - new { name = "Bob" } - } - }; - Assert.Equal("Alice Bob ", template(data)); - } - - [Fact] - public void Issue582_UpperCamelCaseResolverDoesNotBreakEachWithAtIndex() - { - var h = CreateHandlebars(); - var template = h.Compile("{{#each items}}{{@index}}:{{name}} {{/each}}"); - var data = new { items = new[] { new { name = "Alice" }, new { name = "Bob" } } }; - Assert.Equal("0:Alice 1:Bob ", template(data)); - } - - [Fact] - public void Issue582_UpperCamelCaseResolverDoesNotBreakEachWithAtFirst() - { - var h = CreateHandlebars(); - var template = h.Compile("{{#each items}}{{#if @first}}first:{{/if}}{{name}} {{/each}}"); - var data = new { items = new[] { new { name = "Alice" }, new { name = "Bob" } } }; - Assert.Equal("first:Alice Bob ", template(data)); - } - - [Fact] - public void Issue582_UpperCamelCaseResolverWorksWithNestedPropertyAccess() - { - var h = CreateHandlebars(); - var template = h.Compile("{{#each items}}{{address.city}} {{/each}}"); - var data = new - { - items = new[] - { - new { address = new { city = "New York" } }, - new { address = new { city = "London" } } - } - }; - Assert.Equal("New York London ", template(data)); - } - - [Fact] - public void Issue582_UpperCamelCaseResolverWorksWithStringArray() - { - var h = CreateHandlebars(); - var template = h.Compile("{{#each items}}{{this}} {{/each}}"); - var data = new { items = new[] { "Alice", "Bob" } }; - Assert.Equal("Alice Bob ", template(data)); - } - - [Fact] - public void Issue582_UpperCamelCaseResolverWorksWithNestedEach() - { - var h = CreateHandlebars(); - var template = h.Compile("{{#each groups}}{{#each members}}{{name}} {{/each}}{{/each}}"); - var data = new - { - groups = new[] - { - new { members = new[] { new { name = "Alice" }, new { name = "Bob" } } }, - new { members = new[] { new { name = "Carol" } } } - } - }; - Assert.Equal("Alice Bob Carol ", template(data)); - } - } -} diff --git a/source/Handlebars.Test/Issues/Issue595Tests.cs b/source/Handlebars.Test/Issues/Issue595Tests.cs deleted file mode 100644 index 4fda122a..00000000 --- a/source/Handlebars.Test/Issues/Issue595Tests.cs +++ /dev/null @@ -1,160 +0,0 @@ -using System.Collections.Generic; -using Xunit; - -namespace HandlebarsDotNet.Test -{ - // Issue https://github.com/Handlebars-Net/Handlebars.Net/issues/595 - // NotSupportedException: TypeConverter cannot convert UndefinedBindingResult - // when using `{{#with this as |field|}}` inside `{{#each Fields}}` and then - // passing `field` as an argument to a helper in an inner `{{#each (helper field)}}`. - public class Issue595Tests - { - [Fact] - public void BlockParamFromWithShouldBePassableToHelperInInnerEach() - { - var handlebars = Handlebars.Create(); - var receivedArgs = new List(); - - handlebars.RegisterHelper("Getattributes", (context, arguments) => - { - receivedArgs.Add(arguments[0]); - return new[] { "attr1", "attr2" }; - }); - - var template = handlebars.Compile( - "{{#each Fields}}" + - "{{#with this as |field|}}" + - "{{#each (Getattributes field)}}" + - "{{this}} " + - "{{/each}}" + - "{{/with}}" + - "{{/each}}" - ); - - var data = new - { - Fields = new[] { "field1", "field2" } - }; - - // Should not throw NotSupportedException: TypeConverter cannot convert UndefinedBindingResult - var result = template(data); - - Assert.Equal("attr1 attr2 attr1 attr2 ", result); - - // Verify that the helper received the actual field values, not UndefinedBindingResult - Assert.Equal(2, receivedArgs.Count); - Assert.Equal("field1", receivedArgs[0]); - Assert.Equal("field2", receivedArgs[1]); - } - - [Fact] - public void BlockParamTypedAccessShouldNotThrowWhenPassedToHelper() - { - // Reproduces the exact error: - // System.NotSupportedException: TypeConverter cannot convert UndefinedBindingResult to string - // The bug manifests when field resolves to UndefinedBindingResult and the helper - // uses arguments.At() (typed access) which calls TypeConverter.ConvertTo. - var handlebars = Handlebars.Create(); - var receivedArgs = new List(); - - handlebars.RegisterHelper("Getattributes", (context, arguments) => - { - // Using At() typed access triggers the NotSupportedException if - // arguments[0] is UndefinedBindingResult rather than the actual field value - var fieldValue = arguments.At(0); - receivedArgs.Add(fieldValue); - return new[] { "attr1", "attr2" }; - }); - - var template = handlebars.Compile( - "{{#each Fields}}" + - "{{#with this as |field|}}" + - "{{#each (Getattributes field)}}" + - "{{this}} " + - "{{/each}}" + - "{{/with}}" + - "{{/each}}" - ); - - var data = new - { - Fields = new[] { "field1", "field2" } - }; - - // Should not throw NotSupportedException: TypeConverter cannot convert UndefinedBindingResult to string - var result = template(data); - - Assert.Equal("attr1 attr2 attr1 attr2 ", result); - Assert.Equal(2, receivedArgs.Count); - Assert.Equal("field1", receivedArgs[0]); - Assert.Equal("field2", receivedArgs[1]); - } - - [Fact] - public void BlockParamFromWithShouldBePassableToHelperInInnerEachMultipleIterations() - { - // This test uses more iterations to increase the chance of pool reuse, - // which is what triggers the stale-data bug. - var handlebars = Handlebars.Create(); - var receivedArgs = new List(); - - handlebars.RegisterHelper("Getattributes", (context, arguments) => - { - var fieldValue = arguments.At(0); - receivedArgs.Add(fieldValue); - return new[] { "x", "y" }; - }); - - var template = handlebars.Compile( - "{{#each Fields}}" + - "{{#with this as |field|}}" + - "{{#each (Getattributes field)}}" + - "{{this}}" + - "{{/each}}" + - "{{/with}}" + - "{{/each}}" - ); - - var data = new - { - Fields = new[] { "a", "b", "c", "d", "e" } - }; - - var result = template(data); - - Assert.Equal("xyxyxyxyxy", result); - Assert.Equal(5, receivedArgs.Count); - Assert.Equal(new[] { "a", "b", "c", "d", "e" }, receivedArgs); - } - - [Fact] - public void Issue595_WithBlockParamsInEach_DoesNotThrow() - { - // Canonical 1000-iteration stress test to exercise BindingContext pool reuse. - // If the block param `field` ever resolves to UndefinedBindingResult due to - // pool reuse corruption, the helper would receive a wrong value (or throw - // NotSupportedException when typed access is used). - var h = Handlebars.Create(); - h.RegisterHelper("Getattributes", (context, args) => new[] { "attr1", "attr2" }); - - var template = h.Compile( - "{{#each Fields}}" + - "{{#with this as |field|}}" + - "{{#each (Getattributes field)}}" + - "{{this}} " + - "{{/each}}" + - "{{/with}}" + - "{{/each}}"); - - var data = new { Fields = new object[] { new { Name = "f1" }, new { Name = "f2" } } }; - - // Run many times to stress pool reuse - for (int i = 0; i < 1000; i++) - { - var result = template(data); - Assert.Contains("attr1", result); - Assert.Contains("attr2", result); - } - } - } -} diff --git a/source/Handlebars.Test/Issues/Issue614Tests.cs b/source/Handlebars.Test/Issues/Issue614Tests.cs deleted file mode 100644 index ed5a98f1..00000000 --- a/source/Handlebars.Test/Issues/Issue614Tests.cs +++ /dev/null @@ -1,190 +0,0 @@ -using System.IO; -using Xunit; - -namespace HandlebarsDotNet.Test -{ - /// - /// Tests for issue #614 — Partial indentation not preserved. - /// When {{> partial}} is indented with spaces/tabs on its own line, every line of the - /// rendered partial should be prefixed with that same indentation, matching Handlebars.js behavior. - /// - public class Issue614Tests - { - private readonly IHandlebars _handlebars; - - public Issue614Tests() - { - _handlebars = Handlebars.Create(); - } - - /// - /// Spec section 20.12: Template " {{> p}}" + Partial "line1\nline2" => " line1\n line2" - /// The two leading spaces become the indentation for every line of the partial output. - /// - [Fact] - public void InlinePartialSpecExample() - { - var source = " {{> p}}"; - var partialSource = "line1\nline2"; - - using (var reader = new StringReader(partialSource)) - { - _handlebars.RegisterTemplate("p", _handlebars.Compile(reader)); - } - - var result = _handlebars.Compile(source)(new { }); - - Assert.Equal(" line1\n line2", result); - } - - /// - /// The two-space indent before {{> user}} is applied to each line of the partial output. - /// The trailing newline after the standalone partial invocation is stripped (standalone behaviour). - /// - [Fact] - public void PartialIndentationWithMultiLinePartial() - { - var source = "Start\n {{> content}}\nEnd"; - var partialSource = "line1\nline2\nline3"; - - using (var reader = new StringReader(partialSource)) - { - _handlebars.RegisterTemplate("content", _handlebars.Compile(reader)); - } - - var result = _handlebars.Compile(source)(new { }); - - // TrimAfter strips the \n between the partial tag and "End", so the output is: - // "Start\n" + " line1\n line2\n line3" + "End" - Assert.Equal("Start\n line1\n line2\n line3End", result); - } - - /// - /// A tab character before the partial invocation is used as the indentation. - /// - [Fact] - public void PartialIndentationWithTabCharacter() - { - var source = "Start\n\t{{> content}}\nEnd"; - var partialSource = "line1\nline2"; - - using (var reader = new StringReader(partialSource)) - { - _handlebars.RegisterTemplate("content", _handlebars.Compile(reader)); - } - - var result = _handlebars.Compile(source)(new { }); - - Assert.Equal("Start\n\tline1\n\tline2End", result); - } - - /// - /// A partial with no preceding whitespace receives no indentation. - /// The newline that follows the standalone partial tag is stripped. - /// - [Fact] - public void PartialWithNoIndentationUnchanged() - { - var source = "Hello\n{{> greeting}}\nBye"; - var partialSource = "World"; - - using (var reader = new StringReader(partialSource)) - { - _handlebars.RegisterTemplate("greeting", _handlebars.Compile(reader)); - } - - var result = _handlebars.Compile(source)(new { }); - - // Standalone with no indent: TrimAfter strips \nBye → Bye, no indent added. - Assert.Equal("Hello\nWorldBye", result); - } - - /// - /// The indentation is applied inside a block helper iteration. - /// A single-line partial produces indented output per iteration; - /// iterations are not separated because the newline after {{> user}} is stripped. - /// - [Fact] - public void PartialIndentationIsAppliedInsideBlock() - { - var source = "

Names

\n{{#names}}\n {{> user}}\n{{/names}}"; - var partialSource = "{{name}}"; - - using (var reader = new StringReader(partialSource)) - { - _handlebars.RegisterTemplate("user", _handlebars.Compile(reader)); - } - - var template = _handlebars.Compile(source); - var data = new - { - names = new[] - { - new { name = "Karen" }, - new { name = "Jon" } - } - }; - - var result = template(data); - - // The standalone \n after {{> user}} is stripped; iterations are concatenated directly. - // Each partial invocation outputs " Name" (indent applied). - Assert.Equal("

Names

\n Karen Jon", result); - } - - /// - /// A partial whose source uses Windows-style \r\n line endings (e.g. checked out on Windows - /// with git autocrlf=true, or produced by a StringWriter whose NewLine is \r\n) is normalised - /// to \n in the indented output. The library always emits \n as the line separator so that - /// rendered output is identical across platforms. - /// - [Fact] - public void PartialWithCrLfLineEndingsNormalisedToLf() - { - var source = " {{> p}}"; - var partialSource = "line1\r\nline2\r\nline3"; // Windows-style \r\n - - using (var reader = new StringReader(partialSource)) - { - _handlebars.RegisterTemplate("p", _handlebars.Compile(reader)); - } - - var result = _handlebars.Compile(source)(new { }); - - // \r\n in the partial source is normalised to \n; every line gets the indent. - Assert.Equal(" line1\n line2\n line3", result); - } - - /// - /// A multi-line partial called inside an iteration — each line of each iteration is indented. - /// - [Fact] - public void MultiLinePartialIndentationInsideBlock() - { - var source = "{{#items}}\n {{> row}}\n{{/items}}"; - var partialSource = "- {{name}}\n ({{desc}})"; - - using (var reader = new StringReader(partialSource)) - { - _handlebars.RegisterTemplate("row", _handlebars.Compile(reader)); - } - - var template = _handlebars.Compile(source); - var data = new - { - items = new[] - { - new { name = "A", desc = "alpha" }, - new { name = "B", desc = "beta" } - } - }; - - var result = template(data); - - // Each 2-line partial gets " " prepended to both lines. - // The newline between the partial tag and the next iteration/closing tag is stripped. - Assert.Equal(" - A\n (alpha) - B\n (beta)", result); - } - - } -} diff --git a/source/Handlebars.Test/PartialTests.cs b/source/Handlebars.Test/PartialTests.cs index 1f8b9fe3..00f7a868 100644 --- a/source/Handlebars.Test/PartialTests.cs +++ b/source/Handlebars.Test/PartialTests.cs @@ -819,6 +819,181 @@ public void RecursionBoundedAboveLimitPartial() ex = Assert.IsType(ex.InnerException); Assert.Equal("Runtime error while rendering partial 'list', exceeded recursion depth limit of 100", ex.Message); } + + [Fact] + public void NamedArgsInPartialInsideNestedEach() + { + var h = Handlebars.Create(); + h.RegisterTemplate("myPartial", "{{arg1}}-{{arg2}} "); + var template = h.Compile( + "{{#each items}}{{#each nested}}{{> myPartial arg1=value1 arg2=value2}}{{/each}}{{/each}}"); + var data = new + { + items = new[] { new { nested = new[] { new { value1 = "A", value2 = "B" }, new { value1 = "C", value2 = "D" } } } } + }; + var result = template(data); + Assert.Contains("A-B", result); + Assert.Contains("C-D", result); + } + + [Fact] + public void ConditionalInPartialSeesPassedContext() + { + var h = Handlebars.Create(); + h.RegisterTemplate("LinkToCompany", + "{{#if ClientCode}}IT EXISTS{{else}}IT IS NOT HERE{{/if}}"); + var template = h.Compile("{{> LinkToCompany Entity}}"); + var data = new { Entity = new { ClientCode = "TEST" } }; + Assert.Equal("IT EXISTS", template(data)); + } + + // Partial indentation: when {{> partial}} is indented with spaces/tabs on its own line, + // every line of the rendered partial should be prefixed with that same indentation, + // matching Handlebars.js behavior (spec section 20.12). + [Fact] + public void InlinePartialSpecExample() + { + var handlebars = Handlebars.Create(); + var source = " {{> p}}"; + var partialSource = "line1\nline2"; + + using (var reader = new StringReader(partialSource)) + { + handlebars.RegisterTemplate("p", handlebars.Compile(reader)); + } + + var result = handlebars.Compile(source)(new { }); + + Assert.Equal(" line1\n line2", result); + } + + [Fact] + public void PartialIndentationWithMultiLinePartial() + { + var handlebars = Handlebars.Create(); + var source = "Start\n {{> content}}\nEnd"; + var partialSource = "line1\nline2\nline3"; + + using (var reader = new StringReader(partialSource)) + { + handlebars.RegisterTemplate("content", handlebars.Compile(reader)); + } + + var result = handlebars.Compile(source)(new { }); + + // TrimAfter strips the \n between the partial tag and "End", so the output is: + // "Start\n" + " line1\n line2\n line3" + "End" + Assert.Equal("Start\n line1\n line2\n line3End", result); + } + + [Fact] + public void PartialIndentationWithTabCharacter() + { + var handlebars = Handlebars.Create(); + var source = "Start\n\t{{> content}}\nEnd"; + var partialSource = "line1\nline2"; + + using (var reader = new StringReader(partialSource)) + { + handlebars.RegisterTemplate("content", handlebars.Compile(reader)); + } + + var result = handlebars.Compile(source)(new { }); + + Assert.Equal("Start\n\tline1\n\tline2End", result); + } + + [Fact] + public void PartialWithNoIndentationUnchanged() + { + var handlebars = Handlebars.Create(); + var source = "Hello\n{{> greeting}}\nBye"; + var partialSource = "World"; + + using (var reader = new StringReader(partialSource)) + { + handlebars.RegisterTemplate("greeting", handlebars.Compile(reader)); + } + + var result = handlebars.Compile(source)(new { }); + + // Standalone with no indent: TrimAfter strips \nBye → Bye, no indent added. + Assert.Equal("Hello\nWorldBye", result); + } + + [Fact] + public void PartialIndentationIsAppliedInsideBlock() + { + var handlebars = Handlebars.Create(); + var source = "

Names

\n{{#names}}\n {{> user}}\n{{/names}}"; + var partialSource = "{{name}}"; + + using (var reader = new StringReader(partialSource)) + { + handlebars.RegisterTemplate("user", handlebars.Compile(reader)); + } + + var template = handlebars.Compile(source); + var data = new + { + names = new[] + { + new { name = "Karen" }, + new { name = "Jon" } + } + }; + + var result = template(data); + + // The standalone \n after {{> user}} is stripped; iterations are concatenated directly. + Assert.Equal("

Names

\n Karen Jon", result); + } + + [Fact] + public void PartialWithCrLfLineEndingsNormalisedToLf() + { + var handlebars = Handlebars.Create(); + var source = " {{> p}}"; + var partialSource = "line1\r\nline2\r\nline3"; // Windows-style \r\n + + using (var reader = new StringReader(partialSource)) + { + handlebars.RegisterTemplate("p", handlebars.Compile(reader)); + } + + var result = handlebars.Compile(source)(new { }); + + // \r\n in the partial source is normalised to \n; every line gets the indent. + Assert.Equal(" line1\n line2\n line3", result); + } + + [Fact] + public void MultiLinePartialIndentationInsideBlock() + { + var handlebars = Handlebars.Create(); + var source = "{{#items}}\n {{> row}}\n{{/items}}"; + var partialSource = "- {{name}}\n ({{desc}})"; + + using (var reader = new StringReader(partialSource)) + { + handlebars.RegisterTemplate("row", handlebars.Compile(reader)); + } + + var template = handlebars.Compile(source); + var data = new + { + items = new[] + { + new { name = "A", desc = "alpha" }, + new { name = "B", desc = "beta" } + } + }; + + var result = template(data); + + // Each 2-line partial gets " " prepended to both lines. + Assert.Equal(" - A\n (alpha) - B\n (beta)", result); + } } } diff --git a/source/Handlebars/Configuration/HandlebarsConfigurationAdapter.cs b/source/Handlebars/Configuration/HandlebarsConfigurationAdapter.cs index e8f861bf..89b25c20 100644 --- a/source/Handlebars/Configuration/HandlebarsConfigurationAdapter.cs +++ b/source/Handlebars/Configuration/HandlebarsConfigurationAdapter.cs @@ -131,6 +131,7 @@ private ObservableList CreateObjectDescriptorProvider new GenericDictionaryObjectDescriptorProvider(), new ReadOnlyStringDictionaryObjectDescriptorProvider(), new StringDictionaryObjectDescriptorProvider(), + new JsonElementObjectDescriptorProvider(), new LayoutViewModel.DescriptorProvider(), } .AddMany(descriptorProviders); diff --git a/source/Handlebars/Handlebars.csproj b/source/Handlebars/Handlebars.csproj index c33ba767..b050e388 100644 --- a/source/Handlebars/Handlebars.csproj +++ b/source/Handlebars/Handlebars.csproj @@ -37,6 +37,7 @@ + diff --git a/source/Handlebars/HandlebarsUtils.cs b/source/Handlebars/HandlebarsUtils.cs index c5a7307c..7a2f01b9 100644 --- a/source/Handlebars/HandlebarsUtils.cs +++ b/source/Handlebars/HandlebarsUtils.cs @@ -2,6 +2,7 @@ using HandlebarsDotNet.Compiler; using System.Collections; using System.Diagnostics.CodeAnalysis; +using System.Text.Json; namespace HandlebarsDotNet { @@ -37,6 +38,8 @@ public static bool IsFalsy([NotNullWhen(false)] object? value, bool includeZero) return s == string.Empty; case SafeString safe: return safe.Value == string.Empty; + case JsonElement element: + return IsFalsyJsonElement(element, includeZero); } if (IsNumber(value) && !includeZero) @@ -45,12 +48,31 @@ public static bool IsFalsy([NotNullWhen(false)] object? value, bool includeZero) } return false; } - + + private static bool IsFalsyJsonElement(JsonElement element, bool includeZero) + { + switch (element.ValueKind) + { + case JsonValueKind.Null: + case JsonValueKind.Undefined: + case JsonValueKind.False: + return true; + case JsonValueKind.True: + return false; + case JsonValueKind.String: + return element.GetString() == string.Empty; + case JsonValueKind.Number: + return !includeZero && element.GetDouble() == 0; + default: + return false; + } + } + public static bool IsTruthyOrNonEmpty([NotNullWhen(true)] object? value, bool includeZero = false) { return !IsFalsyOrEmpty(value, includeZero); } - + public static bool IsFalsyOrEmpty([NotNullWhen(false)] object? value, bool includeZero = false) { if(IsFalsy(value, includeZero)) @@ -58,6 +80,12 @@ public static bool IsFalsyOrEmpty([NotNullWhen(false)] object? value, bool inclu return true; } + if (value is JsonElement element) + { + return (element.ValueKind == JsonValueKind.Object && !element.EnumerateObject().Any()) + || (element.ValueKind == JsonValueKind.Array && element.GetArrayLength() == 0); + } + return value is IEnumerable enumerable && !enumerable.Any(); } diff --git a/source/Handlebars/Iterators/JsonElementIterator.cs b/source/Handlebars/Iterators/JsonElementIterator.cs new file mode 100644 index 00000000..ff9c85d6 --- /dev/null +++ b/source/Handlebars/Iterators/JsonElementIterator.cs @@ -0,0 +1,140 @@ +using System.Text.Json; +using HandlebarsDotNet.Compiler; +using HandlebarsDotNet.PathStructure; +using HandlebarsDotNet.Runtime; +using HandlebarsDotNet.ValueProviders; + +namespace HandlebarsDotNet.Iterators +{ + public sealed class JsonElementIterator : IIterator + { + public void Iterate( + in EncodedTextWriter writer, + BindingContext context, + ChainSegment[] blockParamsVariables, + object input, + TemplateDelegate template, + TemplateDelegate ifEmpty + ) + { + var element = (JsonElement) input; + switch (element.ValueKind) + { + case JsonValueKind.Array: + IterateArray(element, writer, context, blockParamsVariables, template, ifEmpty); + break; + case JsonValueKind.Object: + IterateObject(element, writer, context, blockParamsVariables, template, ifEmpty); + break; + default: + using (var innerContext = context.CreateFrame()) + { + innerContext.Value = context.Value; + ifEmpty(writer, innerContext); + } + break; + } + } + + private static void IterateArray( + JsonElement target, + in EncodedTextWriter writer, + BindingContext context, + ChainSegment[] blockParamsVariables, + TemplateDelegate template, + TemplateDelegate ifEmpty + ) + { + using var innerContext = context.CreateFrame(); + var iterator = new IteratorValues(innerContext); + var blockParamsValues = new BlockParamsValues(innerContext, blockParamsVariables); + + blockParamsValues.CreateProperty(0, out var _0); + blockParamsValues.CreateProperty(1, out var _1); + + iterator.First = BoxedValues.True; + iterator.Last = BoxedValues.False; + + var index = 0; + var lastIndex = target.GetArrayLength() - 1; + foreach (var value in target.EnumerateArray()) + { + var indexObject = BoxedValues.Int(index); + + if (index == 1) iterator.First = BoxedValues.False; + if (index == lastIndex) iterator.Last = BoxedValues.True; + + iterator.Key = iterator.Index = indexObject; + + blockParamsValues[_0] = value; + blockParamsValues[_1] = indexObject; + + iterator.Value = value; + innerContext.Value = value; + + template(writer, innerContext); + + ++index; + } + + if (index == 0) + { + innerContext.Value = context.Value; + ifEmpty(writer, innerContext); + } + } + + private static void IterateObject( + JsonElement target, + in EncodedTextWriter writer, + BindingContext context, + ChainSegment[] blockParamsVariables, + TemplateDelegate template, + TemplateDelegate ifEmpty + ) + { + using var innerContext = context.CreateFrame(); + var iterator = new IteratorValues(innerContext); + var blockParamsValues = new BlockParamsValues(innerContext, blockParamsVariables); + + blockParamsValues.CreateProperty(0, out var _0); + blockParamsValues.CreateProperty(1, out var _1); + + iterator.First = BoxedValues.True; + iterator.Last = BoxedValues.False; + + var count = 0; + foreach (var _ in target.EnumerateObject()) count++; + + var index = 0; + var lastIndex = count - 1; + foreach (var property in target.EnumerateObject()) + { + if (index == 1) iterator.First = BoxedValues.False; + if (index == lastIndex) iterator.Last = BoxedValues.True; + + var value = property.Value; + var key = property.Name; + + iterator.Key = key; + iterator.Index = BoxedValues.Int(index); + + blockParamsValues[_0] = value; + blockParamsValues[_1] = key; + + iterator.Value = value; + innerContext.Value = value; + + template(writer, innerContext); + + ++index; + } + + if (index == 0) + { + innerContext.Value = context.Value; + ifEmpty(writer, innerContext); + } + } + } +} diff --git a/source/Handlebars/MemberAccessors/JsonElementMemberAccessor.cs b/source/Handlebars/MemberAccessors/JsonElementMemberAccessor.cs new file mode 100644 index 00000000..1c6307e0 --- /dev/null +++ b/source/Handlebars/MemberAccessors/JsonElementMemberAccessor.cs @@ -0,0 +1,33 @@ +using System.Text.Json; +using HandlebarsDotNet.PathStructure; + +namespace HandlebarsDotNet.MemberAccessors +{ + public sealed class JsonElementMemberAccessor : IMemberAccessor + { + public bool TryGetValue(object instance, ChainSegment memberName, out object? value) + { + value = null; + + var element = (JsonElement) instance; + if (element.ValueKind != JsonValueKind.Object) + { + return false; + } + + if (element.TryGetProperty(memberName.TrimmedValue, out var property)) + { + value = property; + return true; + } + + if (memberName.LowerInvariant != memberName.TrimmedValue && element.TryGetProperty(memberName.LowerInvariant, out property)) + { + value = property; + return true; + } + + return false; + } + } +} diff --git a/source/Handlebars/ObjectDescriptors/JsonElementObjectDescriptorProvider.cs b/source/Handlebars/ObjectDescriptors/JsonElementObjectDescriptorProvider.cs new file mode 100644 index 00000000..e8b6f73c --- /dev/null +++ b/source/Handlebars/ObjectDescriptors/JsonElementObjectDescriptorProvider.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json; +using HandlebarsDotNet.Iterators; +using HandlebarsDotNet.MemberAccessors; + +namespace HandlebarsDotNet.ObjectDescriptors +{ + /// + /// Provides support for produced by + /// when deserializing into . + /// + public sealed class JsonElementObjectDescriptorProvider : IObjectDescriptorProvider + { + private static readonly Type Type = typeof(JsonElement); + + private static readonly JsonElementMemberAccessor MemberAccessor = new JsonElementMemberAccessor(); + + private static readonly Func IteratorFactory = _ => new JsonElementIterator(); + + private static readonly Func GetProperties = (descriptor, arg) => + { + var element = (JsonElement) arg; + return element.ValueKind == JsonValueKind.Object + ? element.EnumerateObject().Select(property => property.Name) + : Enumerable.Empty(); + }; + + private static readonly ObjectDescriptor Descriptor = new ObjectDescriptor(Type, MemberAccessor, GetProperties, IteratorFactory); + + public bool TryGetDescriptor(Type type, [NotNullWhen(true)] out ObjectDescriptor? value) + { + if (type != Type) + { + value = ObjectDescriptor.Empty; + return false; + } + + value = Descriptor; + return true; + } + } +}