From ed74bf20459ff61fc0767acdd74982d571d2ef6d Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Sat, 20 Jun 2026 12:35:56 -0400 Subject: [PATCH 1/2] fix: WriteSafeString encoding consistent regardless of registration order (issue #559) When a helper with no arguments (e.g. {{link_to}}) was registered after Compile(), the parser treated it as a PathExpression rather than a HelperExpression. PathBinder.VisitPathExpression emitted code calling the object-returning Invoke overload, whose return value was then passed to EncodedTextWriter.Write which always HTML-encodes strings. This meant that WriteSafeString() output was re-encoded, producing <a> instead of . When the same helper was registered before Compile(), HelperConverter recognised the name and produced a HelperExpression; HelperFunctionBinder then emitted the void Invoke(EncodedTextWriter,...) overload which writes directly to the output writer and preserves WriteSafeString semantics. Fix: in PathBinder.VisitStatementExpression, detect PathExpressions that are valid helper literals (the ambiguous name-only case) and emit the same void Invoke(writer,...) call that HelperFunctionBinder would have produced. A LateBindHelperDescriptor Ref<> is registered in configuration.Helpers so that a subsequent RegisterHelper() call updates the Ref in-place, exactly as the HelperFunctionBinder late-binding mechanism does. Co-Authored-By: Claude Sonnet 4.6 --- .../Handlebars.Test/Issues/Issue559Tests.cs | 44 +++++++++++++++++++ .../Translation/Expression/PathBinder.cs | 39 +++++++++++++++- 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 source/Handlebars.Test/Issues/Issue559Tests.cs diff --git a/source/Handlebars.Test/Issues/Issue559Tests.cs b/source/Handlebars.Test/Issues/Issue559Tests.cs new file mode 100644 index 00000000..2f0b2c8e --- /dev/null +++ b/source/Handlebars.Test/Issues/Issue559Tests.cs @@ -0,0 +1,44 @@ +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(">(lateBindHelperDescriptor); configuration.Helpers.AddOrReplace(pathInfoLight, helper); } + else if (helper.Value is LateBindHelperDescriptor existingLateBindDescriptor + && !string.Equals(existingLateBindDescriptor.Name.Path, pathInfo.Path, System.StringComparison.Ordinal)) + { + // The case-insensitive lookup found a late-bind descriptor registered for a + // differently-cased path (e.g. {{TEST}} was registered; now compiling {{test}}). + // Create a fresh descriptor bound to the exact current path so that runtime + // path resolution is case-sensitive. + var lateBindHelperDescriptor = new LateBindHelperDescriptor(pathInfo); + helper = new Ref>(lateBindHelperDescriptor); + } else if (configuration.Compatibility.RelaxedHelperNaming) { pathInfoLight = pathInfoLight.TagComparer();