From e66978b683dd9086131827756e3f58daf3292aa4 Mon Sep 17 00:00:00 2001 From: jsneedles Date: Thu, 8 Oct 2026 11:25:11 -0400 Subject: [PATCH 1/5] add JsonNode support & enhance JsonElement --- README.md | 56 +++ source/Handlebars.Test/DynamicTests.cs | 112 ----- source/Handlebars.Test/JsonTests.cs | 428 ++++++++++++++++++ .../HandlebarsConfigurationAdapter.cs | 6 +- source/Handlebars/HandlebarsUtils.cs | 20 + .../Handlebars/Iterators/JsonNodeIterator.cs | 184 ++++++++ .../JsonElementMemberAccessor.cs | 67 ++- .../MemberAccessors/JsonNodeMemberAccessor.cs | 48 ++ .../CollectionMemberAliasProvider.cs | 5 + .../JsonElementObjectDescriptorProvider.cs | 16 +- .../JsonNodeObjectDescriptorProvider.cs | 66 +++ 11 files changed, 885 insertions(+), 123 deletions(-) create mode 100644 source/Handlebars.Test/JsonTests.cs create mode 100644 source/Handlebars/Iterators/JsonNodeIterator.cs create mode 100644 source/Handlebars/MemberAccessors/JsonNodeMemberAccessor.cs create mode 100644 source/Handlebars/ObjectDescriptors/JsonNodeObjectDescriptorProvider.cs diff --git a/README.md b/README.md index d9bbd253..28fcc4cb 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,62 @@ var result = template(data); // "6" This is a C#-specific capability, since JavaScript/Handlebars.js has no equivalent to true multi-dimensional arrays. +### System.Text.Json + +`System.Text.Json` values can be passed to a template directly, with no conversion to POCOs or dictionaries. + +**`JsonElement`**: deserializing JSON as `object` with `JsonSerializer.Deserialize(json)` gives you a `JsonElement`. You can pass that in, or any other `JsonElement` such as `JsonDocument.RootElement`. + +**`JsonNode`**: `JsonObject`, `JsonArray` and `JsonValue` work too, whether they come from `JsonNode.Parse` or are built in code. A node tree is read when the template renders, so changes made to it between renders show up in the output. + +```c# +var template = Handlebars.Compile("{{title}}: {{#each tags}}{{@index}}={{this}} {{/each}}{{#if draft}}(draft){{/if}}"); + +var json = @"{ ""title"": ""My post"", ""tags"": [""a"", ""b""], ""draft"": false }"; + +template(JsonSerializer.Deserialize(json)); // "My post: 0=a 1=b " +template(JsonNode.Parse(json)); // "My post: 0=a 1=b " + +var data = new JsonObject +{ + ["title"] = "My post", + ["tags"] = new JsonArray("a", "b"), + ["draft"] = true +}; + +template(data); // "My post: 0=a 1=b (draft)" +``` + +For both types: +- Properties are matched by exact name first, then by the lowercase name, so `{{Title}}` finds a `"title"` property. +- Arrays support index access (`{{tags.[0]}}`), `{{tags.Count}}`, and `#each` with `@index`, `@first` and `@last`. Objects support `#each` with `@key`. +- As with other collections, `{{tags.length}}` works once `handlebars.Configuration.UseCollectionMemberAliasProvider()` is enabled. +- `#if`, `#unless` and `#each` treat `false`, `null`, `0`, `""`, `[]` and `{}` as falsy. Pass `includeZero=true` to `#if` to treat `0` as truthy. + +#### Values wrapping .NET objects + +A `JsonValue` created from a .NET collection or object, such as `JsonValue.Create(new List { "a", "b" })`, behaves like the wrapped value would if you passed it in directly. You can iterate it and access its members. Members are resolved with .NET property names, so JSON attributes like `[JsonPropertyName]` and serializer naming policies don't apply: + +```c# +class Author +{ + [JsonPropertyName("full_name")] + public string Name { get; set; } +} + +var data = new JsonObject +{ + ["author"] = JsonValue.Create(new Author { Name = "Ada" }), + ["tags"] = JsonValue.Create(new List { "a", "b" }) +}; + +Handlebars.Compile("{{author.Name}}")(data); // "Ada" +Handlebars.Compile("{{author.full_name}}")(data); // "" - the [JsonPropertyName] name is not used +Handlebars.Compile("{{tags.[1]}}")(data); // "b" +``` + +To use the JSON names instead, serialize the value into a node tree first with `JsonSerializer.SerializeToNode(value)`. + ### Registering Partials ```c# diff --git a/source/Handlebars.Test/DynamicTests.cs b/source/Handlebars.Test/DynamicTests.cs index cc3ce01e..aed6c110 100644 --- a/source/Handlebars.Test/DynamicTests.cs +++ b/source/Handlebars.Test/DynamicTests.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Text.Json; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -301,116 +300,6 @@ 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] @@ -449,4 +338,3 @@ public override bool TryGetMember(GetMemberBinder binder, out object result) } } } - diff --git a/source/Handlebars.Test/JsonTests.cs b/source/Handlebars.Test/JsonTests.cs new file mode 100644 index 00000000..fda666c4 --- /dev/null +++ b/source/Handlebars.Test/JsonTests.cs @@ -0,0 +1,428 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using HandlebarsDotNet.Features; +using HandlebarsDotNet.ObjectDescriptors; +using Xunit; + +namespace HandlebarsDotNet.Test +{ + public abstract class JsonTestBase + { + protected abstract object Parse(string json); + + [Fact] + public void ResolvesNestedProperty() + { + var template = Handlebars.Create().Compile("{{A.B}}"); + + Assert.Equal("b", template(Parse("{\"A\": {\"B\": \"b\"}}"))); + } + + [Fact] + public void RendersScalarPropertyValues() + { + var template = Handlebars.Create().Compile("{{str}}|{{num}}|{{nothing}}"); + + Assert.Equal("hello|42|", template(Parse("{\"str\": \"hello\", \"num\": 42, \"nothing\": null}"))); + } + + [Fact] + public void IteratesArray() + { + var template = Handlebars.Create().Compile("{{#each items}}{{this}},{{/each}}"); + + Assert.Equal("1,2,3,", template(Parse("{\"items\": [1, 2, 3]}"))); + } + + [Theory] + [InlineData("{{#each this}}{{Name}},{{/each}}", "[{\"Name\": \"a\"}, {\"Name\": \"b\"}]")] + [InlineData("{{#each items}}{{Name}},{{/each}}", "{\"items\": [{\"Name\": \"a\"}, {\"Name\": \"b\"}]}")] + public void IteratesArrayOfObjects(string source, string json) + { + var template = Handlebars.Create().Compile(source); + + Assert.Equal("a,b,", template(Parse(json))); + } + + [Fact] + public void IteratesObjectProperties() + { + var template = Handlebars.Create().Compile("{{#each this}}{{@key}}={{this}} {{/each}}"); + + Assert.Equal("A=1 B=2 ", template(Parse("{\"A\": \"1\", \"B\": \"2\"}"))); + } + + [Theory] + [InlineData("true", true)] + [InlineData("false", false)] + [InlineData("null", false)] + [InlineData("\"\"", false)] + [InlineData("\"text\"", true)] + [InlineData("\"false\"", true)] + [InlineData("0", false)] + [InlineData("0.0", false)] + [InlineData("1", true)] + [InlineData("-1.5", true)] + [InlineData("[]", false)] + [InlineData("{}", false)] + [InlineData("[null]", true)] + [InlineData("{\"key\": null}", true)] + public void RespectsConditionalTruthiness(string value, bool truthy) + { + var template = Handlebars.Create().Compile("{{#if value}}T{{else}}F{{/if}}|{{#unless value}}F{{else}}T{{/unless}}"); + + Assert.Equal(truthy ? "T|T" : "F|F", template(Parse("{\"value\": " + value + "}"))); + } + + [Theory] + [InlineData("true", "T")] + [InlineData("false", "F")] + [InlineData("null", "F")] + [InlineData("0", "F")] + [InlineData("\"\"", "F")] + [InlineData("\"text\"", "T")] + [InlineData("[]", "F")] + [InlineData("{}", "F")] + public void RespectsRootValueTruthiness(string json, string expected) + { + var template = Handlebars.Create().Compile("{{#if this}}T{{else}}F{{/if}}"); + + Assert.Equal(expected, template(Parse(json))); + } + + [Theory] + [InlineData("0", "F|T")] + [InlineData("0.0", "F|T")] + [InlineData("1", "T|T")] + public void HonorsIncludeZero(string value, string expected) + { + var template = Handlebars.Create().Compile("{{#if value includeZero=false}}T{{else}}F{{/if}}|{{#if value includeZero=true}}T{{else}}F{{/if}}"); + + Assert.Equal(expected, template(Parse("{\"value\": " + value + "}"))); + } + + [Fact] + public void ReturnsEmptyForMissingProperty() + { + var template = Handlebars.Create().Compile("{{Missing.Nested}}|{{#if Missing}}T{{else}}F{{/if}}"); + + Assert.Equal("|F", template(Parse("{\"A\": \"a\"}"))); + } + + [Fact] + public void ReturnsEmptyForNullAndScalarMemberAccess() + { + var template = Handlebars.Create().Compile("{{nullVal.Nested}}|{{intVal.Nested}}|{{stringVal.Nested}}"); + + Assert.Equal("||", template(Parse("{\"nullVal\": null, \"intVal\": 1, \"stringVal\": \"a\"}"))); + } + + [Theory] + [InlineData("{{Prop}}|{{prop}}", "{\"Prop\": \"a\", \"prop\": \"b\"}", "a|b")] + [InlineData("{{PROP}}", "{\"prop\": \"a\"}", "a")] + public void PrefersExactPropertyNameBeforeLowercaseFallback(string source, string json, string expected) + { + var template = Handlebars.Create().Compile(source); + + Assert.Equal(expected, template(Parse(json))); + } + + [Theory] + [InlineData("[]")] + [InlineData("{}")] + [InlineData("null")] + [InlineData("42")] + [InlineData("true")] + [InlineData("\"text\"")] + public void EachUsesInverseForEmptyOrScalarValues(string json) + { + var template = Handlebars.Create().Compile("{{#each this}}item{{else}}empty{{/each}}"); + + Assert.Equal("empty", template(Parse(json))); + } + + [Fact] + public void IteratedValuesRespectTruthiness() + { + var template = Handlebars.Create().Compile("{{#each items}}{{#if this}}T{{else}}F{{/if}},{{/each}}"); + + Assert.Equal("F,T,F,T,F,T,F,F,F,T,", template(Parse("{\"items\": [false, true, 0, 1, \"\", \"false\", null, [], {}, [1]]}"))); + } + + [Fact] + public void PreservesArrayIterationMetadataAndBlockParameters() + { + var template = Handlebars.Create().Compile("{{#each items as |item index|}}{{@index}}:{{@key}}:{{@first}}:{{@last}}:{{index}}:{{item}}:{{@value}};{{/each}}"); + + Assert.Equal("0:0:True:False:0:a:a;1:1:False:True:1:b:b;", template(Parse("{\"items\": [\"a\", \"b\"]}"))); + } + + [Fact] + public void PreservesObjectIterationMetadataAndBlockParameters() + { + var template = Handlebars.Create().Compile("{{#each this as |value key|}}{{@index}}:{{@key}}:{{@first}}:{{@last}}:{{key}}:{{value}}:{{@value}};{{/each}}"); + + Assert.Equal("0:A:True:False:A:a:a;1:B:False:True:B:b:b;", template(Parse("{\"A\": \"a\", \"B\": \"b\"}"))); + } + + [Fact] + public void PreservesParentAndRootContextInNestedLoops() + { + var template = Handlebars.Create().Compile("{{#each groups as |group|}}{{#each items as |item index|}}{{@../index}}.{{index}}={{../stringVal}}/{{group.stringVal}}/{{item.stringVal}}/{{@root.stringVal}};{{/each}}{{/each}}"); + var data = Parse("{\"stringVal\": \"root\", \"groups\": [{\"stringVal\": \"a\", \"items\": [{\"stringVal\": \"x\"}, {\"stringVal\": \"y\"}]}, {\"stringVal\": \"b\", \"items\": [{\"stringVal\": \"z\"}]}]}"); + + Assert.Equal("0.0=a/a/x/root;0.1=a/a/y/root;1.0=b/b/z/root;", template(data)); + } + + [Fact] + public void WithPreservesParentAndRootContext() + { + var template = Handlebars.Create().Compile("{{#with objectVal}}{{stringVal}}:{{../rootVal}}:{{@root.rootVal}}{{/with}}"); + + Assert.Equal("b:a:a", template(Parse("{\"rootVal\": \"a\", \"objectVal\": {\"stringVal\": \"b\"}}"))); + } + + [Theory] + [InlineData("{{arrayVal.[0].stringVal}}|{{arrayVal.[1].stringVal}}", "a|b")] + [InlineData("{{arrayVal.[2].stringVal}}|{{arrayVal.[-1].stringVal}}|{{arrayVal.[x].stringVal}}", "||")] + [InlineData("{{scalars.[1]}}|{{lookup scalars 1}}|{{lookup scalars 5}}", "y|y|")] + [InlineData("{{matrix.[1].[0]}}", "3")] + [InlineData("{{#with arrayVal.[1]}}{{stringVal}}{{/with}}", "b")] + [InlineData("{{#if scalars.[0]}}T{{else}}F{{/if}}{{#if scalars.[3]}}T{{else}}F{{/if}}", "TF")] + public void ResolvesArrayIndexes(string source, string expected) + { + var template = Handlebars.Create().Compile(source); + var data = Parse("{\"arrayVal\": [{\"stringVal\": \"a\"}, {\"stringVal\": \"b\"}], \"scalars\": [\"x\", \"y\"], \"matrix\": [[1, 2], [3, 4]]}"); + + Assert.Equal(expected, template(data)); + } + + [Fact] + public void PartialHashMergesContextProperties() + { + var handlebars = Handlebars.Create(); + handlebars.RegisterTemplate("partial", "{{stringVal}}/{{extra}}"); + var template = handlebars.Compile("{{#with objectVal}}{{> partial extra='x'}}{{/with}}"); + + Assert.Equal("b/x", template(Parse("{\"objectVal\": {\"stringVal\": \"b\"}}"))); + } + + [Fact] + public void PreservesArrayIndexAndCountAccess() + { + var template = Handlebars.Create().Compile("{{arrayVal.[0].stringVal}}|{{arrayVal.[1].stringVal}}|{{arrayVal.Count}}|{{arrayVal.[2].stringVal}}|{{arrayVal.[-1].stringVal}}|{{emptyVal.Count}}"); + + Assert.Equal("a|b|2|||0", template(Parse("{\"arrayVal\": [{\"stringVal\": \"a\"}, {\"stringVal\": \"b\"}], \"emptyVal\": []}"))); + } + + [Theory] + [InlineData(false, "2|2||")] + [InlineData(true, "2|2|2|2")] + public void ArrayMembersUseConfiguredCollectionAliases(bool useAliases, string expected) + { + var handlebars = Handlebars.Create(); + if (useAliases) + { + handlebars.Configuration.UseCollectionMemberAliasProvider(); + } + + var template = handlebars.Compile("{{arrayVal.Count}}|{{arrayVal.count}}|{{arrayVal.Length}}|{{arrayVal.length}}"); + var data = Parse("{\"arrayVal\": [\"a\", \"b\"]}"); + + Assert.Equal(expected, template(data)); + Assert.Equal(template(new { arrayVal = new List { "a", "b" } }), template(data)); + } + + [Fact] + public void ObjectMembersKeepJsonPropertyLookupWithCollectionAliases() + { + var handlebars = Handlebars.Create(); + handlebars.Configuration.UseCollectionMemberAliasProvider(); + var template = handlebars.Compile("{{objectVal.Count}}|{{objectVal.Length}}|{{objectVal.Options}}|{{emptyObj.Count}}|{{emptyObj.length}}|{{stringVal.Count}}|{{stringVal.length}}"); + + Assert.Equal("7|8|||||", template(Parse("{\"objectVal\": {\"Count\": 7, \"Length\": 8}, \"emptyObj\": {}, \"stringVal\": \"abc\"}"))); + } + } + + public class JsonElementTests : JsonTestBase + { + protected override object Parse(string json) => JsonSerializer.Deserialize(json)!; + + [Fact] + public void ArrayMembersUseCustomAliases() + { + var handlebars = Handlebars.Create(); + handlebars.Configuration.AliasProviders.Add(new DelegatedMemberAliasProvider().AddAlias("itemCount", element => element.GetArrayLength())); + var template = handlebars.Compile("{{arrayVal.itemCount}}|{{arrayVal.[0]}}"); + + Assert.Equal("2|a", template(Parse("{\"arrayVal\": [\"a\", \"b\"]}"))); + } + } + + public class JsonNodeTests : JsonTestBase + { + protected override object Parse(string json) => JsonNode.Parse(json)!; + + [Fact] + public void CreatedNumericValuesRespectTruthiness() + { + var template = Handlebars.Create().Compile("{{#if value}}T{{else}}F{{/if}}|{{#if value includeZero=true}}T{{else}}F{{/if}}"); + var zeros = new JsonNode[] + { + JsonValue.Create(0)!, JsonValue.Create(0L)!, JsonValue.Create(0d)!, JsonValue.Create(0f)!, + JsonValue.Create(0m)!, JsonValue.Create(0u)!, JsonValue.Create(0UL)!, JsonValue.Create((short) 0)!, + JsonValue.Create((ushort) 0)!, JsonValue.Create((byte) 0)!, JsonValue.Create((sbyte) 0)! + }; + + foreach (var zero in zeros) + { + Assert.Equal("F|T", template(new JsonObject { ["value"] = zero })); + } + } + + [Fact] + public void CreatedNodesRenderAndReflectChanges() + { + var data = new JsonObject + { + ["stringVal"] = "a", + ["boolVal"] = false, + ["arrayVal"] = new JsonArray(new JsonObject { ["intVal"] = 1 }) + }; + var template = Handlebars.Create().Compile("{{stringVal}}:{{#if boolVal}}T{{else}}F{{/if}}:{{#each arrayVal}}{{intVal}}{{/each}}"); + + Assert.Equal("a:F:1", template(data)); + + data["stringVal"] = "b"; + data["boolVal"] = true; + data["arrayVal"]![0]!["intVal"] = 2; + + Assert.Equal("b:T:2", template(data)); + } + + [Fact] + public void CreatedTextValuesRespectTruthiness() + { + var template = Handlebars.Create().Compile("{{#if this}}T{{else}}F{{/if}}"); + + Assert.Equal("F", template(JsonValue.Create(string.Empty))); + Assert.Equal("T", template(JsonValue.Create('a'))); + Assert.Equal("T", template(JsonValue.Create(Guid.Empty))); + Assert.Equal("T", template(JsonValue.Create(DateTime.MinValue))); + } + + [Fact] + public void EachIteratesValuesWrappingClrContainers() + { + // These report JsonValueKind.Array/Object but are JsonValue, not JsonArray/JsonObject. + var handlebars = Handlebars.Create(); + var nested = handlebars.Compile("{{#each items as |value key|}}{{key}}:{{value}},{{else}}empty{{/each}}"); + var root = handlebars.Compile("{{#each this as |value key|}}{{key}}:{{value}},{{else}}empty{{/each}}"); + var cases = new (JsonNode Value, string Expected)[] + { + (JsonValue.Create(new[] { 1, 2 })!, "0:1,1:2,"), + (JsonValue.Create(new List { "a", "b" })!, "0:a,1:b,"), + (JsonValue.Create(new Dictionary { ["a"] = 1, ["b"] = 2 })!, "a:1,b:2,"), + (JsonValue.Create(new WrappedItem())!, "Name:n,"), + (JsonValue.Create(new AnnotatedItem())!, "Name:n,"), + (JsonValue.Create(Array.Empty())!, "empty"), + (JsonValue.Create(new List())!, "empty"), + (JsonValue.Create(new Dictionary())!, "empty") + }; + + foreach (var (value, expected) in cases) + { + Assert.Equal(expected, nested(new JsonObject { ["items"] = value })); + Assert.Equal(expected, root(value)); + } + } + + [Fact] + public void MembersResolveOnValuesWrappingClrContainers() + { + // Members resolve against the wrapped CLR value, so CLR names apply rather than JSON property annotations. + var handlebars = Handlebars.Create(); + var cases = new (JsonNode Value, string Source, string Expected)[] + { + (JsonValue.Create(new WrappedItem())!, "{{items.Name}}|{{#with items}}{{Name}}{{/with}}|{{lookup items 'Name'}}|{{items.Missing}}", "n|n|n|"), + (JsonValue.Create(new List { "a", "b" })!, "{{items.[0]}}|{{items.[1]}}|{{items.Count}}|{{lookup items 1}}|{{items.[2]}}", "a|b|2|b|"), + (JsonValue.Create(new[] { 1, 2 })!, "{{items.[1]}}|{{items.Length}}", "2|2"), + (JsonValue.Create(new Dictionary { ["a"] = 1 })!, "{{items.a}}|{{lookup items 'a'}}", "1|1"), + (JsonValue.Create(new AnnotatedItem())!, "{{items.Name}}|{{items.full_name}}", "n|") + }; + + foreach (var (value, source, expected) in cases) + { + Assert.Equal(expected, handlebars.Compile(source)(new JsonObject { ["items"] = value })); + } + } + + [Fact] + public void MembersDoNotResolveOnScalarValues() + { + var template = Handlebars.Create().Compile("{{items.Length}}|{{items.Year}}"); + + Assert.Equal("|", template(new JsonObject { ["items"] = "abc" })); + Assert.Equal("|", template(new JsonObject { ["items"] = new DateTime(2020, 1, 2) })); + Assert.Equal("|", template(Parse("{\"items\": \"abc\"}"))); + } + + [Fact] + public void PartialHashMergesPropertiesOfValuesWrappingClrContainers() + { + var handlebars = Handlebars.Create(); + handlebars.RegisterTemplate("partial", "{{Name}}{{a}}/{{extra}}"); + var template = handlebars.Compile("{{#with items}}{{> partial extra='x'}}{{/with}}"); + + Assert.Equal("n/x", template(new JsonObject { ["items"] = JsonValue.Create(new WrappedItem()) })); + Assert.Equal("1/x", template(new JsonObject { ["items"] = JsonValue.Create(new Dictionary { ["a"] = 1 }) })); + } + + [Fact] + public void ArrayMembersUseCustomAliases() + { + var handlebars = Handlebars.Create(); + handlebars.Configuration.AliasProviders.Add(new DelegatedMemberAliasProvider().AddAlias("itemCount", array => array.Count)); + var template = handlebars.Compile("{{arrayVal.itemCount}}"); + + Assert.Equal("2", template(Parse("{\"arrayVal\": [\"a\", \"b\"]}"))); + } + + [Fact] + public void DescriptorSupportsConcreteNodeTypesAndContainerProperties() + { + var provider = new JsonNodeObjectDescriptorProvider(new EnumerableObjectDescriptor(new ObjectDescriptorProvider(Array.Empty()))); + var nodes = new JsonNode[] { new JsonObject { ["stringVal"] = "a" }, new JsonArray(1), JsonValue.Create(false)! }; + + foreach (var node in nodes) + { + Assert.True(provider.TryGetDescriptor(node.GetType(), out var descriptor)); + Assert.Equal(node.GetType(), descriptor!.DescribedType); + var properties = descriptor.GetProperties(descriptor, node).Cast().Select(property => property.ToString()); + if (node is JsonArray) + { + Assert.Contains("Count", properties); + } + else + { + Assert.Equal(node is JsonObject ? new[] { "stringVal" } : Array.Empty(), properties); + } + } + } + + private class WrappedItem + { + public string Name { get; set; } = "n"; + } + + private class AnnotatedItem + { + [JsonPropertyName("full_name")] + public string Name { get; set; } = "n"; + } + } +} diff --git a/source/Handlebars/Configuration/HandlebarsConfigurationAdapter.cs b/source/Handlebars/Configuration/HandlebarsConfigurationAdapter.cs index 89b25c20..7a924bda 100644 --- a/source/Handlebars/Configuration/HandlebarsConfigurationAdapter.cs +++ b/source/Handlebars/Configuration/HandlebarsConfigurationAdapter.cs @@ -121,17 +121,19 @@ public HandlebarsConfigurationAdapter(HandlebarsConfiguration configuration) private ObservableList CreateObjectDescriptorProvider(ObservableList descriptorProviders) { var objectDescriptorProvider = new ObjectDescriptorProvider(AliasProviders); + var enumerableObjectDescriptor = new EnumerableObjectDescriptor(objectDescriptorProvider); var objectDescriptorProviders = new ObservableList { objectDescriptorProvider, new DynamicObjectDescriptor(objectDescriptorProvider), - new EnumerableObjectDescriptor(objectDescriptorProvider), + enumerableObjectDescriptor, new DictionaryObjectDescriptor(), new ReadOnlyGenericDictionaryObjectDescriptorProvider(), new GenericDictionaryObjectDescriptorProvider(), new ReadOnlyStringDictionaryObjectDescriptorProvider(), new StringDictionaryObjectDescriptorProvider(), - new JsonElementObjectDescriptorProvider(), + new JsonElementObjectDescriptorProvider(AliasProviders), + new JsonNodeObjectDescriptorProvider(enumerableObjectDescriptor), new LayoutViewModel.DescriptorProvider(), } .AddMany(descriptorProviders); diff --git a/source/Handlebars/HandlebarsUtils.cs b/source/Handlebars/HandlebarsUtils.cs index 455d5861..f7f00a75 100644 --- a/source/Handlebars/HandlebarsUtils.cs +++ b/source/Handlebars/HandlebarsUtils.cs @@ -3,6 +3,7 @@ using System.Collections; using System.Diagnostics.CodeAnalysis; using System.Text.Json; +using System.Text.Json.Nodes; namespace HandlebarsDotNet { @@ -38,6 +39,8 @@ public static bool IsFalsy([NotNullWhen(false)] object? value, bool includeZero) return s == string.Empty; case JsonElement element: return IsFalsyJsonElement(element, includeZero); + case JsonNode node: + return IsFalsyJsonNode(node, includeZero); } if (includeZero) return false; @@ -80,6 +83,12 @@ private static bool IsFalsyJsonElement(JsonElement element, bool includeZero) } } + private static bool IsFalsyJsonNode(JsonNode node, bool includeZero) + { + // Parsed values wrap JsonElement; programmatically created values retain their CLR type. + return node is JsonValue scalar && IsFalsy(scalar.GetValue(), includeZero); + } + public static bool IsTruthyOrNonEmpty([NotNullWhen(true)] object? value, bool includeZero = false) { return !IsFalsyOrEmpty(value, includeZero); @@ -98,6 +107,17 @@ public static bool IsFalsyOrEmpty([NotNullWhen(false)] object? value, bool inclu || (element.ValueKind == JsonValueKind.Array && element.GetArrayLength() == 0); } + if (value is JsonNode node) + { + return node switch + { + JsonObject obj => obj.Count == 0, + JsonArray array => array.Count == 0, + JsonValue scalar => IsFalsyOrEmpty(scalar.GetValue(), includeZero), + _ => false + }; + } + // O(1) emptiness checks that avoid allocating an enumerator for the common // collection shapes; the IEnumerable fallback boxes List-style struct enumerators. if (value is ICollection collection) return collection.Count == 0; diff --git a/source/Handlebars/Iterators/JsonNodeIterator.cs b/source/Handlebars/Iterators/JsonNodeIterator.cs new file mode 100644 index 00000000..96656a09 --- /dev/null +++ b/source/Handlebars/Iterators/JsonNodeIterator.cs @@ -0,0 +1,184 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using HandlebarsDotNet.Compiler; +using HandlebarsDotNet.ObjectDescriptors; +using HandlebarsDotNet.PathStructure; +using HandlebarsDotNet.Runtime; +using HandlebarsDotNet.ValueProviders; + +namespace HandlebarsDotNet.Iterators +{ + public sealed class JsonNodeIterator : IIterator + { + public void Iterate( + in EncodedTextWriter writer, + BindingContext context, + ChainSegment[] blockParamsVariables, + object input, + TemplateDelegate template, + TemplateDelegate ifEmpty + ) + { + var node = (JsonNode) input; + switch (node) + { + case JsonObject obj: + IterateObject(obj, writer, context, blockParamsVariables, template, ifEmpty); + break; + case JsonArray array: + IterateArray(array, writer, context, blockParamsVariables, template, ifEmpty); + break; + default: + if (node.GetValueKind() is JsonValueKind.Array or JsonValueKind.Object) + { + // A JsonValue wrapping a CLR collection or object (e.g. JsonValue.Create(new List())): + // iterate the underlying value with its own iterator. + IterateWrappedValue(node, writer, context, blockParamsVariables, template, ifEmpty); + } + else + { + IterateEmpty(writer, context, ifEmpty); + } + break; + } + } + + private static void IterateWrappedValue( + JsonNode node, + in EncodedTextWriter writer, + BindingContext context, + ChainSegment[] blockParamsVariables, + TemplateDelegate template, + TemplateDelegate ifEmpty + ) + { + var value = node.GetValue(); + if (ObjectDescriptor.TryCreate(value, out var descriptor) && descriptor.Iterator != null) + { + descriptor.Iterator.Iterate(writer, context, blockParamsVariables, value, template, ifEmpty); + return; + } + + IterateEmpty(writer, context, ifEmpty); + } + + private static void IterateEmpty(in EncodedTextWriter writer, BindingContext context, TemplateDelegate ifEmpty) + { + using var innerContext = context.CreateFrame(); + innerContext.Value = context.Value; + ifEmpty(writer, innerContext); + } + + private static void IterateArray( + JsonArray 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.Count - 1; + foreach (var value in target) + { + 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( + JsonObject 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 = target.Count; + + var index = 0; + var lastIndex = count - 1; + foreach (var property in target) + { + if (index == 1) + { + iterator.First = BoxedValues.False; + } + if (index == lastIndex) + { + iterator.Last = BoxedValues.True; + } + + var value = property.Value; + var key = property.Key; + + 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 index 1c6307e0..1237d09b 100644 --- a/source/Handlebars/MemberAccessors/JsonElementMemberAccessor.cs +++ b/source/Handlebars/MemberAccessors/JsonElementMemberAccessor.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Text.Json; using HandlebarsDotNet.PathStructure; @@ -5,16 +7,71 @@ namespace HandlebarsDotNet.MemberAccessors { public sealed class JsonElementMemberAccessor : IMemberAccessor { - public bool TryGetValue(object instance, ChainSegment memberName, out object? value) + private static readonly Type Type = typeof(JsonElement); + + private readonly IReadOnlyList _aliasProviders; + + public JsonElementMemberAccessor() : this(Array.Empty()) { - value = null; + } + public JsonElementMemberAccessor(IReadOnlyList aliasProviders) + { + _aliasProviders = aliasProviders; + } + + public bool TryGetValue(object instance, ChainSegment memberName, out object? value) + { var element = (JsonElement) instance; - if (element.ValueKind != JsonValueKind.Object) + var found = element.ValueKind switch { - return false; + JsonValueKind.Object => TryGetPropertyValue(element, memberName, out value), + JsonValueKind.Array => TryGetArrayMember(element, memberName, out value), + _ => NotFound(out value) + }; + + return found || TryGetValueByAlias(instance, memberName, out value); + } + + private static bool TryGetArrayMember(JsonElement element, ChainSegment memberName, out object? value) + { + // Same index parsing as EnumerableMemberAccessor: non-negative integers only. + if (int.TryParse(memberName.LowerInvariant, out var index) && index >= 0 && index < element.GetArrayLength()) + { + value = element[index]; + return true; } + // Matches the Count property that List and JsonArray expose through reflection; + // Length is added by the collection alias provider, as for other collections. + if (memberName.LowerInvariant == "count") + { + value = element.GetArrayLength(); + return true; + } + + return NotFound(out value); + } + + private bool TryGetValueByAlias(object instance, ChainSegment memberName, out object? value) + { + for (var index = 0; index < _aliasProviders.Count; index++) + { + if (_aliasProviders[index].TryGetMemberByAlias(instance, Type, memberName, out value)) + return true; + } + + return NotFound(out value); + } + + private static bool NotFound(out object? value) + { + value = null; + return false; + } + + private static bool TryGetPropertyValue(JsonElement element, ChainSegment memberName, out object? value) + { if (element.TryGetProperty(memberName.TrimmedValue, out var property)) { value = property; @@ -27,7 +84,7 @@ public bool TryGetValue(object instance, ChainSegment memberName, out object? va return true; } - return false; + return NotFound(out value); } } } diff --git a/source/Handlebars/MemberAccessors/JsonNodeMemberAccessor.cs b/source/Handlebars/MemberAccessors/JsonNodeMemberAccessor.cs new file mode 100644 index 00000000..c55297d8 --- /dev/null +++ b/source/Handlebars/MemberAccessors/JsonNodeMemberAccessor.cs @@ -0,0 +1,48 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using HandlebarsDotNet.ObjectDescriptors; +using HandlebarsDotNet.PathStructure; + +namespace HandlebarsDotNet.MemberAccessors +{ + public sealed class JsonNodeMemberAccessor : IMemberAccessor + { + public bool TryGetValue(object instance, ChainSegment memberName, out object? value) + { + value = null; + + switch (instance) + { + case JsonObject obj: + return TryGetPropertyValue(obj, memberName, out value); + // A JsonValue wrapping a CLR collection or object (e.g. JsonValue.Create(new List())): + // resolve members on the underlying value with its own accessor, matching JsonNodeIterator. + // GetValueKind() serializes the wrapped value; System.Text.Json 9+ caches the result per node, 8.x does not. + case JsonValue wrapped when wrapped.GetValueKind() is JsonValueKind.Array or JsonValueKind.Object: + var underlying = wrapped.GetValue(); + return ObjectDescriptor.TryCreate(underlying, out var descriptor) + && descriptor.MemberAccessor.TryGetValue(underlying, memberName, out value); + default: + return false; + } + } + + private static bool TryGetPropertyValue(JsonObject obj, ChainSegment memberName, out object? value) + { + if (obj.TryGetPropertyValue(memberName.TrimmedValue, out var property)) + { + value = property; + return true; + } + + if (memberName.LowerInvariant != memberName.TrimmedValue && obj.TryGetPropertyValue(memberName.LowerInvariant, out property)) + { + value = property; + return true; + } + + value = null; + return false; + } + } +} diff --git a/source/Handlebars/MemberAliasProvider/CollectionMemberAliasProvider.cs b/source/Handlebars/MemberAliasProvider/CollectionMemberAliasProvider.cs index 84336f1c..cc8b67d3 100644 --- a/source/Handlebars/MemberAliasProvider/CollectionMemberAliasProvider.cs +++ b/source/Handlebars/MemberAliasProvider/CollectionMemberAliasProvider.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Linq; +using System.Text.Json; using HandlebarsDotNet.ObjectDescriptors; using HandlebarsDotNet.PathStructure; @@ -23,6 +24,10 @@ public bool TryGetMemberByAlias(object instance, Type targetType, ChainSegment m value = array.Count; return true; + case JsonElement { ValueKind: JsonValueKind.Array } element when memberAlias.Equals(Length): + value = element.GetArrayLength(); + return true; + case IEnumerable enumerable when ObjectDescriptorFactory.Current!.TryGetDescriptor(targetType, out var descriptor) && descriptor.GetProperties != null: var properties = descriptor.GetProperties(descriptor, enumerable); var property = properties.OfType().FirstOrDefault(o => diff --git a/source/Handlebars/ObjectDescriptors/JsonElementObjectDescriptorProvider.cs b/source/Handlebars/ObjectDescriptors/JsonElementObjectDescriptorProvider.cs index e8b6f73c..69b38749 100644 --- a/source/Handlebars/ObjectDescriptors/JsonElementObjectDescriptorProvider.cs +++ b/source/Handlebars/ObjectDescriptors/JsonElementObjectDescriptorProvider.cs @@ -17,8 +17,6 @@ public sealed class JsonElementObjectDescriptorProvider : IObjectDescriptorProvi { 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) => @@ -29,7 +27,17 @@ public sealed class JsonElementObjectDescriptorProvider : IObjectDescriptorProvi : Enumerable.Empty(); }; - private static readonly ObjectDescriptor Descriptor = new ObjectDescriptor(Type, MemberAccessor, GetProperties, IteratorFactory); + private readonly ObjectDescriptor _descriptor; + + public JsonElementObjectDescriptorProvider() : this(Array.Empty()) + { + } + + /// Consulted for members a does not resolve itself, e.g. length on arrays. + public JsonElementObjectDescriptorProvider(IReadOnlyList aliasProviders) + { + _descriptor = new ObjectDescriptor(Type, new JsonElementMemberAccessor(aliasProviders), GetProperties, IteratorFactory); + } public bool TryGetDescriptor(Type type, [NotNullWhen(true)] out ObjectDescriptor? value) { @@ -39,7 +47,7 @@ public bool TryGetDescriptor(Type type, [NotNullWhen(true)] out ObjectDescriptor return false; } - value = Descriptor; + value = _descriptor; return true; } } diff --git a/source/Handlebars/ObjectDescriptors/JsonNodeObjectDescriptorProvider.cs b/source/Handlebars/ObjectDescriptors/JsonNodeObjectDescriptorProvider.cs new file mode 100644 index 00000000..058e54c1 --- /dev/null +++ b/source/Handlebars/ObjectDescriptors/JsonNodeObjectDescriptorProvider.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using HandlebarsDotNet.Iterators; +using HandlebarsDotNet.MemberAccessors; + +namespace HandlebarsDotNet.ObjectDescriptors +{ + /// + /// Provides support for , , and in templates. + /// + public sealed class JsonNodeObjectDescriptorProvider : IObjectDescriptorProvider + { + private readonly EnumerableObjectDescriptor _enumerableObjectDescriptor; + + private static readonly Type Type = typeof(JsonNode); + + private static readonly JsonNodeMemberAccessor MemberAccessor = new JsonNodeMemberAccessor(); + + private static readonly Func IteratorFactory = _ => new JsonNodeIterator(); + + private static readonly Func GetProperties = (descriptor, arg) => + { + switch (arg) + { + case JsonObject obj: + return obj.Select(property => property.Key); + // A JsonValue wrapping a CLR collection or object: list the underlying value's properties, + // matching JsonNodeMemberAccessor and JsonNodeIterator. + case JsonValue wrapped when wrapped.GetValueKind() is JsonValueKind.Array or JsonValueKind.Object: + var underlying = wrapped.GetValue(); + return ObjectDescriptor.TryCreate(underlying, out var underlyingDescriptor) + ? underlyingDescriptor.GetProperties(underlyingDescriptor, underlying) + : Enumerable.Empty(); + default: + return Enumerable.Empty(); + } + }; + + public JsonNodeObjectDescriptorProvider(EnumerableObjectDescriptor enumerableObjectDescriptor) + { + _enumerableObjectDescriptor = enumerableObjectDescriptor; + } + + public bool TryGetDescriptor(Type type, [NotNullWhen(true)] out ObjectDescriptor? value) + { + if (!Type.IsAssignableFrom(type)) + { + value = ObjectDescriptor.Empty; + return false; + } + + if (type == typeof(JsonArray) && _enumerableObjectDescriptor.TryGetDescriptor(type, out var arrayDescriptor)) + { + value = new ObjectDescriptor(type, arrayDescriptor.MemberAccessor, arrayDescriptor.GetProperties, IteratorFactory, arrayDescriptor.Dependencies); + return true; + } + + value = new ObjectDescriptor(type, MemberAccessor, GetProperties, IteratorFactory); + return true; + } + } +} From eee5c6c4db55948b6555b52fbac004febbc65368 Mon Sep 17 00:00:00 2001 From: jsneedles Date: Thu, 8 Oct 2026 11:54:41 -0400 Subject: [PATCH 2/5] Block parameter fix fix: resolve block params named like @-variables by name Unrelated to the JsonNode changes in this PR. The new JSON array iteration tests use `as |item index|`, which exposed this bug as an intermittent CI failure. A block param that shares a name with a context variable (e.g. `{{#each items as |item index|}}`) was looked up in BlockParamsObject using the entry index of the matching @-variable in ContextDataObject. The two dictionaries usually place the key in the same slot, so this mostly worked, but when a hash collision moved the @-variable's slot, the lookup returned another block param's value or nothing. String hashes are randomized per process, so the failure was intermittent on every OS. Look block params up by segment, as TryGetContextVariable already does. Adds a regression test covering index, key, first, last, root and parent. Because the bug depends on the process hash seed, the test cannot fail deterministically without the fix. --- source/Handlebars.Test/BasicIntegrationTests.cs | 15 +++++++++++++++ source/Handlebars/BindingContext.cs | 11 +++-------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/source/Handlebars.Test/BasicIntegrationTests.cs b/source/Handlebars.Test/BasicIntegrationTests.cs index d5211f82..5890f4cb 100644 --- a/source/Handlebars.Test/BasicIntegrationTests.cs +++ b/source/Handlebars.Test/BasicIntegrationTests.cs @@ -991,6 +991,21 @@ public void DictionaryEnumeratorWithBlockParams(IHandlebars handlebars) Assert.Equal("hello foo world bar ", result); } + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void BlockParamsNamedLikeContextVariables(IHandlebars handlebars) + { + // Block params sharing a name with @-variables used to be looked up by the @-variable's slot in a + // different dictionary, so whether they resolved depended on the process's string hash seed. + var data = new { items = new List { "a", "b" } }; + + foreach (var name in new[] { "index", "key", "first", "last", "root", "parent" }) + { + var template = handlebars.Compile($"{{{{#each items as |item {name}|}}}}{{{{{name}}}}}:{{{{item}}}};{{{{/each}}}}"); + + Assert.Equal("0:a;1:b;", template(data)); + } + } + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] public void DictionaryWithLastEnumerator(IHandlebars handlebars) { diff --git a/source/Handlebars/BindingContext.cs b/source/Handlebars/BindingContext.cs index 42b1b38b..0240f1e7 100644 --- a/source/Handlebars/BindingContext.cs +++ b/source/Handlebars/BindingContext.cs @@ -147,14 +147,9 @@ out WellKnownVariables[(int) WellKnownVariable.Parent] internal bool TryGetVariable(ChainSegment segment, [MaybeNullWhen(false)] out object value) { - if (segment.WellKnownVariable != WellKnownVariable.None) - { - var wellKnownVariable = WellKnownVariables[(int) segment.WellKnownVariable]; - return BlockParamsObject.TryGetValue(wellKnownVariable, out value) - || (Descriptor.Value.MemberAccessor?.TryGetValue(Value!, segment, out value) ?? false); - } - - return BlockParamsObject.TryGetValue(segment, out value) + // Block params are looked up by segment even for well-known names (e.g. `as |item index|`): + // WellKnownVariables holds entry indexes into ContextDataObject, not BlockParamsObject. + return BlockParamsObject.TryGetValue(segment, out value) || (Descriptor.Value.MemberAccessor?.TryGetValue(Value!, segment, out value) ?? false); } From a9f713a77294631c246b8630f56dca74ccad1c15 Mon Sep 17 00:00:00 2001 From: jsneedles Date: Mon, 19 Oct 2026 18:52:35 -0400 Subject: [PATCH 3/5] address feedback --- README.md | 24 +-- source/Handlebars.Test/JsonTests.cs | 152 +++++++++++------- .../HandlebarsConfigurationAdapter.cs | 2 +- source/Handlebars/HandlebarsUtils.cs | 1 - .../Handlebars/Iterators/JsonNodeIterator.cs | 99 +----------- .../MemberAccessors/JsonNodeMemberAccessor.cs | 41 +++-- .../JsonNodeObjectDescriptorProvider.cs | 44 ++--- 7 files changed, 135 insertions(+), 228 deletions(-) diff --git a/README.md b/README.md index 28fcc4cb..c9a61d35 100644 --- a/README.md +++ b/README.md @@ -135,29 +135,9 @@ For both types: - As with other collections, `{{tags.length}}` works once `handlebars.Configuration.UseCollectionMemberAliasProvider()` is enabled. - `#if`, `#unless` and `#each` treat `false`, `null`, `0`, `""`, `[]` and `{}` as falsy. Pass `includeZero=true` to `#if` to treat `0` as truthy. -#### Values wrapping .NET objects +When interpolated directly, booleans render as `True`/`False` for `JsonElement` and `true`/`false` for `JsonNode`. -A `JsonValue` created from a .NET collection or object, such as `JsonValue.Create(new List { "a", "b" })`, behaves like the wrapped value would if you passed it in directly. You can iterate it and access its members. Members are resolved with .NET property names, so JSON attributes like `[JsonPropertyName]` and serializer naming policies don't apply: - -```c# -class Author -{ - [JsonPropertyName("full_name")] - public string Name { get; set; } -} - -var data = new JsonObject -{ - ["author"] = JsonValue.Create(new Author { Name = "Ada" }), - ["tags"] = JsonValue.Create(new List { "a", "b" }) -}; - -Handlebars.Compile("{{author.Name}}")(data); // "Ada" -Handlebars.Compile("{{author.full_name}}")(data); // "" - the [JsonPropertyName] name is not used -Handlebars.Compile("{{tags.[1]}}")(data); // "b" -``` - -To use the JSON names instead, serialize the value into a node tree first with `JsonSerializer.SerializeToNode(value)`. +Compatibility: `JsonNode` values `false`, `0`, and `""` now follow falsy semantics; `JsonObject` lookups now include the lowercase fallback described above; custom member alias providers are now consulted for unresolved `JsonElement` and `JsonObject` members. ### Registering Partials diff --git a/source/Handlebars.Test/JsonTests.cs b/source/Handlebars.Test/JsonTests.cs index fda666c4..6390b279 100644 --- a/source/Handlebars.Test/JsonTests.cs +++ b/source/Handlebars.Test/JsonTests.cs @@ -3,7 +3,6 @@ using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; -using System.Text.Json.Serialization; using HandlebarsDotNet.Features; using HandlebarsDotNet.ObjectDescriptors; using Xunit; @@ -156,7 +155,7 @@ public void IteratedValuesRespectTruthiness() [Fact] public void PreservesArrayIterationMetadataAndBlockParameters() { - var template = Handlebars.Create().Compile("{{#each items as |item index|}}{{@index}}:{{@key}}:{{@first}}:{{@last}}:{{index}}:{{item}}:{{@value}};{{/each}}"); + var template = Handlebars.Create().Compile("{{#each items as |item itemIndex|}}{{@index}}:{{@key}}:{{@first}}:{{@last}}:{{itemIndex}}:{{item}}:{{@value}};{{/each}}"); Assert.Equal("0:0:True:False:0:a:a;1:1:False:True:1:b:b;", template(Parse("{\"items\": [\"a\", \"b\"]}"))); } @@ -164,7 +163,7 @@ public void PreservesArrayIterationMetadataAndBlockParameters() [Fact] public void PreservesObjectIterationMetadataAndBlockParameters() { - var template = Handlebars.Create().Compile("{{#each this as |value key|}}{{@index}}:{{@key}}:{{@first}}:{{@last}}:{{key}}:{{value}}:{{@value}};{{/each}}"); + var template = Handlebars.Create().Compile("{{#each this as |item propertyKey|}}{{@index}}:{{@key}}:{{@first}}:{{@last}}:{{propertyKey}}:{{item}}:{{@value}};{{/each}}"); Assert.Equal("0:A:True:False:A:a:a;1:B:False:True:B:b:b;", template(Parse("{\"A\": \"a\", \"B\": \"b\"}"))); } @@ -172,7 +171,7 @@ public void PreservesObjectIterationMetadataAndBlockParameters() [Fact] public void PreservesParentAndRootContextInNestedLoops() { - var template = Handlebars.Create().Compile("{{#each groups as |group|}}{{#each items as |item index|}}{{@../index}}.{{index}}={{../stringVal}}/{{group.stringVal}}/{{item.stringVal}}/{{@root.stringVal}};{{/each}}{{/each}}"); + var template = Handlebars.Create().Compile("{{#each groups as |group|}}{{#each items as |item itemIndex|}}{{@../index}}.{{itemIndex}}={{../stringVal}}/{{group.stringVal}}/{{item.stringVal}}/{{@root.stringVal}};{{/each}}{{/each}}"); var data = Parse("{\"stringVal\": \"root\", \"groups\": [{\"stringVal\": \"a\", \"items\": [{\"stringVal\": \"x\"}, {\"stringVal\": \"y\"}]}, {\"stringVal\": \"b\", \"items\": [{\"stringVal\": \"z\"}]}]}"); Assert.Equal("0.0=a/a/x/root;0.1=a/a/y/root;1.0=b/b/z/root;", template(data)); @@ -246,12 +245,49 @@ public void ObjectMembersKeepJsonPropertyLookupWithCollectionAliases() Assert.Equal("7|8|||||", template(Parse("{\"objectVal\": {\"Count\": 7, \"Length\": 8}, \"emptyObj\": {}, \"stringVal\": \"abc\"}"))); } + + [Fact] + public void ObjectMembersUseCustomAliases() + { + var data = Parse("{}"); + var handlebars = Handlebars.Create(); + handlebars.Configuration.AliasProviders.Add(new DelegatedMemberAliasProvider().AddAlias("genericAlias", _ => "generic")); + handlebars.Configuration.AliasProviders.Add(new DelegatedMemberAliasProvider().AddAlias("baseAlias", _ => "base").AddAlias("baseAlias", _ => "base")); + handlebars.Configuration.AliasProviders.Add(new DelegatedMemberAliasProvider().AddAlias("typedAlias", _ => "typed").AddAlias("typedAlias", _ => "typed")); + handlebars.Configuration.AliasProviders.Add(new DelegatedMemberAliasProvider().AddAlias(data.GetType(), "runtimeAlias", _ => "runtime")); + var template = handlebars.Compile("{{genericAlias}}|{{baseAlias}}|{{typedAlias}}|{{runtimeAlias}}"); + + Assert.Equal("generic|base|typed|runtime", template(data)); + } + + [Fact] + public void JsonPropertiesTakePrecedenceOverCustomAliases() + { + var data = Parse("{\"Name\": \"exact\", \"lower\": \"fallback\", \"nothing\": null}"); + var aliases = new DelegatedMemberAliasProvider() + .AddAlias(data.GetType(), "Name", _ => "alias") + .AddAlias(data.GetType(), "LOWER", _ => "alias") + .AddAlias(data.GetType(), "nothing", _ => "alias"); + var handlebars = Handlebars.Create(); + handlebars.Configuration.AliasProviders.Add(aliases); + var template = handlebars.Compile("{{Name}}|{{LOWER}}|{{nothing}}"); + + Assert.Equal("exact|fallback|", template(data)); + } } public class JsonElementTests : JsonTestBase { protected override object Parse(string json) => JsonSerializer.Deserialize(json)!; + [Fact] + public void BooleanTextKeepsExistingFormatting() + { + var template = Handlebars.Create().Compile("{{falseVal}}|{{trueVal}}"); + + Assert.Equal("False|True", template(Parse("{\"falseVal\": false, \"trueVal\": true}"))); + } + [Fact] public void ArrayMembersUseCustomAliases() { @@ -267,6 +303,14 @@ public class JsonNodeTests : JsonTestBase { protected override object Parse(string json) => JsonNode.Parse(json)!; + [Fact] + public void BooleanTextKeepsExistingFormatting() + { + var template = Handlebars.Create().Compile("{{falseVal}}|{{trueVal}}"); + + Assert.Equal("false|true", template(Parse("{\"falseVal\": false, \"trueVal\": true}"))); + } + [Fact] public void CreatedNumericValuesRespectTruthiness() { @@ -316,48 +360,49 @@ public void CreatedTextValuesRespectTruthiness() } [Fact] - public void EachIteratesValuesWrappingClrContainers() + public void CreatedBooleanValuesRespectTruthiness() { - // These report JsonValueKind.Array/Object but are JsonValue, not JsonArray/JsonObject. - var handlebars = Handlebars.Create(); - var nested = handlebars.Compile("{{#each items as |value key|}}{{key}}:{{value}},{{else}}empty{{/each}}"); - var root = handlebars.Compile("{{#each this as |value key|}}{{key}}:{{value}},{{else}}empty{{/each}}"); - var cases = new (JsonNode Value, string Expected)[] - { - (JsonValue.Create(new[] { 1, 2 })!, "0:1,1:2,"), - (JsonValue.Create(new List { "a", "b" })!, "0:a,1:b,"), - (JsonValue.Create(new Dictionary { ["a"] = 1, ["b"] = 2 })!, "a:1,b:2,"), - (JsonValue.Create(new WrappedItem())!, "Name:n,"), - (JsonValue.Create(new AnnotatedItem())!, "Name:n,"), - (JsonValue.Create(Array.Empty())!, "empty"), - (JsonValue.Create(new List())!, "empty"), - (JsonValue.Create(new Dictionary())!, "empty") - }; + var template = Handlebars.Create().Compile("{{#if this}}T{{else}}F{{/if}}"); - foreach (var (value, expected) in cases) - { - Assert.Equal(expected, nested(new JsonObject { ["items"] = value })); - Assert.Equal(expected, root(value)); - } + Assert.Equal("F", template(JsonValue.Create(false))); + Assert.Equal("T", template(JsonValue.Create(true))); + } + + [Theory] + [InlineData("false", "F|F")] + [InlineData("true", "T|T")] + [InlineData("0", "F|T")] + [InlineData("0.0", "F|T")] + [InlineData("1", "T|T")] + [InlineData("\"\"", "F|F")] + [InlineData("\"false\"", "T|T")] + public void CreatedJsonElementValuesRespectTruthiness(string json, string expected) + { + using var document = JsonDocument.Parse(json); + var value = JsonValue.Create(document.RootElement); + var template = Handlebars.Create().Compile("{{#if value}}T{{else}}F{{/if}}|{{#if value includeZero=true}}T{{else}}F{{/if}}"); + + Assert.Equal(expected, template(new JsonObject { ["value"] = value })); } [Fact] - public void MembersResolveOnValuesWrappingClrContainers() + public void WrappedClrValuesRemainOpaqueWithoutSerialization() { - // Members resolve against the wrapped CLR value, so CLR names apply rather than JSON property annotations. var handlebars = Handlebars.Create(); - var cases = new (JsonNode Value, string Source, string Expected)[] + var nested = handlebars.Compile("{{items.Name}}|{{items.Self}}|{{items.Count}}|{{items.[0]}}|{{lookup items 'Name'}}|{{#each items}}item{{else}}empty{{/each}}|{{#if items}}T{{else}}F{{/if}}"); + var root = handlebars.Compile("{{Name}}|{{Self}}|{{Count}}|{{this.[0]}}|{{lookup this 'Name'}}|{{#each this}}item{{else}}empty{{/each}}|{{#if this}}T{{else}}F{{/if}}"); + var values = new object[] { - (JsonValue.Create(new WrappedItem())!, "{{items.Name}}|{{#with items}}{{Name}}{{/with}}|{{lookup items 'Name'}}|{{items.Missing}}", "n|n|n|"), - (JsonValue.Create(new List { "a", "b" })!, "{{items.[0]}}|{{items.[1]}}|{{items.Count}}|{{lookup items 1}}|{{items.[2]}}", "a|b|2|b|"), - (JsonValue.Create(new[] { 1, 2 })!, "{{items.[1]}}|{{items.Length}}", "2|2"), - (JsonValue.Create(new Dictionary { ["a"] = 1 })!, "{{items.a}}|{{lookup items 'a'}}", "1|1"), - (JsonValue.Create(new AnnotatedItem())!, "{{items.Name}}|{{items.full_name}}", "n|") + new[] { 1, 2 }, new List { "a", "b" }, new Dictionary { ["Name"] = 1 }, + Array.Empty(), new List(), new Dictionary(), + new CyclicValue(), new ThrowingGetterValue(), typeof(string) }; - foreach (var (value, source, expected) in cases) + foreach (var value in values) { - Assert.Equal(expected, handlebars.Compile(source)(new JsonObject { ["items"] = value })); + var node = JsonValue.Create(value)!; + Assert.Equal("|||||empty|T", nested(new JsonObject { ["items"] = node })); + Assert.Equal("|||||empty|T", root(node)); } } @@ -371,17 +416,6 @@ public void MembersDoNotResolveOnScalarValues() Assert.Equal("|", template(Parse("{\"items\": \"abc\"}"))); } - [Fact] - public void PartialHashMergesPropertiesOfValuesWrappingClrContainers() - { - var handlebars = Handlebars.Create(); - handlebars.RegisterTemplate("partial", "{{Name}}{{a}}/{{extra}}"); - var template = handlebars.Compile("{{#with items}}{{> partial extra='x'}}{{/with}}"); - - Assert.Equal("n/x", template(new JsonObject { ["items"] = JsonValue.Create(new WrappedItem()) })); - Assert.Equal("1/x", template(new JsonObject { ["items"] = JsonValue.Create(new Dictionary { ["a"] = 1 }) })); - } - [Fact] public void ArrayMembersUseCustomAliases() { @@ -393,36 +427,30 @@ public void ArrayMembersUseCustomAliases() } [Fact] - public void DescriptorSupportsConcreteNodeTypesAndContainerProperties() + public void DescriptorSupportsConcreteObjectAndValueTypes() { - var provider = new JsonNodeObjectDescriptorProvider(new EnumerableObjectDescriptor(new ObjectDescriptorProvider(Array.Empty()))); - var nodes = new JsonNode[] { new JsonObject { ["stringVal"] = "a" }, new JsonArray(1), JsonValue.Create(false)! }; + var provider = new JsonNodeObjectDescriptorProvider(); + var nodes = new JsonNode[] { new JsonObject { ["stringVal"] = "a" }, JsonValue.Create(false)!, JsonValue.Create(new CyclicValue())! }; foreach (var node in nodes) { Assert.True(provider.TryGetDescriptor(node.GetType(), out var descriptor)); Assert.Equal(node.GetType(), descriptor!.DescribedType); var properties = descriptor.GetProperties(descriptor, node).Cast().Select(property => property.ToString()); - if (node is JsonArray) - { - Assert.Contains("Count", properties); - } - else - { - Assert.Equal(node is JsonObject ? new[] { "stringVal" } : Array.Empty(), properties); - } + Assert.Equal(node is JsonObject ? new[] { "stringVal" } : Array.Empty(), properties); } + + Assert.False(provider.TryGetDescriptor(typeof(JsonArray), out _)); } - private class WrappedItem + private class CyclicValue { - public string Name { get; set; } = "n"; + public CyclicValue Self => this; } - private class AnnotatedItem + private class ThrowingGetterValue { - [JsonPropertyName("full_name")] - public string Name { get; set; } = "n"; + public string Name => throw new InvalidOperationException("The wrapped value must not be inspected or serialized."); } } } diff --git a/source/Handlebars/Configuration/HandlebarsConfigurationAdapter.cs b/source/Handlebars/Configuration/HandlebarsConfigurationAdapter.cs index 7a924bda..b703461b 100644 --- a/source/Handlebars/Configuration/HandlebarsConfigurationAdapter.cs +++ b/source/Handlebars/Configuration/HandlebarsConfigurationAdapter.cs @@ -133,7 +133,7 @@ private ObservableList CreateObjectDescriptorProvider new ReadOnlyStringDictionaryObjectDescriptorProvider(), new StringDictionaryObjectDescriptorProvider(), new JsonElementObjectDescriptorProvider(AliasProviders), - new JsonNodeObjectDescriptorProvider(enumerableObjectDescriptor), + new JsonNodeObjectDescriptorProvider(AliasProviders), new LayoutViewModel.DescriptorProvider(), } .AddMany(descriptorProviders); diff --git a/source/Handlebars/HandlebarsUtils.cs b/source/Handlebars/HandlebarsUtils.cs index f7f00a75..ca6102b2 100644 --- a/source/Handlebars/HandlebarsUtils.cs +++ b/source/Handlebars/HandlebarsUtils.cs @@ -113,7 +113,6 @@ public static bool IsFalsyOrEmpty([NotNullWhen(false)] object? value, bool inclu { JsonObject obj => obj.Count == 0, JsonArray array => array.Count == 0, - JsonValue scalar => IsFalsyOrEmpty(scalar.GetValue(), includeZero), _ => false }; } diff --git a/source/Handlebars/Iterators/JsonNodeIterator.cs b/source/Handlebars/Iterators/JsonNodeIterator.cs index 96656a09..e02bc3c0 100644 --- a/source/Handlebars/Iterators/JsonNodeIterator.cs +++ b/source/Handlebars/Iterators/JsonNodeIterator.cs @@ -1,7 +1,5 @@ -using System.Text.Json; using System.Text.Json.Nodes; using HandlebarsDotNet.Compiler; -using HandlebarsDotNet.ObjectDescriptors; using HandlebarsDotNet.PathStructure; using HandlebarsDotNet.Runtime; using HandlebarsDotNet.ValueProviders; @@ -19,110 +17,17 @@ public void Iterate( TemplateDelegate ifEmpty ) { - var node = (JsonNode) input; - switch (node) + if (input is JsonObject obj) { - case JsonObject obj: - IterateObject(obj, writer, context, blockParamsVariables, template, ifEmpty); - break; - case JsonArray array: - IterateArray(array, writer, context, blockParamsVariables, template, ifEmpty); - break; - default: - if (node.GetValueKind() is JsonValueKind.Array or JsonValueKind.Object) - { - // A JsonValue wrapping a CLR collection or object (e.g. JsonValue.Create(new List())): - // iterate the underlying value with its own iterator. - IterateWrappedValue(node, writer, context, blockParamsVariables, template, ifEmpty); - } - else - { - IterateEmpty(writer, context, ifEmpty); - } - break; - } - } - - private static void IterateWrappedValue( - JsonNode node, - in EncodedTextWriter writer, - BindingContext context, - ChainSegment[] blockParamsVariables, - TemplateDelegate template, - TemplateDelegate ifEmpty - ) - { - var value = node.GetValue(); - if (ObjectDescriptor.TryCreate(value, out var descriptor) && descriptor.Iterator != null) - { - descriptor.Iterator.Iterate(writer, context, blockParamsVariables, value, template, ifEmpty); + IterateObject(obj, writer, context, blockParamsVariables, template, ifEmpty); return; } - IterateEmpty(writer, context, ifEmpty); - } - - private static void IterateEmpty(in EncodedTextWriter writer, BindingContext context, TemplateDelegate ifEmpty) - { using var innerContext = context.CreateFrame(); innerContext.Value = context.Value; ifEmpty(writer, innerContext); } - private static void IterateArray( - JsonArray 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.Count - 1; - foreach (var value in target) - { - 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( JsonObject target, in EncodedTextWriter writer, diff --git a/source/Handlebars/MemberAccessors/JsonNodeMemberAccessor.cs b/source/Handlebars/MemberAccessors/JsonNodeMemberAccessor.cs index c55297d8..2c031deb 100644 --- a/source/Handlebars/MemberAccessors/JsonNodeMemberAccessor.cs +++ b/source/Handlebars/MemberAccessors/JsonNodeMemberAccessor.cs @@ -1,30 +1,41 @@ -using System.Text.Json; +using System; +using System.Collections.Generic; using System.Text.Json.Nodes; -using HandlebarsDotNet.ObjectDescriptors; using HandlebarsDotNet.PathStructure; namespace HandlebarsDotNet.MemberAccessors { public sealed class JsonNodeMemberAccessor : IMemberAccessor { + private readonly IReadOnlyList _aliasProviders; + + public JsonNodeMemberAccessor() : this(Array.Empty()) + { + } + + public JsonNodeMemberAccessor(IReadOnlyList aliasProviders) + { + _aliasProviders = aliasProviders; + } + public bool TryGetValue(object instance, ChainSegment memberName, out object? value) { - value = null; + if (instance is JsonObject obj && TryGetPropertyValue(obj, memberName, out value)) + { + return true; + } - switch (instance) + var type = instance.GetType(); + for (var index = 0; index < _aliasProviders.Count; index++) { - case JsonObject obj: - return TryGetPropertyValue(obj, memberName, out value); - // A JsonValue wrapping a CLR collection or object (e.g. JsonValue.Create(new List())): - // resolve members on the underlying value with its own accessor, matching JsonNodeIterator. - // GetValueKind() serializes the wrapped value; System.Text.Json 9+ caches the result per node, 8.x does not. - case JsonValue wrapped when wrapped.GetValueKind() is JsonValueKind.Array or JsonValueKind.Object: - var underlying = wrapped.GetValue(); - return ObjectDescriptor.TryCreate(underlying, out var descriptor) - && descriptor.MemberAccessor.TryGetValue(underlying, memberName, out value); - default: - return false; + if (_aliasProviders[index].TryGetMemberByAlias(instance, type, memberName, out value)) + { + return true; + } } + + value = null; + return false; } private static bool TryGetPropertyValue(JsonObject obj, ChainSegment memberName, out object? value) diff --git a/source/Handlebars/ObjectDescriptors/JsonNodeObjectDescriptorProvider.cs b/source/Handlebars/ObjectDescriptors/JsonNodeObjectDescriptorProvider.cs index 058e54c1..b70ccb7e 100644 --- a/source/Handlebars/ObjectDescriptors/JsonNodeObjectDescriptorProvider.cs +++ b/source/Handlebars/ObjectDescriptors/JsonNodeObjectDescriptorProvider.cs @@ -1,8 +1,8 @@ using System; using System.Collections; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Text.Json; using System.Text.Json.Nodes; using HandlebarsDotNet.Iterators; using HandlebarsDotNet.MemberAccessors; @@ -10,56 +10,40 @@ namespace HandlebarsDotNet.ObjectDescriptors { /// - /// Provides support for , , and in templates. + /// Provides support for and in templates. /// public sealed class JsonNodeObjectDescriptorProvider : IObjectDescriptorProvider { - private readonly EnumerableObjectDescriptor _enumerableObjectDescriptor; - private static readonly Type Type = typeof(JsonNode); - private static readonly JsonNodeMemberAccessor MemberAccessor = new JsonNodeMemberAccessor(); + private readonly JsonNodeMemberAccessor _memberAccessor; private static readonly Func IteratorFactory = _ => new JsonNodeIterator(); private static readonly Func GetProperties = (descriptor, arg) => + arg is JsonObject obj + ? obj.Select(property => property.Key) + : Enumerable.Empty(); + + public JsonNodeObjectDescriptorProvider() : this(Array.Empty()) { - switch (arg) - { - case JsonObject obj: - return obj.Select(property => property.Key); - // A JsonValue wrapping a CLR collection or object: list the underlying value's properties, - // matching JsonNodeMemberAccessor and JsonNodeIterator. - case JsonValue wrapped when wrapped.GetValueKind() is JsonValueKind.Array or JsonValueKind.Object: - var underlying = wrapped.GetValue(); - return ObjectDescriptor.TryCreate(underlying, out var underlyingDescriptor) - ? underlyingDescriptor.GetProperties(underlyingDescriptor, underlying) - : Enumerable.Empty(); - default: - return Enumerable.Empty(); - } - }; + } - public JsonNodeObjectDescriptorProvider(EnumerableObjectDescriptor enumerableObjectDescriptor) + /// Consulted for members a does not resolve itself. + public JsonNodeObjectDescriptorProvider(IReadOnlyList aliasProviders) { - _enumerableObjectDescriptor = enumerableObjectDescriptor; + _memberAccessor = new JsonNodeMemberAccessor(aliasProviders); } public bool TryGetDescriptor(Type type, [NotNullWhen(true)] out ObjectDescriptor? value) { - if (!Type.IsAssignableFrom(type)) + if (!Type.IsAssignableFrom(type) || type == typeof(JsonArray)) { value = ObjectDescriptor.Empty; return false; } - if (type == typeof(JsonArray) && _enumerableObjectDescriptor.TryGetDescriptor(type, out var arrayDescriptor)) - { - value = new ObjectDescriptor(type, arrayDescriptor.MemberAccessor, arrayDescriptor.GetProperties, IteratorFactory, arrayDescriptor.Dependencies); - return true; - } - - value = new ObjectDescriptor(type, MemberAccessor, GetProperties, IteratorFactory); + value = new ObjectDescriptor(type, _memberAccessor, GetProperties, IteratorFactory); return true; } } From b77b0023b2ff1b41796ec9e5f1e5539bbc8da40e Mon Sep 17 00:00:00 2001 From: jsneedles Date: Mon, 19 Oct 2026 18:55:16 -0400 Subject: [PATCH 4/5] Revert "Block parameter fix" This reverts commit eee5c6c4db55948b6555b52fbac004febbc65368. --- source/Handlebars.Test/BasicIntegrationTests.cs | 15 --------------- source/Handlebars/BindingContext.cs | 11 ++++++++--- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/source/Handlebars.Test/BasicIntegrationTests.cs b/source/Handlebars.Test/BasicIntegrationTests.cs index 5890f4cb..d5211f82 100644 --- a/source/Handlebars.Test/BasicIntegrationTests.cs +++ b/source/Handlebars.Test/BasicIntegrationTests.cs @@ -991,21 +991,6 @@ public void DictionaryEnumeratorWithBlockParams(IHandlebars handlebars) Assert.Equal("hello foo world bar ", result); } - [Theory, ClassData(typeof(HandlebarsEnvGenerator))] - public void BlockParamsNamedLikeContextVariables(IHandlebars handlebars) - { - // Block params sharing a name with @-variables used to be looked up by the @-variable's slot in a - // different dictionary, so whether they resolved depended on the process's string hash seed. - var data = new { items = new List { "a", "b" } }; - - foreach (var name in new[] { "index", "key", "first", "last", "root", "parent" }) - { - var template = handlebars.Compile($"{{{{#each items as |item {name}|}}}}{{{{{name}}}}}:{{{{item}}}};{{{{/each}}}}"); - - Assert.Equal("0:a;1:b;", template(data)); - } - } - [Theory, ClassData(typeof(HandlebarsEnvGenerator))] public void DictionaryWithLastEnumerator(IHandlebars handlebars) { diff --git a/source/Handlebars/BindingContext.cs b/source/Handlebars/BindingContext.cs index 0240f1e7..42b1b38b 100644 --- a/source/Handlebars/BindingContext.cs +++ b/source/Handlebars/BindingContext.cs @@ -147,9 +147,14 @@ out WellKnownVariables[(int) WellKnownVariable.Parent] internal bool TryGetVariable(ChainSegment segment, [MaybeNullWhen(false)] out object value) { - // Block params are looked up by segment even for well-known names (e.g. `as |item index|`): - // WellKnownVariables holds entry indexes into ContextDataObject, not BlockParamsObject. - return BlockParamsObject.TryGetValue(segment, out value) + if (segment.WellKnownVariable != WellKnownVariable.None) + { + var wellKnownVariable = WellKnownVariables[(int) segment.WellKnownVariable]; + return BlockParamsObject.TryGetValue(wellKnownVariable, out value) + || (Descriptor.Value.MemberAccessor?.TryGetValue(Value!, segment, out value) ?? false); + } + + return BlockParamsObject.TryGetValue(segment, out value) || (Descriptor.Value.MemberAccessor?.TryGetValue(Value!, segment, out value) ?? false); } From b152d2bea31641b8e9c2dc07bdb8751338c0fbdf Mon Sep 17 00:00:00 2001 From: jsneedles Date: Mon, 19 Oct 2026 19:01:21 -0400 Subject: [PATCH 5/5] fix: restrict JsonArray member access to indexes, Count, and aliases --- source/Handlebars.Test/JsonTests.cs | 64 +++++++++++++++++-- .../JsonElementMemberAccessor.cs | 2 +- .../MemberAccessors/JsonNodeMemberAccessor.cs | 21 +++++- .../CollectionMemberAliasProvider.cs | 7 +- .../JsonNodeObjectDescriptorProvider.cs | 9 ++- 5 files changed, 93 insertions(+), 10 deletions(-) diff --git a/source/Handlebars.Test/JsonTests.cs b/source/Handlebars.Test/JsonTests.cs index 6390b279..d993cc28 100644 --- a/source/Handlebars.Test/JsonTests.cs +++ b/source/Handlebars.Test/JsonTests.cs @@ -218,6 +218,42 @@ public void PreservesArrayIndexAndCountAccess() Assert.Equal("a|b|2|||0", template(Parse("{\"arrayVal\": [{\"stringVal\": \"a\"}, {\"stringVal\": \"b\"}], \"emptyVal\": []}"))); } + [Theory] + [InlineData("Parent", false)] + [InlineData("parent", false)] + [InlineData("PARENT", false)] + [InlineData("Root", false)] + [InlineData("root", false)] + [InlineData("ROOT", false)] + [InlineData("Parent", true)] + [InlineData("parent", true)] + [InlineData("PARENT", true)] + [InlineData("Root", true)] + [InlineData("root", true)] + [InlineData("ROOT", true)] + public void ArrayMembersDoNotExposeNodeParents(string member, bool useAliases) + { + var handlebars = Handlebars.Create(); + if (useAliases) + { + handlebars.Configuration.UseCollectionMemberAliasProvider(); + } + + var template = handlebars.Compile("{{holder.items." + member + "}}|{{holder.items." + member + ".secret}}|{{lookup holder.items '" + member + "'}}|{{lookup (lookup holder.items '" + member + "') 'secret'}}"); + var data = Parse("{\"secret\": \"root\", \"holder\": {\"secret\": \"parent\", \"items\": [1]}}"); + + Assert.Equal("|||", template(data)); + } + + [Fact] + public void ArrayElementsKeepJsonPropertiesNamedLikeNodeMetadata() + { + var template = Handlebars.Create().Compile("{{items.[0].Parent}}|{{items.[0].Root}}|{{items.[0].Options}}|{{#each items}}{{this.Parent}}/{{this.Root}}/{{this.Options}}/{{lookup this 'Parent'}}/{{lookup this 'Root'}}/{{lookup this 'Options'}};{{/each}}"); + var data = Parse("{\"items\": [{\"Parent\": \"p\", \"Root\": \"r\", \"Options\": \"o\"}]}"); + + Assert.Equal("p|r|o|p/r/o/p/r/o;", template(data)); + } + [Theory] [InlineData(false, "2|2||")] [InlineData(true, "2|2|2|2")] @@ -426,11 +462,33 @@ public void ArrayMembersUseCustomAliases() Assert.Equal("2", template(Parse("{\"arrayVal\": [\"a\", \"b\"]}"))); } + [Theory] + [InlineData("Options", false)] + [InlineData("options", false)] + [InlineData("OPTIONS", false)] + [InlineData("Options", true)] + [InlineData("options", true)] + [InlineData("OPTIONS", true)] + public void ArrayMembersDoNotExposeNodeOptions(string member, bool useAliases) + { + var handlebars = Handlebars.Create(); + if (useAliases) + { + handlebars.Configuration.UseCollectionMemberAliasProvider(); + } + + var data = JsonNode.Parse("{\"items\": [1]}", new JsonNodeOptions { PropertyNameCaseInsensitive = true })!; + var template = handlebars.Compile("{{items." + member + "}}|{{items." + member + ".PropertyNameCaseInsensitive}}|{{lookup items '" + member + "'}}|{{#with (lookup items '" + member + "')}}{{PropertyNameCaseInsensitive}}{{/with}}"); + + Assert.Equal(true, data["items"]!.Options?.PropertyNameCaseInsensitive); + Assert.Equal("|||", template(data)); + } + [Fact] - public void DescriptorSupportsConcreteObjectAndValueTypes() + public void DescriptorSupportsConcreteNodeTypes() { var provider = new JsonNodeObjectDescriptorProvider(); - var nodes = new JsonNode[] { new JsonObject { ["stringVal"] = "a" }, JsonValue.Create(false)!, JsonValue.Create(new CyclicValue())! }; + var nodes = new JsonNode[] { new JsonObject { ["stringVal"] = "a" }, new JsonArray(1), JsonValue.Create(false)!, JsonValue.Create(new CyclicValue())! }; foreach (var node in nodes) { @@ -439,8 +497,6 @@ public void DescriptorSupportsConcreteObjectAndValueTypes() var properties = descriptor.GetProperties(descriptor, node).Cast().Select(property => property.ToString()); Assert.Equal(node is JsonObject ? new[] { "stringVal" } : Array.Empty(), properties); } - - Assert.False(provider.TryGetDescriptor(typeof(JsonArray), out _)); } private class CyclicValue diff --git a/source/Handlebars/MemberAccessors/JsonElementMemberAccessor.cs b/source/Handlebars/MemberAccessors/JsonElementMemberAccessor.cs index 1237d09b..3fa6d565 100644 --- a/source/Handlebars/MemberAccessors/JsonElementMemberAccessor.cs +++ b/source/Handlebars/MemberAccessors/JsonElementMemberAccessor.cs @@ -42,7 +42,7 @@ private static bool TryGetArrayMember(JsonElement element, ChainSegment memberNa return true; } - // Matches the Count property that List and JsonArray expose through reflection; + // Matches the Count property exposed by List and JsonArray; // Length is added by the collection alias provider, as for other collections. if (memberName.LowerInvariant == "count") { diff --git a/source/Handlebars/MemberAccessors/JsonNodeMemberAccessor.cs b/source/Handlebars/MemberAccessors/JsonNodeMemberAccessor.cs index 2c031deb..3a301e47 100644 --- a/source/Handlebars/MemberAccessors/JsonNodeMemberAccessor.cs +++ b/source/Handlebars/MemberAccessors/JsonNodeMemberAccessor.cs @@ -20,7 +20,8 @@ public JsonNodeMemberAccessor(IReadOnlyList aliasProviders public bool TryGetValue(object instance, ChainSegment memberName, out object? value) { - if (instance is JsonObject obj && TryGetPropertyValue(obj, memberName, out value)) + if ((instance is JsonObject obj && TryGetPropertyValue(obj, memberName, out value)) || + (instance is JsonArray array && TryGetArrayMember(array, memberName, out value))) { return true; } @@ -38,6 +39,24 @@ public bool TryGetValue(object instance, ChainSegment memberName, out object? va return false; } + private static bool TryGetArrayMember(JsonArray array, ChainSegment memberName, out object? value) + { + if (int.TryParse(memberName.LowerInvariant, out var index) && index >= 0 && index < array.Count) + { + value = array[index]; + return true; + } + + if (memberName.LowerInvariant == "count") + { + value = array.Count; + return true; + } + + value = null; + return false; + } + private static bool TryGetPropertyValue(JsonObject obj, ChainSegment memberName, out object? value) { if (obj.TryGetPropertyValue(memberName.TrimmedValue, out var property)) diff --git a/source/Handlebars/MemberAliasProvider/CollectionMemberAliasProvider.cs b/source/Handlebars/MemberAliasProvider/CollectionMemberAliasProvider.cs index cc8b67d3..55a390d1 100644 --- a/source/Handlebars/MemberAliasProvider/CollectionMemberAliasProvider.cs +++ b/source/Handlebars/MemberAliasProvider/CollectionMemberAliasProvider.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Linq; using System.Text.Json; +using System.Text.Json.Nodes; using HandlebarsDotNet.ObjectDescriptors; using HandlebarsDotNet.PathStructure; @@ -28,6 +29,10 @@ public bool TryGetMemberByAlias(object instance, Type targetType, ChainSegment m value = element.GetArrayLength(); return true; + case JsonArray array when memberAlias.Equals(Length): + value = array.Count; + return true; + case IEnumerable enumerable when ObjectDescriptorFactory.Current!.TryGetDescriptor(targetType, out var descriptor) && descriptor.GetProperties != null: var properties = descriptor.GetProperties(descriptor, enumerable); var property = properties.OfType().FirstOrDefault(o => @@ -47,4 +52,4 @@ public bool TryGetMemberByAlias(object instance, Type targetType, ChainSegment m } } } -} \ No newline at end of file +} diff --git a/source/Handlebars/ObjectDescriptors/JsonNodeObjectDescriptorProvider.cs b/source/Handlebars/ObjectDescriptors/JsonNodeObjectDescriptorProvider.cs index b70ccb7e..bf19e5d4 100644 --- a/source/Handlebars/ObjectDescriptors/JsonNodeObjectDescriptorProvider.cs +++ b/source/Handlebars/ObjectDescriptors/JsonNodeObjectDescriptorProvider.cs @@ -10,7 +10,7 @@ namespace HandlebarsDotNet.ObjectDescriptors { /// - /// Provides support for and in templates. + /// Provides support for in templates. /// public sealed class JsonNodeObjectDescriptorProvider : IObjectDescriptorProvider { @@ -18,7 +18,10 @@ public sealed class JsonNodeObjectDescriptorProvider : IObjectDescriptorProvider private readonly JsonNodeMemberAccessor _memberAccessor; - private static readonly Func IteratorFactory = _ => new JsonNodeIterator(); + private static readonly Func IteratorFactory = descriptor => + descriptor.DescribedType == typeof(JsonArray) + ? new ListIterator() + : new JsonNodeIterator(); private static readonly Func GetProperties = (descriptor, arg) => arg is JsonObject obj @@ -37,7 +40,7 @@ public JsonNodeObjectDescriptorProvider(IReadOnlyList alia public bool TryGetDescriptor(Type type, [NotNullWhen(true)] out ObjectDescriptor? value) { - if (!Type.IsAssignableFrom(type) || type == typeof(JsonArray)) + if (!Type.IsAssignableFrom(type)) { value = ObjectDescriptor.Empty; return false;