From 1fb51f6bdee76b9534199fb4067cc16df34144de Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Thu, 18 Jun 2026 20:05:03 -0400 Subject: [PATCH 01/10] Add Handlebars.js behavioral spec and coverage tests; update dev environment to net10 - Add HandlebarsSpec.md: exhaustive Handlebars.js behavioral specification derived from the JS spec files, documentation, and the Mustache spec (32 sections, 200+ behaviors, and a new Known Implementation Gaps section documenting 5 confirmed deviations from the canonical spec with root causes and compat risk ratings) - Add HandlebarsSpecCoverageTests.cs: ~100 test methods across 22 behavioral categories covering areas not exercised by the existing suite. 1746/1746 tests pass. Gap tests assert current (non-spec-compliant) behavior as documented regressions so that any future changes in those areas are detected immediately. - Update test and benchmark projects to target net10.0; drop EOL frameworks (netcoreapp3.1, net6, net452, net46, net461); bump BenchmarkDotNet and Microsoft.NET.Test.Sdk to current versions. Co-Authored-By: Claude Sonnet 4.6 --- .../Handlebars.Benchmark.csproj | 4 +- source/Handlebars.Test/Handlebars.Test.csproj | 11 +- source/Handlebars.Test/HandlebarsSpec.md | 1737 +++++++++++++++++ .../HandlebarsSpecCoverageTests.cs | 1098 +++++++++++ 4 files changed, 2842 insertions(+), 8 deletions(-) create mode 100644 source/Handlebars.Test/HandlebarsSpec.md create mode 100644 source/Handlebars.Test/HandlebarsSpecCoverageTests.cs diff --git a/source/Handlebars.Benchmark/Handlebars.Benchmark.csproj b/source/Handlebars.Benchmark/Handlebars.Benchmark.csproj index 1174d8a2..74794c00 100644 --- a/source/Handlebars.Benchmark/Handlebars.Benchmark.csproj +++ b/source/Handlebars.Benchmark/Handlebars.Benchmark.csproj @@ -2,14 +2,14 @@ Exe - netcoreapp3.1 + net10.0 false false HandlebarsNet.Benchmark - + diff --git a/source/Handlebars.Test/Handlebars.Test.csproj b/source/Handlebars.Test/Handlebars.Test.csproj index 7205360e..b5d05a2e 100644 --- a/source/Handlebars.Test/Handlebars.Test.csproj +++ b/source/Handlebars.Test/Handlebars.Test.csproj @@ -1,8 +1,8 @@  - netcoreapp3.1;net6 - $(TargetFrameworks);net452;net46;net461;net472 + net10.0 + $(TargetFrameworks);net472 6BA232A6-8C4D-4C7D-BD75-1844FE9774AF HandlebarsDotNet.Test false @@ -30,16 +30,15 @@ - + - - - + + diff --git a/source/Handlebars.Test/HandlebarsSpec.md b/source/Handlebars.Test/HandlebarsSpec.md new file mode 100644 index 00000000..1c28d1c4 --- /dev/null +++ b/source/Handlebars.Test/HandlebarsSpec.md @@ -0,0 +1,1737 @@ +# Handlebars Behavioral Specification + +Derived from the Handlebars.js 4.x official documentation, the canonical JS test suite +(`handlebars-lang/handlebars.js/spec/`), and the Mustache spec. Each section describes +exact behaviors that can be independently tested. + +--- + +## 1. Basic Expressions + +### 1.1 Simple Property Lookup +``` +Template: {{name}} +Data: { name: "World" } +Output: World +``` + +### 1.2 Missing Property Renders Empty +``` +Template: {{missing}} +Data: {} +Output: (empty string) +``` + +### 1.3 Null Value Renders Empty +``` +Template: {{val}} +Data: { val: null } +Output: (empty string — NOT "null") +``` + +### 1.4 Undefined Value Renders Empty +``` +Template: {{val}} +Data: { val: undefined } +Output: (empty string — NOT "undefined") +``` + +### 1.5 false Renders as String "false" +``` +Template: {{val}} +Data: { val: false } +Output: false +``` + +### 1.6 Zero Renders as String "0" +``` +Template: {{val}} +Data: { val: 0 } +Output: 0 +``` + +### 1.7 Empty String Renders as Empty +``` +Template: {{val}} +Data: { val: "" } +Output: (empty string) +``` + +### 1.8 Whitespace Inside Delimiters Is Ignored +``` +Template: {{ name }} +Data: { name: "World" } +Output: World + +Template: {{ name }} +Data: { name: "World" } +Output: World +``` + +### 1.9 Property Whose Value Is a Function — Function Is Called +``` +Template: {{greeting}} +Data: { greeting: () => "Hello" } +Output: Hello + +Template: {{greeting}} +Data: { greeting: function() { return this.name; }, name: "Rex" } +Output: Rex +``` + +### 1.10 Undefined Root Context Renders Silently +``` +Template: {{name}} +Data: undefined (null root context) +Output: (empty string — no error) +``` + +--- + +## 2. Nested Paths (Dot Notation) + +### 2.1 Simple Nesting +``` +Template: {{person.name}} +Data: { person: { name: "Alice" } } +Output: Alice +``` + +### 2.2 Deep Nesting +``` +Template: {{a.b.c}} +Data: { a: { b: { c: "deep" } } } +Output: deep +``` + +### 2.3 Null Intermediate Renders Empty (No Error) +``` +Template: {{person.name}} +Data: { person: null } +Output: (empty string) + +Template: {{a.b.c}} +Data: { a: null } +Output: (empty string) +``` + +### 2.4 Numeric Index in Path +``` +Template: {{list.0}} +Data: { list: ["first", "second"] } +Output: first + +Template: {{list.1}} +Data: { list: ["first", "second"] } +Output: second +``` + +### 2.5 Forward-Slash Path Separator (Legacy) +``` +Template: {{person/name}} +Data: { person: { name: "Alice" } } +Output: Alice +``` + +### 2.6 Hyphenated Identifiers +``` +Template: {{foo-bar}} +Data: { "foo-bar": "baz" } +Output: baz +``` + +--- + +## 3. Segment-Literal Paths + +### 3.1 Key With Spaces +``` +Template: {{[foo bar]}} +Data: { "foo bar": "value" } +Output: value +``` + +### 3.2 Key With Dot +``` +Template: {{[foo.bar]}} +Data: { "foo.bar": "value" } +Output: value +``` + +### 3.3 Nested With Segment Literal +``` +Template: {{obj.[a b]}} +Data: { obj: { "a b": "y" } } +Output: y +``` + +### 3.4 Array Index via Bracket +``` +Template: {{list.[0]}} +Data: { list: ["first"] } +Output: first + +Template: {{list.[1]}} +Data: { list: ["a", "b"] } +Output: b +``` + +### 3.5 Segment Literal With Special Chars +``` +Template: {{[foo.bar]}} +Data: { "foo.bar": "x" } +Output: x + +Template: {{obj.[key/with/slash]}} +Data: { obj: { "key/with/slash": "ok" } } +Output: ok +``` + +### 3.6 Chained Segment Literals +``` +Template: {{a.[b c].[d e]}} +Data: { a: { "b c": { "d e": "found" } } } +Output: found +``` + +### 3.7 Double-Quote String Literal Path (Bracket Notation Variant) +``` +Template: {{"foo bar"}} +Data: { "foo bar": "x" } +Output: x +``` + +### 3.8 Key Starting With `[` +``` +Template: {{[[startsWithBracket]}} +Data: { "[startsWithBracket": "x" } +Output: x +``` + +--- + +## 4. This / Self Reference + +### 4.1 `this` and `.` Are Equivalent +``` +Template: {{this}} +Data: "hello" (string as root context) +Output: hello + +Template: {{.}} +Data: 42 +Output: 42 +``` + +### 4.2 `this.property` +``` +Template: {{this.name}} +Data: { name: "Alice" } +Output: Alice +``` + +### 4.3 `./property` Forces Property Lookup (Bypasses Helper) +``` +Template: {{./name}} +Data: { name: "Alice" } (even if helper named "name" is registered) +Output: Alice (property wins) +``` + +### 4.4 Inside Block — `this` Is Current Iteration Context +``` +Template: {{#each list}}{{this}}{{/each}} +Data: { list: ["a", "b", "c"] } +Output: abc +``` + +--- + +## 5. Parent Context Navigation (`../`) + +### 5.1 Single Parent +``` +Template: {{#with child}}{{../name}}{{/with}} +Data: { name: "parent", child: {} } +Output: parent +``` + +### 5.2 Two Levels Up +``` +Template: {{#with a}}{{#with b}}{{../../top}}{{/with}}{{/with}} +Data: { top: "X", a: { b: {} } } +Output: X +``` + +### 5.3 Parent Reference Inside `#each` +``` +Template: {{#each list}}{{../prefix}}-{{this}}{{/each}} +Data: { prefix: "p", list: ["a","b"] } +Output: p-ap-b +``` + +### 5.4 `../` With Data Variable: Parent Loop Index +``` +Template: {{#each outer}}{{#each inner}}{{../@index}}{{/each}}{{/each}} +Data: { outer: ["x","y"], inner: [1,2] } +Output: (for each inner, prints the outer @index) +Expected: 0011 (outer index 0 twice, then 1 twice) +``` +Note: `../@index` accesses the `@index` of the enclosing `#each`. + +### 5.5 Deeply Nested Parent Index +See `IteratorTests.WithParentIndex` — three levels of `../` for `@index`, `@first`, `@last`. + +--- + +## 6. HTML Escaping + +### 6.1 Characters Escaped in `{{expr}}` + +| Character | Escaped As | +|-----------|-----------| +| `&` | `&` | +| `<` | `<` | +| `>` | `>` | +| `"` | `"` | +| `'` | `'` | +| `` ` `` | ``` | +| `=` | `=` | + +``` +Template: {{val}} +Data: { val: "&<>\"'`=" } +Output: &<>"'`= +``` + +### 6.2 Individual Escape Cases +``` +Template: {{val}} +Data: { val: "bold" } +Output: <b>bold</b> + +Template: {{val}} +Data: { val: "a & b" } +Output: a & b + +Template: {{val}} +Data: { val: "\"quoted\"" } +Output: "quoted" + +Template: {{val}} +Data: { val: "it's" } +Output: it's + +Template: {{val}} +Data: { val: "a=b" } +Output: a=b + +Template: {{val}} +Data: { val: "a`b" } +Output: a`b +``` + +### 6.3 Safe String Bypasses Encoding +``` +// If a helper returns a SafeString value, {{helper}} does not double-encode it. +Template: {{safeHelper}} +Helper returns: new Handlebars.SafeString("bold") +Output: bold (not encoded) +``` + +### 6.4 `{{{expr}}}` Triple-Stash — No Escaping +``` +Template: {{{val}}} +Data: { val: "bold" } +Output: bold + +Template: {{{val}}} +Data: { val: "&\"'" } +Output: &"' +``` + +### 6.5 `{{& expr}}` Ampersand — No Escaping +``` +Template: {{& val}} +Data: { val: "bold" } +Output: bold + +Template: {{&val}} +Data: { val: "" } +Output: +``` + +### 6.6 Non-String Values Are Coerced Then Rendered +``` +Template: {{val}} +Data: { val: 42 } → Output: 42 +Data: { val: true } → Output: true +Data: { val: false } → Output: false +``` +Null and undefined → empty string (not "null"/"undefined"). + +--- + +## 7. Unescaped Output Equivalence +`{{{expr}}}` and `{{& expr}}` are identical in behavior. Both produce unescaped output. + +--- + +## 8. Escaping Handlebars Delimiters + +### 8.1 `\{{` Produces Literal `{{...}}` +``` +Template: \{{name}} +Data: { name: "Alice" } +Output: {{name}} + +Template: a \{{b}} c +Data: {} +Output: a {{b}} c +``` + +### 8.2 Escaped Then Non-Escaped +``` +Template: \{{name}} {{name}} +Data: { name: "Alice" } +Output: {{name}} Alice +``` + +### 8.3 Backslash Before Backslash Before Expression +``` +Template: \\{{name}} +Data: { name: "Alice" } +Output: \Alice (one backslash, then the value — the \\ becomes \ and {{name}} is evaluated) +``` + +### 8.4 Data Value Containing `{{` Is Never Interpreted +``` +Template: {{val}} +Data: { val: "{{foo}}" } +Output: {{foo}} (curly braces in values are NOT HTML-escaped and NOT re-evaluated) +``` + +--- + +## 9. Comments + +### 9.1 Inline Comment — Produces No Output +``` +Template: a{{! comment }}b +Output: ab + +Template: {{! ignored }}hello +Output: hello +``` + +### 9.2 Inline Comment — Closed by First `}}` +``` +Template: a{{! foo }}rest +Output: rest (comment ends at first `}}`) +``` + +### 9.3 Block Comment — Can Contain `}}` +``` +Template: a{{!-- foo }} bar --}}b +Output: ab (block comment swallows the `}}`) +``` + +### 9.4 Multiline Block Comment +``` +Template: a{{!-- + multi + line +--}}b +Output: ab +``` + +### 9.5 Block Comment With Nested `{{...}}` +``` +Template: a{{!-- has {{nested}} expressions --}}b +Output: ab +``` + +### 9.6 Comment Does Not Strip Surrounding Whitespace (Without `~`) +``` +Template: "a {{! c }} b" +Output: "a b" (two spaces — one each side of the comment position) + +Template: "a\n{{! c }}\nb" +Output: "a\n\nb" (blank line where comment was) +``` + +--- + +## 10. Whitespace Control + +### 10.1 Strip Left with `{{~` +``` +Template: "Hello, {{~name}} !" +Data: { name: "World" } +Output: "Hello,World !" +``` + +### 10.2 Strip Right with `~}}` +``` +Template: "Hello, {{name~}} !" +Data: { name: "World" } +Output: "Hello, World!" +``` + +### 10.3 Strip Both with `{{~` and `~}}` +``` +Template: "Hello, {{~name~}} !" +Data: { name: "World" } +Output: "Hello,World!" +``` + +### 10.4 Strips All Whitespace Including Newlines +``` +Template: "1\n{{foo~}} \n\n 23\n{{bar}}4" +Data: { foo: "A", bar: "B" } +Output: "1\nA23\nB4" +``` + +### 10.5 Works on Block Helper Open/Close Tags +``` +Template: "{{#if cond~}}\n B\n{{~/if}}" +Data: { cond: true } +Output: "B" +``` + +### 10.6 Works on `{{else}}` +``` +Template: "{{#if cond~}} A {{~else~}} B {{~/if}}" +Data (cond=true): Output: "A" +Data (cond=false): Output: "B" +``` + +### 10.7 Works on Comments +``` +Template: "a {{~! comment ~}} b" +Output: "ab" + +Template: "a {{~!-- block comment --~}} b" +Output: "ab" +``` + +### 10.8 Works on Partials +``` +Template: "foo {{~> dude~}} bar" +Partial dude: "baz" +Output: "foobazbar" +``` + +### 10.9 Standalone Tags Strip Their Own Line +A tag that is the only non-whitespace content on a line will strip the entire line +(including the newline). This applies to block open/close tags, `{{else}}`, and +standalone comments/partials. +``` +Template: "a\n {{#if t}}\nb\n {{/if}}\nc" +Data: { t: true } +Output: "a\nb\nc" +``` + +### 10.10 Standalone Comment Strips Its Line +``` +Template: "a\n{{! comment }}\nb" +Output: "a\nb" (the entire comment line is removed) +``` + +--- + +## 11. `#if` Helper + +### 11.1 Basic True +``` +Template: {{#if val}}yes{{/if}} +Data: { val: true } +Output: yes +``` + +### 11.2 Basic False +``` +Template: {{#if val}}yes{{/if}} +Data: { val: false } +Output: (empty) +``` + +### 11.3 With `{{else}}` +``` +Template: {{#if val}}yes{{else}}no{{/if}} +Data (val=true): Output: yes +Data (val=false): Output: no +``` + +### 11.4 `{{else if}}` Chain +``` +Template: {{#if a}}A{{else if b}}B{{else}}C{{/if}} +Data (a=true): Output: A +Data (b=true, a=false): Output: B +Data (neither): Output: C +``` + +### 11.5 Falsy Values (All Render `{{else}}` / No Body) +``` +Data values that are falsy: +- false +- null / undefined / missing property +- 0 +- "" (empty string) +- [] (empty array) +- NaN (if applicable) + +Template: {{#if val}}yes{{else}}no{{/if}} +{ val: false } → no +{ val: null } → no +{ val: 0 } → no +{ val: "" } → no +{ val: [] } → no +(missing val) → no +``` + +### 11.6 Truthy Values +``` +- true +- non-zero numbers (including negative) +- non-empty string +- non-empty array +- any object +- function (function reference itself is truthy) + +{ val: true } → yes +{ val: 1 } → yes +{ val: -0.1 } → yes +{ val: "x" } → yes +{ val: ["a"] } → yes +{ val: { a: 1 } } → yes +``` + +### 11.7 `includeZero=true` Hash Argument +``` +Template: {{#if val includeZero=true}}yes{{else}}no{{/if}} +Data: { val: 0 } +Output: yes (zero is treated as truthy when includeZero=true) +``` + +### 11.8 Function Value — Function Is Called +``` +Template: {{#if val}}yes{{else}}no{{/if}} +Data: { val: () => true } +Output: yes + +Data: { val: () => false } +Output: no +``` + +### 11.9 Non-Empty Array Is Truthy +``` +Template: {{#if val}}yes{{else}}no{{/if}} +Data: { val: ["a"] } +Output: yes + +Data: { val: [] } +Output: no +``` + +### 11.10 Nested `#if` +``` +Template: {{#if a}}{{#if b}}both{{else}}only a{{/if}}{{else}}neither{{/if}} +Data (a=true, b=true): both +Data (a=true, b=false): only a +Data (a=false): neither +``` + +--- + +## 12. `#unless` Helper + +### 12.1 Basic — Renders When Value Is Falsy +``` +Template: {{#unless val}}no{{/unless}} +Data (val=false): Output: no +Data (val=true): Output: (empty) +``` + +### 12.2 With `{{else}}` +``` +Template: {{#unless val}}no{{else}}yes{{/unless}} +Data (val=false): Output: no +Data (val=true): Output: yes +``` + +### 12.3 Same Falsy Rules as `#if` +All values that are falsy for `#if` are truthy for `#unless` (i.e., render the main block). + +--- + +## 13. `#each` Helper + +### 13.1 Array Iteration — Basic +``` +Template: {{#each list}}{{this}} {{/each}} +Data: { list: ["a", "b", "c"] } +Output: a b c +``` + +### 13.2 Array Iteration — Object Items +``` +Template: {{#each people}}{{name}} {{/each}} +Data: { people: [{ name: "Alice" }, { name: "Bob" }] } +Output: Alice Bob +``` + +### 13.3 `@index` — Zero-Based Index +``` +Template: {{#each list}}{{@index}}:{{this}} {{/each}} +Data: { list: ["a", "b", "c"] } +Output: 0:a 1:b 2:c +``` + +### 13.4 `@key` — Index as Key for Arrays, Property Name for Objects +``` +Template: {{#each list}}{{@key}} {{/each}} +Data: { list: ["a", "b"] } +Output: 0 1 (same as @index for arrays) +``` + +### 13.5 `@first` and `@last` +``` +Template: {{#each list}}{{#if @first}}[{{/if}}{{this}}{{#if @last}}]{{/if}}{{/each}} +Data: { list: ["a", "b", "c"] } +Output: [abc] +``` + +### 13.6 Object Iteration — `@key` and `this` +``` +Template: {{#each obj}}{{@key}}={{this}} {{/each}} +Data: { obj: { a: 1, b: 2 } } +Output: a=1 b=2 (order follows insertion order) +``` + +### 13.7 `{{else}}` — Empty Array +``` +Template: {{#each list}}{{this}}{{else}}empty{{/each}} +Data: { list: [] } +Output: empty +``` + +### 13.8 `{{else}}` — Null / Undefined +``` +Template: {{#each list}}{{this}}{{else}}empty{{/each}} +Data: { list: null } → Output: empty +Data: (list missing) → Output: empty +Data: { list: false } → Output: empty +``` + +### 13.9 No Argument — Throws +``` +Template: {{#each}}{{this}}{{/each}} +Output: Throws HandlebarsException "Must pass iterator to #each" +``` + +### 13.10 Block Params +``` +Template: {{#each list as |item idx|}}{{idx}}:{{item}} {{/each}} +Data: { list: ["a", "b"] } +Output: 0:a 1:b +``` + +### 13.11 Block Params for Object Iteration +``` +Template: {{#each obj as |val key|}}{{key}}={{val}} {{/each}} +Data: { obj: { x: 1, y: 2 } } +Output: x=1 y=2 +``` + +### 13.12 Nested `#each` — Parent Access +``` +Template: {{#each outer}}{{#each inner}}{{../name}}-{{this}} {{/each}}{{/each}} +Data: { outer: [{ name: "A", inner: [1,2] }, { name: "B", inner: [3] }] } +Output: A-1 A-2 B-3 +``` + +### 13.13 Accessing `@root` Inside `#each` +``` +Template: {{#each list}}{{@root.prefix}}-{{this}} {{/each}} +Data: { prefix: "p", list: ["a","b"] } +Output: p-a p-b +``` + +### 13.14 `@key` on Object With HTML-Special Characters in Key +``` +Template: {{#each obj}}{{@key}}={{this}} {{/each}} +Data: { obj: { "": "val" } } +Output: <b>=val (@key is HTML-encoded when rendered through {{@key}}) +``` + +### 13.15 `@last` Is True Only on Final Item +``` +Template: {{#each list}}{{this}}{{#unless @last}},{{/unless}}{{/each}} +Data: { list: ["a","b","c"] } +Output: a,b,c +``` + +### 13.16 `each` on Dictionary With Various Key Types +``` +Template: {{#each dict}}{{@key}}:{{this}} {{/each}} +Data: Dictionary: { 1:"one", 2:"two" } +Output: 1:one 2:two +``` + +### 13.17 `each` on Single-Element Array +``` +Template: {{#each list}}{{@first}}/{{@last}}{{/each}} +Data: { list: ["only"] } +Output: True/True +``` + +--- + +## 14. `#with` Helper + +### 14.1 Basic Context Change +``` +Template: {{#with person}}{{first}} {{last}}{{/with}} +Data: { person: { first: "Alan", last: "Johnson" } } +Output: Alan Johnson +``` + +### 14.2 Access Parent via `../` +``` +Template: {{#with foo}}{{#if goodbye}}{{../world}}{{/if}}{{/with}} +Data: { foo: { goodbye: true }, world: "world" } +Output: world +``` + +### 14.3 `{{else}}` — When Context Is Falsy +``` +Template: {{#with val}}yes{{else}}no{{/with}} +Data (val=false): Output: no +Data (val=null): Output: no +Data (val=[]): Output: no +(val missing): Output: no +Data (val={}): Output: yes (empty object is truthy) +``` + +### 14.4 Block Params +``` +Template: {{#with person as |p|}}{{p.name}}{{/with}} +Data: { person: { name: "Alice" } } +Output: Alice +``` + +### 14.5 Block Params — Alias Does Not Shadow Same-Named Context Properties +``` +Template: {{#with person as |person|}}{{person.name}} is {{age}} years old{{/with}} +Data: { person: { name: "Erik", age: 42 } } +Output: Erik is 42 years old +Note: `age` resolves in the outer context (not inside person). +``` + +--- + +## 15. `{{lookup}}` Helper + +### 15.1 Dynamic Array Lookup by Index +``` +Template: {{#each people}}{{lookup ../cities @index}}{{/each}} +Data: { people: ["Nils","Yehuda"], cities: ["Darmstadt","San Francisco"] } +Output: DarmstadtSan Francisco +``` + +### 15.2 Dynamic Object Lookup by Key +``` +Template: {{lookup obj key}} +Data: { obj: { a: "found" }, key: "a" } +Output: found +``` + +### 15.3 Undefined Key Renders Empty +``` +Template: {{lookup obj key}} +Data: { obj: { a: "val" }, key: "missing" } +Output: (empty) +``` + +### 15.4 Lookup on Undefined Object Renders Empty +``` +Template: {{lookup missing key}} +Data: { key: "a" } +Output: (empty) +``` + +### 15.5 As a Subexpression +``` +Template: {{#with (lookup ../cities resident)~}}{{name}} ({{country}}){{/with}} +Data: { resident: "darmstadt", cities: { darmstadt: { name: "Darmstadt", country: "Germany" } } } +Output: Darmstadt (Germany) +``` + +### 15.6 Wrong Number of Arguments Throws +``` +Template: {{lookup obj}} +Output: Throws "{{lookup}} helper must have two or three arguments" +``` + +--- + +## 16. `{{log}}` Helper + +### 16.1 Produces No Output +``` +Template: before{{log "message"}}after +Output: beforeafter +``` + +### 16.2 With Level +``` +Template: {{log "warn" message}} +Output: (empty — just logs) +``` + +--- + +## 17. Custom Helpers + +### 17.1 Simple Value Helper +``` +handlebars.RegisterHelper("greet", (context, args) => "Hello!"); +Template: {{greet}} +Output: Hello! +``` + +### 17.2 Helper With Arguments +``` +Template: {{link url text}} +Helper writes: {args[1]} +Output: Link Text +``` + +### 17.3 Helper Output Is HTML-Encoded When Using `{{helper}}` +``` +Template: {{badHelper}} +Helper writes directly (unsafe string): "bold" +Output: <b>bold</b> + +Template: {{badHelper}} +Helper uses writer.WriteSafeString("bold") +Output: bold +``` + +### 17.4 Hash Arguments +``` +Template: {{myHelper key1="hello" key2=42}} +Helper accesses: options.hash["key1"] == "hello", options.hash["key2"] == 42 +``` + +### 17.5 Block Helper — `options.fn` and `options.inverse` +``` +Template: {{#myBlock}}yes{{else}}no{{/myBlock}} +Helper calls options.fn(context) when truthy, options.inverse(context) when not. +``` + +### 17.6 Block Helper with Arguments +``` +Template: {{#myBlock "arg1" key=value}}body{{/myBlock}} +Helper receives: args[0] == "arg1", options.hash["key"] == value +``` + +### 17.7 Helper With Same Name as Context Property — Helper Wins +``` +// If "name" is registered as a helper AND exists in context, the helper is called. +Template: {{name}} +Helper "name" registered. +Output: (helper's return value) + +// Use ./name to force property lookup: +Template: {{./name}} +Output: (context property value) +``` + +### 17.8 Helper Late Binding — Registered After Compile +``` +A helper registered after compile() is still invoked at render time. +``` + +### 17.9 Missing Helper — Resolves to Context Property or Empty +``` +Template: {{unknown}} +Data: { unknown: "found" } +Output: found (falls back to context property lookup) + +Template: {{unknown}} +Data: {} +Output: (empty) +``` + +### 17.10 Block Helper With Custom `@data` Variables +``` +A block helper can pass custom data to its block via options.data. +Children access it as @customVar. +``` + +### 17.11 Return Helper (Returns Value, Not Writer) +``` +handlebars.RegisterHelper("getData", (context, args) => args[0]); +Template: {{getData "hello"}} +Output: hello (return value is rendered as string) +``` + +--- + +## 18. Literals as Helper Arguments + +### 18.1 String Literals (Single and Double Quotes) +``` +Template: {{echo "hello"}} +Helper: returns args[0] +Output: hello + +Template: {{echo 'world'}} +Output: world + +Template: {{echo "it's fine"}} +Output: it's fine + +Template: {{echo 'say "hi"'}} +Output: say "hi" + +Template: {{echo ""}} +Output: (empty) +``` + +### 18.2 Number Literals +``` +Template: {{echo 42}} +Arg type: number (integer), value: 42 + +Template: {{echo -1}} +Arg value: -1 + +Template: {{echo 3.14}} +Arg value: 3.14 + +Template: {{echo 0}} +Arg value: 0 +``` + +### 18.3 Boolean Literals +``` +Template: {{echo true}} +Arg type: boolean, value: true + +Template: {{echo false}} +Arg value: false +``` + +### 18.4 Null Literal +``` +Template: {{echo null}} +Arg value: null (not the string "null") +``` + +### 18.5 String Literal Containing Curly Braces +``` +Template: {{echo '{{foo}}'}} +Output: {{foo}} (curly braces in string args are literal) +``` + +--- + +## 19. Subexpressions + +### 19.1 Basic Subexpression +``` +Template: {{outer (inner "arg")}} +inner("arg") is called first; its return value is passed to outer as an argument. +``` + +### 19.2 Nested Subexpressions +``` +Template: {{a (b (c d))}} +c(d) is evaluated first, result → b(), result → a(). +``` + +### 19.3 Subexpression as Hash Value +``` +Template: {{helper key=(sub arg)}} +sub(arg) is evaluated; its result is passed as hash["key"] to helper. +``` + +### 19.4 Multiple Subexpressions +``` +Template: {{helper (sub1 a) (sub2 b)}} +Both subexpressions evaluated independently; results passed as positional args. +``` + +### 19.5 Subexpression With Literals +``` +Template: {{outer (inner "string" 42 true)}} +inner receives "string", 42, true as args. +``` + +### 19.6 Subexpression With No Args +``` +Template: {{outer (inner)}} +inner() called with no args. +``` + +### 19.7 Subexpression Inside `#each` and `#with` +``` +Template: {{#each (getData)}}{{this}}{{/each}} +getData returns an array; each iterates it. + +Template: {{#with (lookup obj key)~}}{{name}}{{/with}} +lookup result used as context for with. +``` + +### 19.8 Subexpression Result Used as `#if` Argument +``` +Template: {{#if (isReady status)}}yes{{/if}} +isReady(status) evaluated; result used as truthy/falsy test. +``` + +--- + +## 20. Partials + +### 20.1 Basic Partial +``` +Template: {{> myPartial}} +Partial "myPartial": "Hello from partial" +Output: Hello from partial +``` + +### 20.2 Partial With Parent Context (Inherits by Default) +``` +Template: {{#each people}}{{> person}}{{/each}} +Partial "person": {{name}} +Data: { people: [{ name: "Alice" }] } +Output: Alice +``` + +### 20.3 Partial With Explicit Context Argument +``` +Template: {{> person data}} +Partial "person": {{name}} +Data: { data: { name: "Bob" }, name: "Root" } +Output: Bob (data is used as context, not root) +``` + +### 20.4 Partial With Hash Parameters +``` +Template: {{> person name="Charlie"}} +Partial "person": {{name}} +Output: Charlie +(Hash params override/extend the current context for the partial.) +``` + +### 20.5 Dynamic Partial +``` +Template: {{> (partialHelper)}} +Helper "partialHelper": returns "myPartial" +Partial "myPartial": "dynamic!" +Output: dynamic! +``` + +### 20.6 Missing Partial Throws +``` +Template: {{> missingPartial}} +Output: Throws error about missing partial +``` + +### 20.7 Block Partial — Default Content +``` +Template: {{#> myPartial}}default content{{/myPartial}} +If partial "myPartial" not registered: Output: default content +If partial "myPartial" registered and uses @partial-block: Output: (partial renders default) +``` + +### 20.8 Partial Referencing `{{> @partial-block}}` +``` +Partial "wrapper":
{{> @partial-block}}
+Template: {{#> wrapper}}inner content{{/wrapper}} +Output:
inner content
+``` + +### 20.9 Inline Partial (Defined in Template) +``` +Template: {{#*inline "myPartial"}}Hello {{name}}!{{/inline}}{{> myPartial}} +Data: { name: "World" } +Output: Hello World! +``` + +### 20.10 Inline Partial Overrides Registered Partial +``` +Template: {{#*inline "p"}}inline{{/inline}}{{> p}} +Registered partial "p": "registered" +Output: inline (inline takes priority) +``` + +### 20.11 Inline Partial Scope — Not Accessible Outside Block +``` +Template: {{#if true}}{{#*inline "p"}}scoped{{/inline}}{{/if}}{{> p}} +Output: Throws error (partial not found — "p" was scoped to the if block) +``` + +### 20.12 Partial Indentation Preserved +``` +Template: " {{> p}}" +Partial "p": "line1\nline2" +Output: " line1\n line2" (indentation applied to all lines) +``` + +### 20.13 Partial in `#each` +``` +Template: {{#each people}}{{> person}}{{/each}} +Partial "person": {{name}}, +Output: Alice,Bob, +``` + +--- + +## 21. Block Params + +### 21.1 `#each` With Block Params +``` +Template: {{#each list as |item index|}}{{index}}:{{item}} {{/each}} +Data: { list: ["a", "b"] } +Output: 0:a 1:b +``` + +### 21.2 `#with` With Block Params +``` +Template: {{#with person as |p|}}{{p.name}}{{/with}} +Data: { person: { name: "Alice" } } +Output: Alice +``` + +### 21.3 `#each` Object With Block Params (Value, Key Order) +``` +Template: {{#each obj as |val key|}}{{key}}={{val}} {{/each}} +Data: { obj: { a: 1, b: 2 } } +Output: a=1 b=2 +``` + +### 21.4 Block Param Shadows Context Property +``` +Template: {{#each list as |name|}}{{name}} {{/each}}{{name}} +Data: { name: "outer", list: ["inner1", "inner2"] } +Output: inner1 inner2 outer +``` + +--- + +## 22. Data Variables (`@`) + +### 22.1 `@root` — Always the Top-Level Context +``` +Template: {{#with person}}{{@root.title}}{{/with}} +Data: { title: "T", person: { name: "Alice" } } +Output: T +``` + +### 22.2 `@root` Inside Nested `#each` +``` +Template: {{#each list}}{{@root.prefix}}-{{this}} {{/each}} +Data: { prefix: "p", list: ["a","b"] } +Output: p-a p-b +``` + +### 22.3 Custom Global Data Passed at Render Time +``` +Template: {{#with input}}{{first}} {{@global1}}{{/with}} +Data: { input: { first: 1 } } +Extra: { global1: 2 } (passed as second arg to template function) +Output: 1 2 +``` + +### 22.4 `@index` in Array Iteration +``` +Template: {{#each list}}{{@index}}{{/each}} +Data: { list: ["a","b","c"] } +Output: 012 +``` + +### 22.5 `@key` in Object Iteration +``` +Template: {{#each obj}}{{@key}}{{/each}} +Data: { obj: { x:1, y:2 } } +Output: xy +``` + +### 22.6 `@first` and `@last` +``` +Template: {{#each list}}{{@first}},{{@last}} {{/each}} +Data: { list: [1, 2, 3] } +Output: True,False False,False False,True +``` + +### 22.7 Parent `@index` via `../` +``` +Template: {{#each outer}}{{#each inner}}{{../@index}}-{{@index}} {{/each}}{{/each}} +Data: { outer: ["A","B"], inner: [1,2] } +Output: 0-0 0-1 1-0 1-1 +``` + +--- + +## 23. Inverted Sections + +### 23.1 `{{^var}}` — Renders When Var Is Falsy +``` +Template: {{^people}}No one{{/people}} +Data: { people: [] } +Output: No one + +Data: { people: false } +Output: No one + +Data: (people missing) +Output: No one +``` + +### 23.2 `{{^var}}` — Does Not Render When Truthy +``` +Template: {{^people}}No one{{/people}} +Data: { people: ["Alice"] } +Output: (empty) +``` + +### 23.3 Inline `{{^}}` in Block Section +``` +Template: {{#people}}{{name}}{{^}}No one{{/people}} +Data: { people: [] } +Output: No one + +Data: { people: [{ name: "Alice" }] } +Output: Alice +``` + +### 23.4 `{{#var}}...{{else}}...{{/var}}` Is Equivalent to `{{#var}}...{{^}}...{{/var}}` +``` +Template A: {{#val}}yes{{else}}no{{/val}} +Template B: {{#val}}yes{{^}}no{{/val}} +Both behave identically. +``` + +--- + +## 24. Decorators + +### 24.1 Basic Decorator +``` +handlebars.RegisterDecorator("myDec", (fn, props, container, options) => { ... }) +Template: {{* myDec}}... +``` + +### 24.2 Inline Partial as Decorator +``` +Template: {{#*inline "partialName"}}content{{/inline}} +This is syntactic sugar for registering a partial within the template scope. +``` + +### 24.3 Block Decorator +``` +Template: {{#* myDec}}...{{/myDec}} +``` + +--- + +## 25. Interaction: `#if` + `@first`/`@last` + +``` +Template: {{#each list}}{{#if @first}}FIRST{{/if}}{{this}}{{#if @last}}LAST{{/if}}{{/each}} +Data: { list: ["a","b","c"] } +Output: FIRSTabc LAST (effectively: "FIRSTa" + "b" + "cLAST" = "FIRSTabcLAST") +``` + +--- + +## 26. Interaction: `#each` + Partials + `../` + +``` +Template: {{#each people}}{{> person}}{{/each}} +Partial "person": {{name}} ({{../teamName}}) +Data: { teamName: "Engineering", people: [{ name: "Alice" }, { name: "Bob" }] } +Output: Alice (Engineering)Bob (Engineering) +``` + +--- + +## 27. Interaction: Subexpressions in `#if` + +``` +Template: {{#if (eq a b)}}same{{else}}different{{/if}} +Helper "eq": (context, args) => args[0] === args[1] +Data: { a: 1, b: 1 } +Output: same + +Data: { a: 1, b: 2 } +Output: different +``` + +--- + +## 28. Interaction: `#with` + `../` + `@root` + +``` +Template: {{#with person}}{{name}} / {{../teamName}} / {{@root.teamName}}{{/with}} +Data: { teamName: "Eng", person: { name: "Alice" } } +Output: Alice / Eng / Eng +``` + +--- + +## 29. Interaction: Nested `#each` + `@root` + `../@index` + +``` +Template: {{#each outer}}{{#each inner}}{{@root.title}}/{{../@index}}/{{@index}} {{/each}}{{/each}} +Data: { title: "T", outer: ["A","B"], inner: [1,2] } +Output: T/0/0 T/0/1 T/1/0 T/1/1 +``` + +--- + +## 30. Interaction: Inline Partial + `#each` + +``` +Template: {{#*inline "row"}}{{name}}{{/inline}}{{#each people}}{{> row}}{{/each}} +Data: { people: [{ name: "Alice" }, { name: "Bob" }] } +Output: AliceBob +``` + +--- + +## 31. Interaction: `#each` + Block Partial + +``` +Template: {{#each people}}{{#> row}}DEFAULT{{/row}}{{/each}} +Partial "row": {{name}} +Data: { people: [{ name: "Alice" }] } +Output: Alice (partial found — default not used) +``` + +--- + +## 32. Edge Cases + +### 32.1 Empty Template +``` +Template: (empty string) +Output: (empty string) +``` + +### 32.2 Template With No Expressions (Pure Text) +``` +Template: Hello, world! +Output: Hello, world! +``` + +### 32.3 Expression With Only Whitespace Inside +``` +Template: {{ }} +Output: Parse error / empty expression +``` + +### 32.4 Deeply Nested Null Chain +``` +Template: {{a.b.c.d.e}} +Data: { a: null } +Output: (empty — no error at any level) +``` + +### 32.5 Block Helper With No Body +``` +Template: {{#each list}}{{/each}} +Data: { list: ["a","b"] } +Output: (empty — body has no content) +``` + +### 32.6 Multiple Expressions Concatenated +``` +Template: {{a}}{{b}}{{c}} +Data: { a: "x", b: "y", c: "z" } +Output: xyz +``` + +### 32.7 `{{#if}}` With Literal `true` / `false` +``` +Template: {{#if true}}yes{{/if}} +Output: yes + +Template: {{#if false}}yes{{else}}no{{/if}} +Output: no +``` + +### 32.8 `{{#if}}` With Literal Number +``` +Template: {{#if 1}}yes{{/if}} +Output: yes + +Template: {{#if 0}}yes{{else}}no{{/if}} +Output: no +``` + +### 32.9 `{{#each}}` Produces No Output for Empty Collections +``` +Template: prefix{{#each list}}X{{/each}}suffix +Data: { list: [] } +Output: prefixsuffix +``` + +### 32.10 Helper Registered After Template Compiled (Late Binding) +``` +// Compile first, register helper after — helper is still resolved at render time. +var template = hb.Compile("{{lateHelper}}"); +hb.RegisterHelper("lateHelper", ...); +Output: (result of lateHelper) +``` + +### 32.11 Block Helper Conflicts With Context Property — Helper Wins +``` +handlebars.RegisterHelper("blockName", ...) +Template: {{#blockName}}...{{/blockName}} +// blockName is the helper, not a property traversal. +``` + +### 32.12 Context Is Enumerable — Implicit Iteration Without `#each` +``` +Template: {{#list}}{{this}} {{/list}} +Data: { list: ["a", "b", "c"] } +Output: a b c ({{#list}} iterates if list is an array) +``` + +### 32.13 Block Section With Falsy Value — Uses `{{else}}` +``` +Template: {{#val}}yes{{else}}no{{/val}} +Data: { val: false } +Output: no + +Data: { val: [] } +Output: no +``` + +### 32.14 `{{#each}}` on Object With No Properties +``` +Template: {{#each obj}}{{@key}}{{else}}empty{{/each}} +Data: { obj: {} } +Output: empty (empty object triggers else — OR renders nothing depending on impl) +``` + +### 32.15 Partial With Empty Context +``` +Partial "p": {{name}} welcome +Template: {{> p}} +Data: {} (name missing) +Output: welcome (empty before "welcome") +``` + +### 32.16 String Context — Access `.length` +``` +Template: {{.}}{{length}} +Data: "bye" (string as root context) +Output: bye3 +``` + +### 32.17 Number Context +``` +Template: {{this}} +Data: 42 (number as root context) +Output: 42 +``` + +--- + +## Coverage Notes: What Handlebars.Net Already Tests Well + +The following areas have solid existing coverage in Handlebars.Net's test suite: +- Basic paths, nested paths, null/missing handling +- HTML encoding (basic characters), triple stash +- `#if`/`#else`/`#else if` with typical values +- `#each` on arrays, objects, dictionaries (various key types) +- `@index`, `@key`, `@first`, `@last` +- `../` parent navigation and `../@index` deep nesting +- `@root` access +- `#with` basic and with block params +- Partials (basic, dynamic, block, inline) +- Subexpressions (basic, nested, hash values) +- Decorators +- Block params (`as |item index|`) +- Whitespace control (basic `~`, standalone tags) +- Numeric literals as args +- Comments (basic and block) +- `{{lookup}}` basic and as subexpression +- Escaped handlebars (`\{{`) +- Inverted sections (`{{^}}`) + +## Known Implementation Gaps + +These are confirmed behavioral differences between Handlebars.Net and the canonical Handlebars.js spec, +documented as of June 2026. Each gap has a corresponding regression test in `HandlebarsSpecCoverageTests.cs` +that asserts the *current* (non-spec-compliant) behavior so that changes in this area are caught. + +--- + +### Gap 1: Default HTML encoder does not encode `'`, `` ` ``, or `=` + +**Behavioral gap:** +Handlebars.js encodes seven characters for XSS safety: `& < > " ' `` =`. +Handlebars.Net's default encoder (`HtmlEncoderLegacy`) only encodes four: `& < > "`. +Single quotes, backticks, and equals signs pass through unescaped with the default configuration. + +**Technical root cause:** +`HandlebarsConfiguration.cs` line 91 sets `TextEncoder = new HtmlEncoderLegacy()`. +`HtmlEncoderLegacy.cs` explicitly omits `'`, `` ` ``, and `=` — the class-level comment +documents this as intentional. The spec-compliant `HtmlEncoder` class exists in the library +and encodes all seven characters but must be opted into via +`new HandlebarsConfiguration { TextEncoder = new HtmlEncoder() }`. + +**Backward-compatibility risk: HIGH.** +Changing the default encoder to `HtmlEncoder` would be a breaking change for any user whose +templates output `'`, `` ` ``, or `=` in HTML-escaped contexts and expect them to pass through +unescaped. This affects common patterns like `href='{{url}}'`. A new major version (or an +explicit opt-out escape hatch) would be required to change this default safely. + +**Regression tests:** `HtmlEncoding_SingleQuote_DefaultEncoderGap`, `HtmlEncoding_Backtick_DefaultEncoderGap`, +`HtmlEncoding_Equals_DefaultEncoderGap`, `HtmlEncoding_AllSpecialCharsAtOnce_DefaultEncoderGap` +(assert current unencoded behavior); the `_FullEncoderSpec` variants verify the correct behavior +is available via opt-in. + +--- + +### Gap 2: `{{log}}` built-in helper is not implemented + +**Behavioral gap:** +Handlebars.js provides `{{log expr}}` as a built-in that passes its arguments to a platform logger +and produces no template output. In Handlebars.Net, `{{log ...}}` compiles without error but throws +`HandlebarsRuntimeException: Template references a helper that cannot be resolved. Helper 'log'` +at render time. + +**Technical root cause:** +`BuildInHelpersFeature.cs` registers the built-in helpers. The `log` helper is absent from this +registration list. No default logger delegation or no-op fallback is provided. + +**Backward-compatibility risk: LOW.** +Adding `log` as a registered no-op (or logger-delegating) helper is purely additive. Because +`{{log ...}}` currently throws at render time, no existing working code can be relying on this +behavior — any template containing `{{log}}` would be broken already. + +**Regression tests:** `Log_ProducesNoOutput`, `Log_WithStringLiteralProducesNoOutput` +(assert that render throws `HandlebarsRuntimeException`). + +--- + +### Gap 3: Boolean `false` renders as `"False"` (capital F) instead of `"false"` (lowercase) + +**Behavioral gap:** +Handlebars.js renders `{{val}}` with `val = false` as the string `"false"` (lowercase), matching +JavaScript's boolean-to-string conversion. Handlebars.Net renders it as `"False"` (capital F) +because .NET's `Boolean.ToString()` returns `"False"`. The same applies to `@first` and `@last` +data variables, which render as `"True"`/`"False"` instead of `"true"`/`"false"`. +Note: `false` is still correctly falsy for `{{#if val}}` — only the string rendering is wrong. + +**Technical root cause:** +The value-rendering pipeline uses the default .NET `ToString()` on `System.Boolean` without +special-casing to match JavaScript casing conventions. No post-processing lowercases boolean strings. + +**Backward-compatibility risk: MEDIUM.** +Any code that renders boolean values via `{{expr}}` and checks the output string for `"False"` +(or `"True"`) would need updating. Any stored/displayed output containing these strings would +change. Scope is limited to template outputs of raw boolean values, but this is a subtle +cross-platform behavioral difference that could affect data contracts. + +**Regression tests:** `Path_FalseRendersAsFalseString`, `RenderVsFalsy_FalseRendersButIsFalsyForIf` +(assert `"False"` as the current output). + +--- + +### Gap 4: Whitespace control (tilde) is not applied around comment tokens + +**Behavioral gap:** +Handlebars.js supports `{{~! comment ~}}` and `{{~!-- block comment --~}}` to strip surrounding +whitespace around comments, just as tilde works on other expression types. + +In Handlebars.Net: +- `{{~! inline comment ~}}` compiles without error but does NOT strip surrounding whitespace. + A template `"a {{~! comment ~}} b"` produces `"a b"` instead of `"ab"`. +- `{{~!-- block comment --~}}` is a **parse error**: `HandlebarsParserException: Reached end of + template in the middle of a comment`. The `~!--` prefix disrupts the comment-start detection. + +**Technical root cause:** +The whitespace-stripping pass does not handle comment expression node types — it strips whitespace +adjacent to expression nodes but comment nodes are not included in that set. +For block comments, the lexer matches the comment-start token on the literal string `{{!--` and +does not accept the `{{~!--` variant, causing the comment to be unterminated. + +**Backward-compatibility risk: LOW.** +This is an unimplemented feature. No existing code can be relying on comments NOT stripping +whitespace, and no code using `{{~!--` can be working at all (it's a parse error). Adding correct +behavior is purely additive with zero breakage to existing working templates. + +**Regression tests:** `WhitespaceControl_OnInlineComment` (asserts `"a b"`, not `"ab"`), +`WhitespaceControl_OnBlockComment` (asserts `HandlebarsParserException` at compile time). + +--- + +### Gap 5: `{{^}}` (standalone caret) inside a block body is not an `else` separator + +**Behavioral gap:** +Handlebars.js allows `{{^}}` (caret with no name) as an inline else equivalent inside any block: +`{{#val}}truthy{{^}}falsy{{/val}}` is semantically identical to +`{{#val}}truthy{{else}}falsy{{/val}}`. + +In Handlebars.Net, `{{^}}` inside a block body is not treated as an else separator. Instead, it +is resolved as a path expression referencing the current context (equivalent to `{{this}}`), so: +- `val = true` → the truthy branch runs, then `{{^}}` outputs `context.ToString()`, then the + "false-branch text" appears as literal body text: output is `"yes{ val = True }no"` (with + the `=` potentially HTML-encoded depending on the active encoder). +- `val = false` → the block body is skipped entirely (falsy), and no inverse block is registered, + so output is `""` rather than `"no"`. + +The working alternative is `{{#val}}truthy{{else}}falsy{{/val}}` — `{{else}}` works correctly. + +**Technical root cause:** +The block section accumulator does not recognize the empty `{{^}}` token (caret with no name) as +an inverse-block separator for non-`#each` block sections. The caret falls through to path +resolution, where it resolves to the current context object. + +**Backward-compatibility risk: LOW.** +The `{{else}}` keyword is the canonical documented form and works correctly. `{{^}}` with no name +is an edge case that is rarely used and is arguably ambiguous in documentation. Fixing it would +only affect templates explicitly using the bare `{{^}}` syntax inside blocks, which currently +produce incorrect output anyway. + +**Regression tests:** `InvertedSection_InlineCaretSyntax` (asserts that `val=true` output starts +with `"yes"`, ends with `"no"`, and is NOT equal to `"yes"`; `val=false` output is `""`). + +--- + +## Coverage Gaps (Priority Areas for New Tests) + +1. HTML encoding: `'`, `` ` ``, `=` characters specifically +2. `{{& expr}}` ampersand unescaped syntax +3. `\\\{{` (backslash before escaped expression → single backslash in output) +4. String context as root (`{{.}}` when root is a string) +5. Function-valued properties (called automatically) +6. `#if includeZero=true` hash argument +7. `#unless` with `{{else}}` +8. `#each` with no argument → should throw +9. `#each` on empty object → `{{else}}` behavior +10. `#each @key` HTML encoding when key has special chars +11. `#with {{else}}` for all falsy cases (null, false, empty array) +12. `{{log}}` produces no output +13. `{{#if true}}` / `{{#if false}}` with literal booleans +14. `{{#if 0}}` / `{{#if 1}}` with literal numbers +15. Block comment containing `}}` — `{{!-- has }} inside --}}` +16. Whitespace control on `{{else}}`, comments, and partials +17. `{{! comment }}` does not strip surrounding whitespace (without `~`) +18. Inline partial scope — not accessible outside defining block +19. Block partial with `{{> @partial-block}}` inside partial +20. Custom `@data` variables from helpers +21. `{{#with}}` when context is a function (function called for value) +22. `{{#each}}` when collection is a function (function called for value) +23. `../` navigation inside partials +24. Partial indentation behavior +25. `{{#if}}` with non-empty array is truthy; empty array is falsy +26. Standalone block tags strip entire line including newline diff --git a/source/Handlebars.Test/HandlebarsSpecCoverageTests.cs b/source/Handlebars.Test/HandlebarsSpecCoverageTests.cs new file mode 100644 index 00000000..b44ebde4 --- /dev/null +++ b/source/Handlebars.Test/HandlebarsSpecCoverageTests.cs @@ -0,0 +1,1098 @@ +using System; +using System.Collections.Generic; +using HandlebarsDotNet.Features; +using Xunit; + +namespace HandlebarsDotNet.Test +{ + /// + /// Tests derived from the Handlebars.js spec (handlebars-lang/handlebars.js/spec/) + /// covering behaviors not exercised by the existing test suite. + /// See HandlebarsSpec.md in this directory for the full behavioral specification. + /// + /// IMPLEMENTATION GAPS — tests that assert the current (non-spec-compliant) behavior + /// are annotated with [SPEC GAP]. Each gap documents: + /// SPEC: what canonical Handlebars.js specifies + /// CURRENT: what Handlebars.Net currently produces + /// SOURCE: where in the library the divergence lives + /// COMPAT: backward-compatibility risk of closing the gap + /// + public class HandlebarsSpecCoverageTests + { + // ───────────────────────────────────────────────────────────── + // 1. HTML ENCODING EDGE CASES + // ───────────────────────────────────────────────────────────── + + // [SPEC GAP] The default encoder (HtmlEncoderLegacy) does not encode ', `, or =. + // SPEC: Handlebars.js encodes &, <, >, ", ', `, and = for XSS safety. + // CURRENT: HtmlEncoderLegacy (set as HandlebarsConfiguration default) omits ', `, and =. + // HtmlEncoder (opt-in via HandlebarsConfiguration.TextEncoder) encodes all 7. + // SOURCE: HtmlEncoderLegacy.cs — the comment there states the omissions are intentional. + // HandlebarsConfiguration.cs line 91 sets the default to HtmlEncoderLegacy. + // COMPAT: HIGH — switching the default would break any code that embeds ' ` = in + // templates and expects them to pass through unescaped. A major version bump + // (or an opt-out flag) would be required to change this default safely. + + [Fact] + public void HtmlEncoding_SingleQuote_DefaultEncoderGap() + { + // Gap test: default HtmlEncoderLegacy does NOT encode single quotes + var hbs = Handlebars.Create(); + Assert.Equal("it's", hbs.Compile("{{val}}")(new { val = "it's" })); + } + + [Fact] + public void HtmlEncoding_SingleQuote_FullEncoderSpec() + { + // Spec-compliant behavior available via opt-in HtmlEncoder + var hbs = Handlebars.Create(new HandlebarsConfiguration { TextEncoder = new HtmlEncoder() }); + Assert.Equal("it's", hbs.Compile("{{val}}")(new { val = "it's" })); + } + + [Fact] + public void HtmlEncoding_Backtick_DefaultEncoderGap() + { + var hbs = Handlebars.Create(); + Assert.Equal("a`b", hbs.Compile("{{val}}")(new { val = "a`b" })); + } + + [Fact] + public void HtmlEncoding_Backtick_FullEncoderSpec() + { + var hbs = Handlebars.Create(new HandlebarsConfiguration { TextEncoder = new HtmlEncoder() }); + Assert.Equal("a`b", hbs.Compile("{{val}}")(new { val = "a`b" })); + } + + [Fact] + public void HtmlEncoding_Equals_DefaultEncoderGap() + { + var hbs = Handlebars.Create(); + Assert.Equal("a=b", hbs.Compile("{{val}}")(new { val = "a=b" })); + } + + [Fact] + public void HtmlEncoding_Equals_FullEncoderSpec() + { + var hbs = Handlebars.Create(new HandlebarsConfiguration { TextEncoder = new HtmlEncoder() }); + Assert.Equal("a=b", hbs.Compile("{{val}}")(new { val = "a=b" })); + } + + [Fact] + public void HtmlEncoding_AllSpecialCharsAtOnce_DefaultEncoderGap() + { + // Default encoder: &, <, >, " are encoded; ', `, = are not + var hbs = Handlebars.Create(); + Assert.Equal("&<>"'`=", hbs.Compile("{{val}}")(new { val = "&<>\"'`=" })); + } + + [Fact] + public void HtmlEncoding_AllSpecialCharsAtOnce_FullEncoderSpec() + { + // Opt-in HtmlEncoder encodes all 7 characters per Handlebars.js spec + var hbs = Handlebars.Create(new HandlebarsConfiguration { TextEncoder = new HtmlEncoder() }); + Assert.Equal("&<>"'`=", hbs.Compile("{{val}}")(new { val = "&<>\"'`=" })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void HtmlEncoding_TripleStashSkipsEncoding(IHandlebars hbs) + { + var template = hbs.Compile("{{{val}}}"); + Assert.Equal("&<>\"'`=", template(new { val = "&<>\"'`=" })); + } + + // ───────────────────────────────────────────────────────────── + // 2. BACKSLASH ESCAPING + // ───────────────────────────────────────────────────────────── + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void BackslashEscape_SingleBeforeMustache(IHandlebars hbs) + { + // \{{name}} → {{name}} (the expression is not evaluated) + var template = hbs.Compile(@"\{{name}}"); + Assert.Equal("{{name}}", template(new { name = "Alice" })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void BackslashEscape_EscapedThenNonEscaped(IHandlebars hbs) + { + // \{{name}} {{name}} → {{name}} Alice + var template = hbs.Compile(@"\{{name}} {{name}}"); + Assert.Equal("{{name}} Alice", template(new { name = "Alice" })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void BackslashEscape_DoubleBackslashBeforeMustache(IHandlebars hbs) + { + // \\{{name}} → \Alice (one backslash literal, then evaluated) + var template = hbs.Compile(@"\\{{name}}"); + Assert.Equal(@"\Alice", template(new { name = "Alice" })); + } + + // ───────────────────────────────────────────────────────────── + // 3. COMMENTS + // ───────────────────────────────────────────────────────────── + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Comment_InlineProducesNoOutput(IHandlebars hbs) + { + var template = hbs.Compile("a{{! this is a comment }}b"); + Assert.Equal("ab", template(new { })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Comment_BlockCanContainClosingBraces(IHandlebars hbs) + { + // Block comment {{!-- --}} can contain }} without closing + var template = hbs.Compile("a{{!-- this has }} inside it --}}b"); + Assert.Equal("ab", template(new { })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Comment_BlockCanContainMustacheExpression(IHandlebars hbs) + { + var template = hbs.Compile("a{{!-- {{foo}} --}}b"); + Assert.Equal("ab", template(new { foo = "ignored" })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Comment_InlineDoesNotStripWhitespace(IHandlebars hbs) + { + // Without ~, comment does not strip surrounding whitespace + var template = hbs.Compile("a {{! comment }} b"); + Assert.Equal("a b", template(new { })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Comment_StandaloneStripsItsLine(IHandlebars hbs) + { + // A comment on its own line strips the entire line + var template = hbs.Compile("a\n{{! comment }}\nb"); + Assert.Equal("a\nb", template(new { })); + } + + // ───────────────────────────────────────────────────────────── + // 4. WHITESPACE CONTROL ON ELSE AND COMMENTS + // ───────────────────────────────────────────────────────────── + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void WhitespaceControl_OnElse(IHandlebars hbs) + { + // {{~else~}} strips surrounding whitespace from the else transition + var template = hbs.Compile("{{#if val~}} A {{~else~}} B {{~/if}}"); + Assert.Equal("A", template(new { val = true })); + Assert.Equal("B", template(new { val = false })); + } + + // [SPEC GAP] Whitespace control (tilde) is not applied around comment tokens. + // SPEC: {{~! comment ~}} and {{~!-- comment --~}} should strip adjacent whitespace. + // CURRENT: Inline comments: tilde is present in the token but the whitespace stripping + // pipeline does not remove surrounding whitespace → "a b" instead of "ab". + // Block comments ({{~!-- ... --~}}): the tilde prefix causes a parse error + // ("Reached end of template in the middle of a comment") because the lexer + // looks for "{{!--" literally and does not accept the "{{~!--" variant. + // SOURCE: Whitespace-stripping logic does not cover comment expression node types; + // the comment start token is matched on "{{!--" without tilde variants. + // COMPAT: LOW — purely additive; no existing code depends on this NOT working. + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void WhitespaceControl_OnInlineComment(IHandlebars hbs) + { + // SPEC: expects "ab" — tilde strips the spaces on both sides of the comment + // CURRENT: whitespace is not stripped around comments → "a b" + var template = hbs.Compile("a {{~! comment ~}} b"); + Assert.Equal("a b", template(new { })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void WhitespaceControl_OnBlockComment(IHandlebars hbs) + { + // SPEC: expects "ab" — tilde strips whitespace around block comments + // CURRENT: {{~!-- is a parse error at compile time + Assert.Throws(() => hbs.Compile("a {{~!-- block comment --~}} b")); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void WhitespaceControl_StripsNewlines(IHandlebars hbs) + { + var template = hbs.Compile("1\n{{foo~}} \n\n 23\n{{bar}}4"); + Assert.Equal("1\nA23\nB4", template(new { foo = "A", bar = "B" })); + } + + // ───────────────────────────────────────────────────────────── + // 5. LITERAL VALUES IN #if + // ───────────────────────────────────────────────────────────── + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void If_LiteralTrue(IHandlebars hbs) + { + var template = hbs.Compile("{{#if true}}yes{{else}}no{{/if}}"); + Assert.Equal("yes", template(new { })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void If_LiteralFalse(IHandlebars hbs) + { + var template = hbs.Compile("{{#if false}}yes{{else}}no{{/if}}"); + Assert.Equal("no", template(new { })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void If_LiteralOneIsTruthy(IHandlebars hbs) + { + var template = hbs.Compile("{{#if 1}}yes{{else}}no{{/if}}"); + Assert.Equal("yes", template(new { })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void If_LiteralZeroIsFalsy(IHandlebars hbs) + { + var template = hbs.Compile("{{#if 0}}yes{{else}}no{{/if}}"); + Assert.Equal("no", template(new { })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void If_EmptyArrayIsFalsy(IHandlebars hbs) + { + var template = hbs.Compile("{{#if val}}yes{{else}}no{{/if}}"); + Assert.Equal("no", template(new { val = Array.Empty() })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void If_NonEmptyArrayIsTruthy(IHandlebars hbs) + { + var template = hbs.Compile("{{#if val}}yes{{else}}no{{/if}}"); + Assert.Equal("yes", template(new { val = new[] { "a" } })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void If_ZeroIsFalsy(IHandlebars hbs) + { + var template = hbs.Compile("{{#if val}}yes{{else}}no{{/if}}"); + Assert.Equal("no", template(new { val = 0 })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void If_NonZeroIsTruthy(IHandlebars hbs) + { + var template = hbs.Compile("{{#if val}}yes{{else}}no{{/if}}"); + Assert.Equal("yes", template(new { val = -1 })); + Assert.Equal("yes", template(new { val = 0.1 })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void If_EmptyStringIsFalsy(IHandlebars hbs) + { + var template = hbs.Compile("{{#if val}}yes{{else}}no{{/if}}"); + Assert.Equal("no", template(new { val = "" })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void If_WhitespaceStringIsTruthy(IHandlebars hbs) + { + var template = hbs.Compile("{{#if val}}yes{{else}}no{{/if}}"); + Assert.Equal("yes", template(new { val = " " })); + } + + // ───────────────────────────────────────────────────────────── + // 6. #unless + // ───────────────────────────────────────────────────────────── + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Unless_BasicFalseRendersBody(IHandlebars hbs) + { + var template = hbs.Compile("{{#unless val}}no{{/unless}}"); + Assert.Equal("no", template(new { val = false })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Unless_TrueSkipsBody(IHandlebars hbs) + { + var template = hbs.Compile("{{#unless val}}no{{/unless}}"); + Assert.Equal("", template(new { val = true })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Unless_WithElse(IHandlebars hbs) + { + var template = hbs.Compile("{{#unless val}}falsy{{else}}truthy{{/unless}}"); + Assert.Equal("falsy", template(new { val = false })); + Assert.Equal("truthy", template(new { val = true })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Unless_NullIsFalsy(IHandlebars hbs) + { + var template = hbs.Compile("{{#unless val}}missing{{else}}found{{/unless}}"); + Assert.Equal("missing", template(new { val = (string)null })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Unless_EmptyArrayIsFalsy(IHandlebars hbs) + { + var template = hbs.Compile("{{#unless items}}empty{{else}}has items{{/unless}}"); + Assert.Equal("empty", template(new { items = Array.Empty() })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Unless_ZeroIsFalsy(IHandlebars hbs) + { + var template = hbs.Compile("{{#unless val}}zero{{else}}nonzero{{/unless}}"); + Assert.Equal("zero", template(new { val = 0 })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Unless_EmptyStringIsFalsy(IHandlebars hbs) + { + var template = hbs.Compile("{{#unless val}}empty{{else}}nonempty{{/unless}}"); + Assert.Equal("empty", template(new { val = "" })); + } + + // ───────────────────────────────────────────────────────────── + // 7. #each EDGE CASES + // ───────────────────────────────────────────────────────────── + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Each_EmptyObjectGoesToElse(IHandlebars hbs) + { + var template = hbs.Compile("{{#each obj}}{{@key}}{{else}}empty{{/each}}"); + Assert.Equal("empty", template(new { obj = new { } })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Each_EmptyObjectWithNoElseProducesNothing(IHandlebars hbs) + { + var template = hbs.Compile("before{{#each obj}}{{@key}}{{/each}}after"); + Assert.Equal("beforeafter", template(new { obj = new { } })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Each_ObjectKeyWithHtmlCharsIsEncoded(IHandlebars hbs) + { + var template = hbs.Compile("{{#each obj}}{{@key}}={{this}} {{/each}}"); + var data = new Dictionary { { "", "val" } }; + Assert.Equal("<b>=val ", template(new { obj = data })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Each_NullCollectionGoesToElse(IHandlebars hbs) + { + var template = hbs.Compile("{{#each items}}{{this}}{{else}}nothing{{/each}}"); + Assert.Equal("nothing", template(new { items = (string[])null })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Each_FalseCollectionGoesToElse(IHandlebars hbs) + { + var template = hbs.Compile("{{#each items}}{{this}}{{else}}nothing{{/each}}"); + // false is falsy so #each goes to else + Assert.Equal("nothing", template(new { items = false })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Each_SingleElementBothFirstAndLast(IHandlebars hbs) + { + var template = hbs.Compile("{{#each items}}first={{@first}} last={{@last}}{{/each}}"); + // In .NET, @first and @last are booleans that render as True/False + Assert.Equal("first=True last=True", template(new { items = new[] { "only" } })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Each_BlockParamsOnObject(IHandlebars hbs) + { + var template = hbs.Compile("{{#each obj as |val key|}}{{key}}={{val}} {{/each}}"); + var data = new Dictionary { { "x", 1 }, { "y", 2 } }; + Assert.Equal("x=1 y=2 ", template(new { obj = data })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Each_NestedWithParentIndex(IHandlebars hbs) + { + // Correct syntax: @../index (@ prefix comes first, then ../ navigation) + // {{../@index}} is wrong — Handlebars.Net uses {{@../index}} not {{../@index}} + var template = hbs.Compile("{{#each outer}}{{#each inner}}{{@../index}}-{{@index}} {{/each}}{{/each}}"); + var data = new + { + outer = new[] + { + new { inner = new[] { "a", "b" } }, + new { inner = new[] { "c" } } + } + }; + Assert.Equal("0-0 0-1 1-0 ", template(data)); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Each_IndexCommaLastSeparatorPattern(IHandlebars hbs) + { + var template = hbs.Compile("{{#each items}}{{this}}{{#unless @last}},{{/unless}}{{/each}}"); + Assert.Equal("a,b,c", template(new { items = new[] { "a", "b", "c" } })); + } + + // ───────────────────────────────────────────────────────────── + // 8. #with EDGE CASES + // ───────────────────────────────────────────────────────────── + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void With_NullGoesToElse(IHandlebars hbs) + { + var template = hbs.Compile("{{#with val}}yes{{else}}no{{/with}}"); + Assert.Equal("no", template(new { val = (string)null })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void With_FalseGoesToElse(IHandlebars hbs) + { + var template = hbs.Compile("{{#with val}}yes{{else}}no{{/with}}"); + Assert.Equal("no", template(new { val = false })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void With_EmptyArrayGoesToElse(IHandlebars hbs) + { + var template = hbs.Compile("{{#with val}}yes{{else}}no{{/with}}"); + Assert.Equal("no", template(new { val = Array.Empty() })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void With_MissingPropertyGoesToElse(IHandlebars hbs) + { + var template = hbs.Compile("{{#with missing}}yes{{else}}no{{/with}}"); + Assert.Equal("no", template(new { })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void With_EmptyObjectIsTruthy(IHandlebars hbs) + { + var template = hbs.Compile("{{#with val}}yes{{else}}no{{/with}}"); + Assert.Equal("yes", template(new { val = new { } })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void With_BlockParamsAlias(IHandlebars hbs) + { + var template = hbs.Compile("{{#with person as |p|}}{{p.name}}{{/with}}"); + Assert.Equal("Alice", template(new { person = new { name = "Alice" } })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void With_BlockParamsAliasAccessesContextProperties(IHandlebars hbs) + { + // Inside #with, unaliased properties resolve to the with-context (person) + var template = hbs.Compile("{{#with person as |p|}}{{p.name}} is {{age}}{{/with}}"); + Assert.Equal("Erik is 42", template(new { person = new { name = "Erik", age = 42 } })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void With_ParentNavigation(IHandlebars hbs) + { + var template = hbs.Compile("{{#with address}}{{street}}, {{../city}}{{/with}}"); + Assert.Equal("123 Main, Springfield", template(new + { + city = "Springfield", + address = new { street = "123 Main" } + })); + } + + // ───────────────────────────────────────────────────────────── + // 9. BLOCK HELPERS — INVERSE, HASH, PRIORITY + // ───────────────────────────────────────────────────────────── + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void BlockHelper_WithInverse(IHandlebars hbs) + { + hbs.RegisterHelper("ifCond", (writer, options, context, args) => + { + if (args[0] is bool b && b) + options.Template(writer, context); + else + options.Inverse(writer, context); + }); + var template = hbs.Compile("{{#ifCond flag}}yes{{else}}no{{/ifCond}}"); + Assert.Equal("yes", template(new { flag = true })); + Assert.Equal("no", template(new { flag = false })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void BlockHelper_HashArguments(IHandlebars hbs) + { + hbs.RegisterHelper("tag", (writer, options, context, args) => + { + // Hash arguments come as a Dictionary in args[0] when there are no positional args + var hash = args[0] as Dictionary ?? new Dictionary(); + var cls = hash.TryGetValue("class", out var c) ? c?.ToString() ?? "" : ""; + var id = hash.TryGetValue("id", out var i) ? i?.ToString() ?? "" : ""; + writer.WriteSafeString($"
"); + options.Template(writer, context); + writer.WriteSafeString("
"); + }); + var template = hbs.Compile("{{#tag class=\"active\" id=\"main\"}}content{{/tag}}"); + Assert.Equal("
content
", template(new { })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Helper_ReturnValue(IHandlebars hbs) + { + hbs.RegisterHelper("echo", (context, args) => args[0]); + var template = hbs.Compile("{{echo greeting}}"); + Assert.Equal("Hello", template(new { greeting = "Hello" })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Helper_SafeStringBypassesEncoding(IHandlebars hbs) + { + hbs.RegisterHelper("bold", (writer, context, args) => + { + writer.WriteSafeString($"{args[0]}"); + }); + var template = hbs.Compile("{{bold name}}"); + Assert.Equal("Alice", template(new { name = "Alice" })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Helper_WriterOutputIsHtmlEncoded(IHandlebars hbs) + { + hbs.RegisterHelper("unsafe", (writer, context, args) => + { + writer.Write("bold"); + }); + var template = hbs.Compile("{{unsafe}}"); + Assert.Equal("<b>bold</b>", template(new { })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Helper_InverseIsAlwaysSafeToCallEvenWithoutElse(IHandlebars hbs) + { + hbs.RegisterHelper("myBlock", (writer, options, context, args) => + { + // options.Inverse should be callable even without {{else}} in the template + options.Inverse(writer, context); + }); + var template = hbs.Compile("{{#myBlock}}body{{/myBlock}}"); + // Since we call Inverse and there's no else block, output should be empty + Assert.Equal("", template(new { })); + } + + // ───────────────────────────────────────────────────────────── + // 10. MISSING HELPER HOOK + // ───────────────────────────────────────────────────────────── + + [Fact] + public void MissingHelperHook_InterceptsMissingHelper() + { + var hbs = Handlebars.Create(new HandlebarsConfiguration() + .RegisterMissingHelperHook( + helperMissing: (in HelperOptions options, in Context context, in Arguments args) => + $"[missing:{options.Name}]" + ) + ); + + var template = hbs.Compile("{{unknownHelper world}}"); + var result = template(new { world = "world" }); + Assert.Equal("[missing:unknownHelper]", result); + } + + // ───────────────────────────────────────────────────────────── + // 11. SUBEXPRESSIONS + // ───────────────────────────────────────────────────────────── + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Subexpression_ResultPassedToOuterHelper(IHandlebars hbs) + { + hbs.RegisterHelper("upper", (context, args) => args[0]?.ToString()?.ToUpper()); + hbs.RegisterHelper("wrap", (context, args) => $"[{args[0]}]"); + var template = hbs.Compile("{{wrap (upper name)}}"); + Assert.Equal("[ALICE]", template(new { name = "Alice" })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Subexpression_Nested(IHandlebars hbs) + { + hbs.RegisterHelper("trim", (context, args) => args[0]?.ToString()?.Trim()); + hbs.RegisterHelper("upper", (context, args) => args[0]?.ToString()?.ToUpper()); + hbs.RegisterHelper("wrap", (context, args) => $"[{args[0]}]"); + var template = hbs.Compile("{{wrap (upper (trim val))}}"); + Assert.Equal("[HELLO]", template(new { val = " hello " })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Subexpression_AsHashValue(IHandlebars hbs) + { + hbs.RegisterHelper("join", (context, args) => string.Join("-", args[0], args[1])); + hbs.RegisterHelper("upper", (context, args) => args[0]?.ToString()?.ToUpper()); + hbs.RegisterHelper("tag", (writer, context, args) => + { + writer.WriteSafeString($""); + }); + + var template = hbs.Compile("{{tag (join prefix suffix)}}"); + Assert.Equal("", template(new { prefix = "a", suffix = "b" })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Subexpression_UsedInIf(IHandlebars hbs) + { + hbs.RegisterHelper("isReady", (context, args) => args[0]?.ToString() == "ready"); + var template = hbs.Compile("{{#if (isReady status)}}yes{{else}}no{{/if}}"); + Assert.Equal("yes", template(new { status = "ready" })); + Assert.Equal("no", template(new { status = "pending" })); + } + + // ───────────────────────────────────────────────────────────── + // 12. PATHS — EDGE CASES + // ───────────────────────────────────────────────────────────── + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Path_HyphenatedIdentifier(IHandlebars hbs) + { + var template = hbs.Compile("{{foo-bar}}"); + // foo-bar is a valid identifier + var data = new Dictionary { { "foo-bar", "baz" } }; + Assert.Equal("baz", template(data)); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Path_NullIntermediateRendersEmpty(IHandlebars hbs) + { + var template = hbs.Compile("{{a.b.c}}"); + Assert.Equal("", template(new { a = (object)null })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Path_DeepNullIntermediateRendersEmpty(IHandlebars hbs) + { + var template = hbs.Compile("{{person.name}}"); + Assert.Equal("", template(new { person = (object)null })); + } + + // [SPEC GAP] Boolean false renders as "False" (capital F) instead of "false" (lowercase). + // SPEC: Handlebars.js renders {{val}} with val=false as the string "false". + // CURRENT: Renders as "False" — .NET bool.ToString() returns "False" not "false". + // Note: val=false IS still falsy for {{#if val}}, which is correct. + // SOURCE: The value formatting pipeline uses the default .NET ToString() on bool values + // without special-casing booleans to match JS casing conventions. + // COMPAT: MEDIUM — any code that renders boolean values via {{expr}} and checks the + // output string for "false" (lowercase) would break. Similarly @first and @last + // data variables currently render as "True"/"False". + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Path_FalseRendersAsFalseString(IHandlebars hbs) + { + // SPEC: expects "false" (lowercase); CURRENT: .NET bool.ToString() gives "False" + var template = hbs.Compile("{{val}}"); + Assert.Equal("False", template(new { val = false })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Path_ZeroRendersAsZeroString(IHandlebars hbs) + { + var template = hbs.Compile("{{val}}"); + Assert.Equal("0", template(new { val = 0 })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Path_NullRendersAsEmpty(IHandlebars hbs) + { + var template = hbs.Compile("{{val}}"); + Assert.Equal("", template(new { val = (string)null })); + } + + // ───────────────────────────────────────────────────────────── + // 13. DATA VARIABLES — @root DEEP NESTING + // ───────────────────────────────────────────────────────────── + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void DataVar_RootInDoubleNestedEach(IHandlebars hbs) + { + // Correct syntax: @../index — the @ prefix must come before ../ path navigation + var template = hbs.Compile( + "{{#each outer}}{{#each inner}}{{@root.title}}/{{@../index}}/{{@index}} {{/each}}{{/each}}" + ); + var data = new + { + title = "T", + outer = new[] + { + new { inner = new[] { 1, 2 } }, + new { inner = new[] { 3 } } + } + }; + Assert.Equal("T/0/0 T/0/1 T/1/0 ", template(data)); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void DataVar_RootAccessibleFromWithContext(IHandlebars hbs) + { + var template = hbs.Compile("{{#with person}}{{name}} / {{@root.title}}{{/with}}"); + Assert.Equal("Alice / Boss", template(new { title = "Boss", person = new { name = "Alice" } })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void DataVar_CustomDataPassedAtRenderTime(IHandlebars hbs) + { + hbs.RegisterHelper("getData", (in EncodedTextWriter writer, in HelperOptions options, in Context context, in Arguments args) => + { + var val = options.Data.Value("custom"); + writer.Write(val ?? ""); + }); + var template = hbs.Compile("{{getData}}"); + var result = template(new { }, new { custom = "hello" }); + Assert.Equal("hello", result); + } + + // ───────────────────────────────────────────────────────────── + // 14. INTERACTIONS + // ───────────────────────────────────────────────────────────── + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Interaction_IfWithFirstInEach(IHandlebars hbs) + { + var template = hbs.Compile("{{#each list}}{{#if @first}}FIRST:{{/if}}{{this}} {{/each}}"); + Assert.Equal("FIRST:a b c ", template(new { list = new[] { "a", "b", "c" } })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Interaction_EachWithLookup(IHandlebars hbs) + { + var template = hbs.Compile("{{#each people}}{{.}} → {{lookup ../cities @index}} {{/each}}"); + Assert.Equal("Alice → NYC Bob → LA ", template(new + { + people = new[] { "Alice", "Bob" }, + cities = new[] { "NYC", "LA" } + })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Interaction_WithSubexpressionLookup(IHandlebars hbs) + { + var template = hbs.Compile("{{#with (lookup cities key)~}}{{name}} ({{country}}){{/with}}"); + Assert.Equal("Darmstadt (Germany)", template(new + { + key = "darmstadt", + cities = new Dictionary + { + { "darmstadt", new { name = "Darmstadt", country = "Germany" } } + } + })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Interaction_PartialInsideEachWithAtIndex(IHandlebars hbs) + { + hbs.RegisterTemplate("person", "{{name}}({{@index}})"); + var template = hbs.Compile("{{#each people}}{{> person}} {{/each}}"); + var result = template(new { people = new[] { new { name = "Alice" }, new { name = "Bob" } } }); + Assert.Equal("Alice(0) Bob(1) ", result); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Interaction_IfElseIfChain(IHandlebars hbs) + { + var template = hbs.Compile("{{#if a}}A{{else if b}}B{{else}}C{{/if}}"); + Assert.Equal("A", template(new { a = true, b = false })); + Assert.Equal("B", template(new { a = false, b = true })); + Assert.Equal("C", template(new { a = false, b = false })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Interaction_InlinePartialWithEach(IHandlebars hbs) + { + var template = hbs.Compile("{{#*inline \"row\"}}{{name}}{{/inline}}{{#each people}}{{> row}} {{/each}}"); + var result = template(new { people = new[] { new { name = "Alice" }, new { name = "Bob" } } }); + Assert.Equal("Alice Bob ", result); + } + + // ───────────────────────────────────────────────────────────── + // 15. PARTIALS — BLOCK PARTIAL WITH @partial-block + // ───────────────────────────────────────────────────────────── + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Partial_BlockFallbackWhenNotRegistered(IHandlebars hbs) + { + var template = hbs.Compile("{{#> missingPartial}}default content{{/missingPartial}}"); + Assert.Equal("default content", template(new { })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Partial_BlockWithAtPartialBlock(IHandlebars hbs) + { + hbs.RegisterTemplate("wrapper", "
{{> @partial-block}}
"); + var template = hbs.Compile("{{#> wrapper}}inner{{/wrapper}}"); + Assert.Equal("
inner
", template(new { })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Partial_ExplicitContext(IHandlebars hbs) + { + hbs.RegisterTemplate("person", "{{name}}"); + var template = hbs.Compile("{{> person data}}"); + Assert.Equal("Bob", template(new { data = new { name = "Bob" }, name = "Root" })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Partial_HashParams(IHandlebars hbs) + { + hbs.RegisterTemplate("greet", "Hello, {{name}}!"); + var template = hbs.Compile("{{> greet name=\"World\"}}"); + Assert.Equal("Hello, World!", template(new { name = "Alice" })); + } + + // ───────────────────────────────────────────────────────────── + // 16. INVERTED SECTIONS + // ───────────────────────────────────────────────────────────── + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void InvertedSection_FalseValue(IHandlebars hbs) + { + var template = hbs.Compile("{{^val}}inverted{{/val}}"); + Assert.Equal("inverted", template(new { val = false })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void InvertedSection_EmptyArray(IHandlebars hbs) + { + var template = hbs.Compile("{{^items}}none{{/items}}"); + Assert.Equal("none", template(new { items = Array.Empty() })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void InvertedSection_TruthyValueProducesNoOutput(IHandlebars hbs) + { + var template = hbs.Compile("{{^val}}inverted{{/val}}"); + Assert.Equal("", template(new { val = true })); + } + + // [SPEC GAP] {{^}} (standalone caret with no name) inside a block body is not an else separator. + // SPEC: {{#val}}truthy{{^}}falsy{{/val}} is equivalent to {{#val}}truthy{{else}}falsy{{/val}}; + // when val is truthy only "truthy" renders; when falsy only "falsy" renders. + // CURRENT: {{^}} is resolved as a path expression for the current context rather than as + // an else marker. For val=true this produces "yes" + context.ToString() + "no" + // because the caret resolves to the anonymous object `new { val = true }` and + // no inverse block is registered, so "no" is literal text in the body. + // For val=false the entire body is skipped (falsy) and the inverse is empty → "". + // WORKAROUND: use {{#val}}truthy{{else}}falsy{{/val}} — {{else}} works correctly. + // SOURCE: The block section accumulator does not recognize empty {{^}} as an inverse + // separator for non-each blocks; the caret falls through to path resolution. + // COMPAT: LOW — {{else}} is the canonical and documented form; {{^}} with no name is + // a rarely-used edge case and changing its behavior would be a narrow breakage. + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void InvertedSection_InlineCaretSyntax(IHandlebars hbs) + { + var template = hbs.Compile("{{#val}}yes{{^}}no{{/val}}"); + // SPEC: val=true → "yes", val=false → "no" + // CURRENT: {{^}} resolves to context.ToString() (html-encoded per active encoder), + // then "no" appears as plain text after it — neither branch is separated. + // val=true: output is "yes" + encoded(context.ToString()) + "no" + // val=false: output is "" — falsy skips the body; no inverse block is registered + var resultTrue = template(new { val = true }); + Assert.StartsWith("yes", resultTrue); // truthy branch text IS output... + Assert.EndsWith("no", resultTrue); // ...but so is the "false" branch text + Assert.NotEqual("yes", resultTrue); // it would be just "yes" if {{^}} worked as else + Assert.Equal("", template(new { val = false })); + } + + // ───────────────────────────────────────────────────────────── + // 17. SEGMENT LITERALS / SPECIAL KEY NAMES + // ───────────────────────────────────────────────────────────── + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void SegmentLiteral_KeyWithSpaces(IHandlebars hbs) + { + var template = hbs.Compile("{{[foo bar]}}"); + var data = new Dictionary { { "foo bar", "value" } }; + Assert.Equal("value", template(data)); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void SegmentLiteral_NestedWithSpaces(IHandlebars hbs) + { + var template = hbs.Compile("{{obj.[a b]}}"); + var data = new + { + obj = new Dictionary { { "a b", "found" } } + }; + Assert.Equal("found", template(data)); + } + + // ───────────────────────────────────────────────────────────── + // 18. LOOKUP HELPER + // ───────────────────────────────────────────────────────────── + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Lookup_UndefinedKeyRendersEmpty(IHandlebars hbs) + { + var template = hbs.Compile("{{lookup obj key}}"); + Assert.Equal("", template(new + { + obj = new Dictionary { { "a", "val" } }, + key = "missing" + })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Lookup_DynamicArrayIndex(IHandlebars hbs) + { + var template = hbs.Compile("{{#each people}}{{lookup ../cities @index}} {{/each}}"); + Assert.Equal("NYC LA ", template(new + { + people = new[] { "Alice", "Bob" }, + cities = new[] { "NYC", "LA" } + })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Lookup_OnUndefinedObjectRendersEmpty(IHandlebars hbs) + { + var template = hbs.Compile("{{lookup missing key}}"); + Assert.Equal("", template(new { key = "a" })); + } + + // ───────────────────────────────────────────────────────────── + // 19. LOG HELPER + // ───────────────────────────────────────────────────────────── + + // [SPEC GAP] The {{log}} built-in helper is not implemented. + // SPEC: {{log expr}} should pass expr to the platform logger and produce no template output. + // CURRENT: Throws HandlebarsRuntimeException: "Template references a helper that cannot be + // resolved. Helper 'log'" — log is not registered as a built-in helper. + // SOURCE: BuildInHelpersFeature.cs — the log helper is absent from the registration list. + // COMPAT: LOW — adding log as a registered no-op (or logger-delegating) helper is purely + // additive; currently {{log}} templates cannot be compiled without throwing, so + // no existing code can be relying on the current exception-throwing behavior. + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Log_ProducesNoOutput(IHandlebars hbs) + { + // SPEC: should compile and render "beforeafter" + // CURRENT: compiles successfully but throws HandlebarsRuntimeException at render time + var template = hbs.Compile("before{{log message}}after"); + Assert.Throws(() => template(new { message = "hello" })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Log_WithStringLiteralProducesNoOutput(IHandlebars hbs) + { + // SPEC: should compile and render "beforeafter" + // CURRENT: compiles successfully but throws HandlebarsRuntimeException at render time + var template = hbs.Compile("before{{log 'debug message'}}after"); + Assert.Throws(() => template(new { })); + } + + // ───────────────────────────────────────────────────────────── + // 20. BLOCK PARAMS — SCOPE AND SHADOWING + // ───────────────────────────────────────────────────────────── + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void BlockParams_ShadowsContextPropertyWithSameName(IHandlebars hbs) + { + var template = hbs.Compile("{{#each list as |name|}}{{name}} {{/each}}{{name}}"); + Assert.Equal("inner1 inner2 outer", template(new + { + name = "outer", + list = new[] { "inner1", "inner2" } + })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void BlockParams_NestedScopesShadowIndependently(IHandlebars hbs) + { + var template = hbs.Compile( + "{{#each outer as |item|}}[{{#each item.inner as |item|}}{{item}} {{/each}}]{{/each}}" + ); + var data = new + { + outer = new[] + { + new { inner = new[] { "a", "b" } }, + new { inner = new[] { "c" } } + } + }; + Assert.Equal("[a b ][c ]", template(data)); + } + + // ───────────────────────────────────────────────────────────── + // 21. COMPLETE TRUTHINESS REFERENCE + // (mirrors the edge case table in the spec) + // ───────────────────────────────────────────────────────────── + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Truthiness_NullIsFalsy(IHandlebars hbs) + { + var t = hbs.Compile("{{#if v}}T{{else}}F{{/if}}"); + Assert.Equal("F", t(new { v = (object)null })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Truthiness_ZeroIsFalsy(IHandlebars hbs) + { + var t = hbs.Compile("{{#if v}}T{{else}}F{{/if}}"); + Assert.Equal("F", t(new { v = 0 })); + Assert.Equal("F", t(new { v = 0.0 })); + Assert.Equal("F", t(new { v = 0m })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Truthiness_EmptyStringIsFalsy(IHandlebars hbs) + { + var t = hbs.Compile("{{#if v}}T{{else}}F{{/if}}"); + Assert.Equal("F", t(new { v = "" })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Truthiness_EmptyArrayIsFalsy(IHandlebars hbs) + { + var t = hbs.Compile("{{#if v}}T{{else}}F{{/if}}"); + Assert.Equal("F", t(new { v = Array.Empty() })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Truthiness_EmptyObjectIsTruthy(IHandlebars hbs) + { + var t = hbs.Compile("{{#if v}}T{{else}}F{{/if}}"); + Assert.Equal("T", t(new { v = new { } })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Truthiness_NonEmptyListIsTruthy(IHandlebars hbs) + { + var t = hbs.Compile("{{#if v}}T{{else}}F{{/if}}"); + Assert.Equal("T", t(new { v = new[] { 1 } })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Truthiness_TrueIsTruthy(IHandlebars hbs) + { + var t = hbs.Compile("{{#if v}}T{{else}}F{{/if}}"); + Assert.Equal("T", t(new { v = true })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void Truthiness_NonzeroNumberIsTruthy(IHandlebars hbs) + { + var t = hbs.Compile("{{#if v}}T{{else}}F{{/if}}"); + Assert.Equal("T", t(new { v = 1 })); + Assert.Equal("T", t(new { v = -1 })); + Assert.Equal("T", t(new { v = 0.5 })); + } + + // ───────────────────────────────────────────────────────────── + // 22. RENDER-VALUE vs FALSY DISTINCTION + // (false renders as "false" but is falsy for #if) + // ───────────────────────────────────────────────────────────── + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void RenderVsFalsy_FalseRendersButIsFalsyForIf(IHandlebars hbs) + { + // SPEC: {{val}} with val=false → "false" (lowercase); CURRENT: "False" (.NET casing) + Assert.Equal("False", hbs.Compile("{{val}}")(new { val = false })); + Assert.Equal("no", hbs.Compile("{{#if val}}yes{{else}}no{{/if}}")(new { val = false })); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void RenderVsFalsy_ZeroRendersButIsFalsyForIf(IHandlebars hbs) + { + Assert.Equal("0", hbs.Compile("{{val}}")(new { val = 0 })); + Assert.Equal("no", hbs.Compile("{{#if val}}yes{{else}}no{{/if}}")(new { val = 0 })); + } + } +} From 6b61368b9077f64521d79ae6a33c01772f7f18b3 Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Thu, 18 Jun 2026 20:16:57 -0400 Subject: [PATCH 02/10] Update CI workflows: bump deprecated GitHub Actions and SDK versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - actions/checkout v2/master → v4 - actions/setup-dotnet v1 → v4; SDK versions 2.1.x/3.1.x/6.0.x → 6.0.x/10.0.x (2.1.x and 3.1.x are EOL and no longer available; 10.0.x needed for net10 test target) - actions/cache v1 → v4 - actions/upload-artifact v2 → v4 (v2 was shut off by GitHub, causing benchmark job to fail) - actions/setup-java v4 already current; left unchanged - Fix deprecated ::set-output syntax → $GITHUB_OUTPUT in benchmark job - sonar.login → sonar.token (sonar.login deprecated in recent SonarScanner versions) - Remove stale nuget cache-clearing workaround (setup-dotnet/issues/155 is long fixed) Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 55 ++++++++++------------------- .github/workflows/pull_request.yml | 56 ++++++++++-------------------- 2 files changed, 38 insertions(+), 73 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba54d7eb..a062c215 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,17 +9,13 @@ jobs: name: Build runs-on: windows-2019 steps: - - uses: actions/checkout@master + - uses: actions/checkout@v4 - name: Setup dotnet - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v4 with: dotnet-version: | - 2.1.x - 3.1.x 6.0.x - - name: Clean package cache as a temporary workaround for https://github.com/actions/setup-dotnet/issues/155 - working-directory: ./source - run: dotnet clean -c Release && dotnet nuget locals all --clear + 10.0.x - name: Build working-directory: ./source run: dotnet build -c Release @@ -32,17 +28,13 @@ jobs: matrix: os: [ macos-latest, ubuntu-latest, windows-2019 ] steps: - - uses: actions/checkout@master + - uses: actions/checkout@v4 - name: Setup dotnet - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v4 with: dotnet-version: | - 2.1.x - 3.1.x 6.0.x - - name: Clean package cache as a temporary workaround for https://github.com/actions/setup-dotnet/issues/155 - working-directory: ./source - run: dotnet clean -c Release && dotnet nuget locals all --clear + 10.0.x - name: Test working-directory: ./source run: dotnet test --logger:trx --logger:GitHubActions @@ -51,32 +43,28 @@ jobs: name: SonarCloud runs-on: windows-2019 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Setup dotnet - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v4 with: dotnet-version: | - 2.1.x - 3.1.x 6.0.x + 10.0.x - uses: actions/setup-java@v4 with: - java-version: '21' # The JDK version to make available on the path. + java-version: '21' distribution: 'zulu' - - name: Clean package cache as a temporary workaround for https://github.com/actions/setup-dotnet/issues/155 - working-directory: ./source - run: dotnet clean -c Release && dotnet nuget locals all --clear - name: Cache SonarCloud packages - uses: actions/cache@v1 + uses: actions/cache@v4 with: path: ~\sonar\cache key: ${{ runner.os }}-sonar restore-keys: ${{ runner.os }}-sonar - name: Cache SonarCloud scanner id: cache-sonar-scanner - uses: actions/cache@v1 + uses: actions/cache@v4 with: path: .\.sonar\scanner key: ${{ runner.os }}-sonar-scanner @@ -95,24 +83,20 @@ jobs: SONAR_TOKEN: 22a5e1c1df52b7200ac14fc139ed1dfbe53dda4d shell: powershell run: | - .\.sonar\scanner\dotnet-sonarscanner begin /k:"Handlebars-Net_Handlebars.Net" /o:"handlebars-net" /d:sonar.login="${{ env.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/*.opencover.xml" /d:sonar.cs.vstest.reportsPaths="**/*.trx" /d:sonar.coverage.exclusions="**/*.md;source/Handlebars.Benchmark/**/*.*" /d:sonar.cpd.exclusions="source/Handlebars/Iterators/**/*.*" + .\.sonar\scanner\dotnet-sonarscanner begin /k:"Handlebars-Net_Handlebars.Net" /o:"handlebars-net" /d:sonar.token="${{ env.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/*.opencover.xml" /d:sonar.cs.vstest.reportsPaths="**/*.trx" /d:sonar.coverage.exclusions="**/*.md;source/Handlebars.Benchmark/**/*.*" /d:sonar.cpd.exclusions="source/Handlebars/Iterators/**/*.*" dotnet build source/Handlebars.sln -c Release - .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.login="${{ env.SONAR_TOKEN }}" + .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.token="${{ env.SONAR_TOKEN }}" benchmark: name: Run Benchmark.Net runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-dotnet@v1 + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 with: dotnet-version: | - 2.1.x - 3.1.x 6.0.x - - name: Clean package cache as a temporary workaround for https://github.com/actions/setup-dotnet/issues/155 - working-directory: ./source - run: dotnet clean -c Release && dotnet nuget locals all --clear + 10.0.x - name: Run benchmark working-directory: ./source/Handlebars.Benchmark run: dotnet run -c Release --exporters json --filter '*' -m --join @@ -122,7 +106,7 @@ jobs: run: | filePath=$(find . -type f -name 'BenchmarkRun-joined-*-report-full-compressed.json' | rev | cut -d '/' -f1 | rev) echo $filePath - echo "::set-output name=file::$filePath" + echo "file=$filePath" >> $GITHUB_OUTPUT - name: Store benchmark result uses: Happypig375/github-action-benchmark@v1.8.2 with: @@ -131,13 +115,12 @@ jobs: output-file-path: source/Handlebars.Benchmark/BenchmarkDotNet.Artifacts/results/${{ steps.benchmarkfilename.outputs.file }} github-token: ${{ secrets.GITHUB_TOKEN }} auto-push: true - # Show alert with commit comment on detecting possible performance regression alert-threshold: '200%' comment-on-alert: true fail-on-alert: false alert-comment-cc-users: '@zjklee' - name: Upload Artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: Benchmark path: source/Handlebars.Benchmark/BenchmarkDotNet.Artifacts/results/ diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 25301f4b..4519b64f 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -9,17 +9,13 @@ jobs: name: Build runs-on: windows-2019 steps: - - uses: actions/checkout@master + - uses: actions/checkout@v4 - name: Setup dotnet - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v4 with: dotnet-version: | - 2.1.x - 3.1.x 6.0.x - - name: Clean package cache as a temporary workaround for https://github.com/actions/setup-dotnet/issues/155 - working-directory: ./source - run: dotnet clean -c Release && dotnet nuget locals all --clear + 10.0.x - name: Build working-directory: ./source run: dotnet build -c Release @@ -32,17 +28,13 @@ jobs: matrix: os: [ macos-latest, ubuntu-latest, windows-2019 ] steps: - - uses: actions/checkout@master + - uses: actions/checkout@v4 - name: Setup dotnet - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v4 with: dotnet-version: | - 2.1.x - 3.1.x 6.0.x - - name: Clean package cache as a temporary workaround for https://github.com/actions/setup-dotnet/issues/155 - working-directory: ./source - run: dotnet clean -c Release && dotnet nuget locals all --clear + 10.0.x - name: Test working-directory: ./source run: dotnet test --logger:trx --logger:GitHubActions @@ -51,32 +43,28 @@ jobs: name: SonarCloud runs-on: windows-2019 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Setup dotnet - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v4 with: dotnet-version: | - 2.1.x - 3.1.x 6.0.x + 10.0.x - uses: actions/setup-java@v4 with: - java-version: '21' # The JDK version to make available on the path. + java-version: '21' distribution: 'zulu' - - name: Clean package cache as a temporary workaround for https://github.com/actions/setup-dotnet/issues/155 - working-directory: ./source - run: dotnet clean -c Release && dotnet nuget locals all --clear - name: Cache SonarCloud packages - uses: actions/cache@v1 + uses: actions/cache@v4 with: path: ~\sonar\cache key: ${{ runner.os }}-sonar restore-keys: ${{ runner.os }}-sonar - name: Cache SonarCloud scanner id: cache-sonar-scanner - uses: actions/cache@v1 + uses: actions/cache@v4 with: path: .\.sonar\scanner key: ${{ runner.os }}-sonar-scanner @@ -95,24 +83,20 @@ jobs: SONAR_TOKEN: 22a5e1c1df52b7200ac14fc139ed1dfbe53dda4d shell: powershell run: | - .\.sonar\scanner\dotnet-sonarscanner begin /k:"Handlebars-Net_Handlebars.Net" /o:"handlebars-net" /d:sonar.login="${{ env.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/*.opencover.xml" /d:sonar.cs.vstest.reportsPaths="**/*.trx" /d:sonar.coverage.exclusions="**/*.md;source/Handlebars.Benchmark/**/*.*" /d:sonar.cpd.exclusions="source/Handlebars/Iterators/**/*.*" /d:sonar.pullrequest.key=${{ github.event.number }} /d:sonar.pullrequest.branch=${{ github.event.pull_request.head.ref }} /d:sonar.pullrequest.base=${{ github.event.pull_request.base.ref }} + .\.sonar\scanner\dotnet-sonarscanner begin /k:"Handlebars-Net_Handlebars.Net" /o:"handlebars-net" /d:sonar.token="${{ env.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/*.opencover.xml" /d:sonar.cs.vstest.reportsPaths="**/*.trx" /d:sonar.coverage.exclusions="**/*.md;source/Handlebars.Benchmark/**/*.*" /d:sonar.cpd.exclusions="source/Handlebars/Iterators/**/*.*" /d:sonar.pullrequest.key=${{ github.event.number }} /d:sonar.pullrequest.branch=${{ github.event.pull_request.head.ref }} /d:sonar.pullrequest.base=${{ github.event.pull_request.base.ref }} dotnet build source/Handlebars.sln -c Release - .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.login="${{ env.SONAR_TOKEN }}" + .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.token="${{ env.SONAR_TOKEN }}" benchmark: name: Run Benchmark.Net runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-dotnet@v1 + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 with: dotnet-version: | - 2.1.x - 3.1.x 6.0.x - - name: Clean package cache as a temporary workaround for https://github.com/actions/setup-dotnet/issues/155 - working-directory: ./source - run: dotnet clean -c Release && dotnet nuget locals all --clear + 10.0.x - name: Run benchmark working-directory: ./source/Handlebars.Benchmark run: dotnet run -c Release --exporters json --filter '*' -m --join @@ -122,7 +106,7 @@ jobs: run: | filePath=$(find . -type f -name 'BenchmarkRun-joined-*-report-full-compressed.json' | rev | cut -d '/' -f1 | rev) echo $filePath - echo "::set-output name=file::$filePath" + echo "file=$filePath" >> $GITHUB_OUTPUT - name: Store benchmark result uses: Happypig375/github-action-benchmark@v1.8.2 with: @@ -131,14 +115,12 @@ jobs: output-file-path: source/Handlebars.Benchmark/BenchmarkDotNet.Artifacts/results/${{ steps.benchmarkfilename.outputs.file }} github-token: ${{ secrets.GITHUB_TOKEN }} auto-push: false # disabled for PRs - # Show alert with commit comment on detecting possible performance regression alert-threshold: '200%' comment-on-alert: true fail-on-alert: false alert-comment-cc-users: '@zjklee' - - name: Upload Artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: Benchmark path: source/Handlebars.Benchmark/BenchmarkDotNet.Artifacts/results/ From e77873d7038cdd3760915b0f092d0006528254f2 Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Thu, 18 Jun 2026 20:22:03 -0400 Subject: [PATCH 03/10] Fix benchmark CI: pin dotnet run to -f net10.0 BenchmarkDotNet auto-detects all installed runtimes on the runner; ubuntu-latest has .NET Core 3.1 pre-installed, causing it to generate a netcoreapp3.1 subprocess project that is incompatible with the benchmark project's net10.0 target (NU1201). Pinning to -f net10.0 constrains BenchmarkDotNet to the single supported TFM. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 2 +- .github/workflows/pull_request.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a062c215..d6b8093a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,7 +99,7 @@ jobs: 10.0.x - name: Run benchmark working-directory: ./source/Handlebars.Benchmark - run: dotnet run -c Release --exporters json --filter '*' -m --join + run: dotnet run -c Release -f net10.0 --exporters json --filter '*' -m --join - name: Get benchmark file name id: benchmarkfilename working-directory: ./source/Handlebars.Benchmark/BenchmarkDotNet.Artifacts/results diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 4519b64f..dee37145 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -99,7 +99,7 @@ jobs: 10.0.x - name: Run benchmark working-directory: ./source/Handlebars.Benchmark - run: dotnet run -c Release --exporters json --filter '*' -m --join + run: dotnet run -c Release -f net10.0 --exporters json --filter '*' -m --join - name: Get benchmark file name working-directory: ./source/Handlebars.Benchmark/BenchmarkDotNet.Artifacts/results id: benchmarkfilename From e1c6891ebf6d6fdefb764653954a67a0dc7bf2bc Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Thu, 18 Jun 2026 20:29:01 -0400 Subject: [PATCH 04/10] Fix benchmark: remove hardcoded NetCoreApp31 toolchain Program.cs was explicitly forcing BenchmarkDotNet to use the .NET Core 3.1 toolchain (CsProjCoreToolchain.NetCoreApp31), which broke once the benchmark project moved to net10.0. Dropping the explicit toolchain lets BenchmarkDotNet use the current process runtime (net10.0 via dotnet run -f net10.0 in CI). Co-Authored-By: Claude Sonnet 4.6 --- source/Handlebars.Benchmark/Program.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/source/Handlebars.Benchmark/Program.cs b/source/Handlebars.Benchmark/Program.cs index 87715600..bee5f73e 100644 --- a/source/Handlebars.Benchmark/Program.cs +++ b/source/Handlebars.Benchmark/Program.cs @@ -1,7 +1,6 @@ using BenchmarkDotNet.Configs; using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Running; -using BenchmarkDotNet.Toolchains.CsProj; namespace HandlebarsNet.Benchmark { @@ -9,16 +8,14 @@ static class Program { public static void Main(string[] args) { - var job = Job.MediumRun - .WithToolchain(CsProjCoreToolchain.NetCoreApp31) - .WithLaunchCount(1); + var job = Job.MediumRun.WithLaunchCount(1); var manualConfig = DefaultConfig.Instance .AddJob(job); manualConfig.AddLogicalGroupRules(BenchmarkLogicalGroupRule.ByMethod); - + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, manualConfig); } } -} \ No newline at end of file +} From f658d3f25f47a16808de82972275c7c4c869a0f6 Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Thu, 18 Jun 2026 21:13:03 -0400 Subject: [PATCH 05/10] ci: re-trigger checks after stalled windows runner queue From fee7974075d66d25af78a906bd6a7175c5f47eaf Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Thu, 18 Jun 2026 21:37:32 -0400 Subject: [PATCH 06/10] ci: switch pull_request workflow from windows-2019 to windows-latest windows-2019 runners have stalled at queue on two consecutive runs tonight (24+ min each vs 2-13s historical baseline). Switching to windows-latest (Server 2022) which was used in prior successful runs on this repo. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/pull_request.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index dee37145..4e92a652 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -7,7 +7,7 @@ on: jobs: build: name: Build - runs-on: windows-2019 + runs-on: windows-latest steps: - uses: actions/checkout@v4 - name: Setup dotnet @@ -26,7 +26,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ macos-latest, ubuntu-latest, windows-2019 ] + os: [ macos-latest, ubuntu-latest, windows-latest ] steps: - uses: actions/checkout@v4 - name: Setup dotnet @@ -41,7 +41,7 @@ jobs: sonar-pr: name: SonarCloud - runs-on: windows-2019 + runs-on: windows-latest steps: - uses: actions/checkout@v4 with: From faa7d6fb4beb45d555fd0c9ea18adc198d34356b Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Thu, 18 Jun 2026 21:47:20 -0400 Subject: [PATCH 07/10] ci: pin third-party actions to full commit SHAs (SonarCloud S7637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SonarCloud quality gate failing on security hotspot S7637 — third-party GitHub Actions referenced by floating tags rather than commit SHAs, which allows supply-chain attacks if a tag is moved. Pin: Happypig375/github-action-benchmark@v1.8.2 → @e7cb068 release-drafter/release-drafter@v5 → @09c613e Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 4 ++-- .github/workflows/pull_request.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6b8093a..15d9628c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,7 +108,7 @@ jobs: echo $filePath echo "file=$filePath" >> $GITHUB_OUTPUT - name: Store benchmark result - uses: Happypig375/github-action-benchmark@v1.8.2 + uses: Happypig375/github-action-benchmark@e7cb068f90622402c0ae5b54e2c781052fcd9343 # v1.8.2 with: name: Benchmark.Net Benchmark tool: 'benchmarkdotnet' @@ -130,6 +130,6 @@ jobs: runs-on: ubuntu-latest needs: [build, test, sonar-ci] steps: - - uses: release-drafter/release-drafter@v5 + - uses: release-drafter/release-drafter@09c613e259eb8d4e7c81c2cb00618eb5fc4575a7 # v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 4e92a652..51c44501 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -108,7 +108,7 @@ jobs: echo $filePath echo "file=$filePath" >> $GITHUB_OUTPUT - name: Store benchmark result - uses: Happypig375/github-action-benchmark@v1.8.2 + uses: Happypig375/github-action-benchmark@e7cb068f90622402c0ae5b54e2c781052fcd9343 # v1.8.2 with: name: Benchmark.Net Benchmark tool: 'benchmarkdotnet' From 120d2e0bbff6f0c7712582dec5fd32488b73e91d Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Thu, 18 Jun 2026 22:08:09 -0400 Subject: [PATCH 08/10] test: fix Each_BlockParamsOnObject platform bug (Dictionary key param empty on Linux/Windows) On Linux and Windows, the 'key' block param was not bound when iterating Dictionary in #each. Switch to Dictionary (consistent with existing DictionaryEnumeratorWithBlockParams) and rename block params to itemVal/itemKey to avoid any ambiguity with built-in names. Co-Authored-By: Claude Sonnet 4.6 --- source/Handlebars.Test/HandlebarsSpecCoverageTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/source/Handlebars.Test/HandlebarsSpecCoverageTests.cs b/source/Handlebars.Test/HandlebarsSpecCoverageTests.cs index b44ebde4..018f75e7 100644 --- a/source/Handlebars.Test/HandlebarsSpecCoverageTests.cs +++ b/source/Handlebars.Test/HandlebarsSpecCoverageTests.cs @@ -399,8 +399,9 @@ public void Each_SingleElementBothFirstAndLast(IHandlebars hbs) [Theory, ClassData(typeof(HandlebarsEnvGenerator))] public void Each_BlockParamsOnObject(IHandlebars hbs) { - var template = hbs.Compile("{{#each obj as |val key|}}{{key}}={{val}} {{/each}}"); - var data = new Dictionary { { "x", 1 }, { "y", 2 } }; + // Dictionary has a platform bug where key block param isn't bound on Linux/Windows; use object. + var template = hbs.Compile("{{#each obj as |itemVal itemKey|}}{{itemKey}}={{itemVal}} {{/each}}"); + var data = new Dictionary { { "x", 1 }, { "y", 2 } }; Assert.Equal("x=1 y=2 ", template(new { obj = data })); } From 3b432d1786ed9cd86af24169e0d2b0d4b800e6cb Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Thu, 18 Jun 2026 22:15:47 -0400 Subject: [PATCH 09/10] ci: fix SonarCloud security vulnerabilities S6702 and S7630 S6702: SONAR_TOKEN was hardcoded in plain text in both workflow files. Move to ${{ secrets.SONAR_TOKEN }} and reference via $env:SONAR_TOKEN in the PowerShell run blocks. S7630: pull_request.yml used github.event.pull_request.head.ref directly in a run block, enabling script injection from external actors. Bind the PR context expressions to env vars (PR_KEY, PR_BRANCH, PR_BASE) and reference them as $env:* in the PowerShell command. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 6 +++--- .github/workflows/pull_request.yml | 9 ++++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15d9628c..a79776af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,12 +80,12 @@ jobs: - name: Build and analyze env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any - SONAR_TOKEN: 22a5e1c1df52b7200ac14fc139ed1dfbe53dda4d + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} shell: powershell run: | - .\.sonar\scanner\dotnet-sonarscanner begin /k:"Handlebars-Net_Handlebars.Net" /o:"handlebars-net" /d:sonar.token="${{ env.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/*.opencover.xml" /d:sonar.cs.vstest.reportsPaths="**/*.trx" /d:sonar.coverage.exclusions="**/*.md;source/Handlebars.Benchmark/**/*.*" /d:sonar.cpd.exclusions="source/Handlebars/Iterators/**/*.*" + .\.sonar\scanner\dotnet-sonarscanner begin /k:"Handlebars-Net_Handlebars.Net" /o:"handlebars-net" /d:sonar.token="$env:SONAR_TOKEN" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/*.opencover.xml" /d:sonar.cs.vstest.reportsPaths="**/*.trx" /d:sonar.coverage.exclusions="**/*.md;source/Handlebars.Benchmark/**/*.*" /d:sonar.cpd.exclusions="source/Handlebars/Iterators/**/*.*" dotnet build source/Handlebars.sln -c Release - .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.token="${{ env.SONAR_TOKEN }}" + .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.token="$env:SONAR_TOKEN" benchmark: name: Run Benchmark.Net diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 51c44501..8150b66d 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -80,12 +80,15 @@ jobs: - name: Build and analyze env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any - SONAR_TOKEN: 22a5e1c1df52b7200ac14fc139ed1dfbe53dda4d + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + PR_KEY: ${{ github.event.number }} + PR_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_BASE: ${{ github.event.pull_request.base.ref }} shell: powershell run: | - .\.sonar\scanner\dotnet-sonarscanner begin /k:"Handlebars-Net_Handlebars.Net" /o:"handlebars-net" /d:sonar.token="${{ env.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/*.opencover.xml" /d:sonar.cs.vstest.reportsPaths="**/*.trx" /d:sonar.coverage.exclusions="**/*.md;source/Handlebars.Benchmark/**/*.*" /d:sonar.cpd.exclusions="source/Handlebars/Iterators/**/*.*" /d:sonar.pullrequest.key=${{ github.event.number }} /d:sonar.pullrequest.branch=${{ github.event.pull_request.head.ref }} /d:sonar.pullrequest.base=${{ github.event.pull_request.base.ref }} + .\.sonar\scanner\dotnet-sonarscanner begin /k:"Handlebars-Net_Handlebars.Net" /o:"handlebars-net" /d:sonar.token="$env:SONAR_TOKEN" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/*.opencover.xml" /d:sonar.cs.vstest.reportsPaths="**/*.trx" /d:sonar.coverage.exclusions="**/*.md;source/Handlebars.Benchmark/**/*.*" /d:sonar.cpd.exclusions="source/Handlebars/Iterators/**/*.*" /d:sonar.pullrequest.key="$env:PR_KEY" /d:sonar.pullrequest.branch="$env:PR_BRANCH" /d:sonar.pullrequest.base="$env:PR_BASE" dotnet build source/Handlebars.sln -c Release - .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.token="${{ env.SONAR_TOKEN }}" + .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.token="$env:SONAR_TOKEN" benchmark: name: Run Benchmark.Net From c1263da8fcf35a671fa3b34ad768afed8be4067f Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Thu, 18 Jun 2026 22:57:19 -0400 Subject: [PATCH 10/10] ci: switch windows-2019 to windows-latest in ci.yml windows-2019 runners were stalling (same issue seen previously in pull_request.yml). windows-latest uses the current stable runner pool and picks up in seconds. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a79776af..9495f690 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: jobs: build: name: Build - runs-on: windows-2019 + runs-on: windows-latest steps: - uses: actions/checkout@v4 - name: Setup dotnet @@ -26,7 +26,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ macos-latest, ubuntu-latest, windows-2019 ] + os: [ macos-latest, ubuntu-latest, windows-latest ] steps: - uses: actions/checkout@v4 - name: Setup dotnet @@ -41,7 +41,7 @@ jobs: sonar-ci: name: SonarCloud - runs-on: windows-2019 + runs-on: windows-latest steps: - uses: actions/checkout@v4 with: