From e2d3dc27e16523036d46da6dedf91e269339167f Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Thu, 6 Aug 2026 20:24:15 -0400 Subject: [PATCH 1/2] fix: preserve literal line endings in static template text (issue #661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StaticConverter unconditionally rewrote \r\n/\r to \n in every static text token, and PartialBinder.WriteWithIndent did the same for indented partial content — both added 2026-06-20 to paper over an internal inconsistency between the two (WriteWithIndent normalized while the surrounding static text didn't, producing mixed line endings within a single render). That fix overshot: it silently rewrote line endings the caller explicitly put in a template string, e.g. explicit \r\n between {{#each}} iterations. Static text is the caller's literal content and must round-trip verbatim, matching handlebars.js and this library's own behavior prior to that change. Removed the rewrite from both places; WriteWithIndent now scans the original content directly, since a \r immediately before a split point rides along with its segment for free. Reverted the test-expectation edits from that commit back to their original \r\n assertions, and added regression coverage: explicit \r\n and \n preservation, an Environment.NewLine-based test that exercises \r\n on Windows and \n on macOS/Ubuntu via the existing 3-OS CI matrix, and an indented-partial CRLF case matching the WriteWithIndent path. Co-Authored-By: Claude Sonnet 5 --- .../Handlebars.Test/BasicIntegrationTests.cs | 4 +- .../ComplexIntegrationTests.cs | 12 ++-- source/Handlebars.Test/IssueTests.cs | 70 ++++++++++++++++++- source/Handlebars.Test/PartialTests.cs | 6 +- source/Handlebars.Test/ReadmeTests.cs | 8 +-- .../ViewEngine/ViewEngineTests.cs | 58 +++++++-------- .../Lexer/Converter/StaticConverter.cs | 6 +- .../Translation/Expression/PartialBinder.cs | 15 ++-- 8 files changed, 122 insertions(+), 57 deletions(-) diff --git a/source/Handlebars.Test/BasicIntegrationTests.cs b/source/Handlebars.Test/BasicIntegrationTests.cs index 0c64e9b3..d5211f82 100644 --- a/source/Handlebars.Test/BasicIntegrationTests.cs +++ b/source/Handlebars.Test/BasicIntegrationTests.cs @@ -482,7 +482,7 @@ public void PathRelativeBinding(IHandlebars handlebars) }; var result = handlebarsTemplate(data); - var actual = string.Join(" ", result.Split(new[] { "\n" }, StringSplitOptions.RemoveEmptyEntries).Select(o => o.Trim())); + var actual = string.Join(" ", result.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries).Select(o => o.Trim(' '))); Assert.Equal("Garry Finch gazraa Karen Finch photobasics", actual); } @@ -546,7 +546,7 @@ public void PathRelativeBinding_WithDefaultValue(IHandlebars handlebars) }; var result = handlebarsTemplate(data); - var actual = string.Join(" ", result.Split(new[] { "\n" }, StringSplitOptions.RemoveEmptyEntries).Select(o => o.Trim())); + var actual = string.Join(" ", result.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries).Select(o => o.Trim(' '))); Assert.Equal("Garry Finch N/A Karen Finch photobasics", actual); } diff --git a/source/Handlebars.Test/ComplexIntegrationTests.cs b/source/Handlebars.Test/ComplexIntegrationTests.cs index da1c26d0..0332c57a 100644 --- a/source/Handlebars.Test/ComplexIntegrationTests.cs +++ b/source/Handlebars.Test/ComplexIntegrationTests.cs @@ -56,10 +56,14 @@ public void DeepIf() var resultTrueFalse = template(trueFalse); var resultFalseTrue = template(falseTrue); var resultFalseFalse = template(falseFalse); - Assert.Equal("a is true\n", resultTrueTrue); - Assert.Equal("a is false\n", resultTrueFalse); - Assert.Equal("b is true\n", resultFalseTrue); - Assert.Equal("b is false\n", resultFalseFalse); + Assert.Equal(@"a is true +", resultTrueTrue); + Assert.Equal(@"a is false +", resultTrueFalse); + Assert.Equal(@"b is true +", resultFalseTrue); + Assert.Equal(@"b is false +", resultFalseFalse); } [Fact] diff --git a/source/Handlebars.Test/IssueTests.cs b/source/Handlebars.Test/IssueTests.cs index d94aaa2c..b3406f60 100644 --- a/source/Handlebars.Test/IssueTests.cs +++ b/source/Handlebars.Test/IssueTests.cs @@ -225,7 +225,9 @@ public void RenderingWithUnusedPartial() var transformed = navTemplate(context).Trim(); - Assert.Equal("
\n
Menu Item: Getting Started
\n
", transformed); + Assert.Equal(@"
+
Menu Item: Getting Started
+
", transformed); } // issue: https://github.com/Handlebars-Net/Handlebars.Net/issues/394 @@ -1270,6 +1272,72 @@ public void Issue601_DefaultInterfaceMemberPropertyIsEnumerated() Assert.Contains($"OtherStr={data.OtherStr};", result); } + // Issue: https://github.com/Handlebars-Net/Handlebars.Net/issues/661 + // Static template text must round-trip verbatim: the compiler must not rewrite + // line endings the caller put in the template string. + // + // This mirrors the reporter's exact repro shape: #each over a helper subexpression + // (String.Split isn't in this repo, so a local "Split" helper stands in for it) and + // the @Key/@Index casing they used — confirmed case-insensitive via ChainSegment's + // OrdinalIgnoreCase lookup, so this is equivalent to @key/@index, not a looser stand-in. + [Fact] + public void Issue661_ExplicitCrLfInTemplateIsPreservedVerbatim() + { + var handlebars = Handlebars.Create(); + handlebars.RegisterHelper("Split", (context, arguments) => + ((string) arguments[0]).Split(((string) arguments[1])[0])); + + var template = handlebars.Compile("{{#each (Split \"a;b;c\" ';')}}\r\n{{@Key}}:{{@Index}}:{{this}}\r\n{{/each}}"); + + var result = template(new { }); + + Assert.Equal("0:0:a\r\n1:1:b\r\n2:2:c\r\n", result); + } + + // A bare \n in the template must not be turned into \r\n either — the fix must not + // trade under-preservation for over-preservation. + [Fact] + public void Issue661_ExplicitLfInTemplateIsPreservedVerbatim() + { + var handlebars = Handlebars.Create(); + var template = handlebars.Compile("{{#each this}}\n{{@key}}:{{@index}}:{{this}}\n{{/each}}"); + + var result = template(new[] { "a", "b", "c" }); + + Assert.Equal("0:0:a\n1:1:b\n2:2:c\n", result); + } + + // Whatever the platform's native newline is, it must pass through unchanged. On the + // CI matrix (macOS/Ubuntu/Windows) this exercises \n and \r\n as separate runs without + // any OS-conditional test code. + [Fact] + public void Issue661_PlatformNewlineInTemplateIsPreservedVerbatim() + { + var handlebars = Handlebars.Create(); + var template = handlebars.Compile("line one" + Environment.NewLine + "{{value}}" + Environment.NewLine + "line three"); + + var result = template(new { value = "middle" }); + + Assert.Equal("line one" + Environment.NewLine + "middle" + Environment.NewLine + "line three", result); + } + + // Indented partials must preserve \r\n in the partial's own content too — WriteWithIndent + // used to normalise to \n while everything else passed content through verbatim. + [Fact] + public void Issue661_IndentedPartialPreservesCrLf() + { + var handlebars = Handlebars.Create(); + using (var reader = new StringReader("one\r\ntwo\r\nthree")) + { + handlebars.RegisterTemplate("myPartial", handlebars.Compile(reader)); + } + var template = handlebars.Compile(" {{> myPartial}}"); + + var result = template(new { }); + + Assert.Equal(" one\r\n two\r\n three", result); + } + private static void RegisterStringEqualityBlockHelper(IHandlebars handlebars) { handlebars.RegisterHelper("StringEqualityBlockHelper", (output, options, context, arguments) => diff --git a/source/Handlebars.Test/PartialTests.cs b/source/Handlebars.Test/PartialTests.cs index 00f7a868..41848ae6 100644 --- a/source/Handlebars.Test/PartialTests.cs +++ b/source/Handlebars.Test/PartialTests.cs @@ -950,7 +950,7 @@ public void PartialIndentationIsAppliedInsideBlock() } [Fact] - public void PartialWithCrLfLineEndingsNormalisedToLf() + public void PartialWithCrLfLineEndingsPreservedVerbatim() { var handlebars = Handlebars.Create(); var source = " {{> p}}"; @@ -963,8 +963,8 @@ public void PartialWithCrLfLineEndingsNormalisedToLf() var result = handlebars.Compile(source)(new { }); - // \r\n in the partial source is normalised to \n; every line gets the indent. - Assert.Equal(" line1\n line2\n line3", result); + // \r\n in the partial source is preserved verbatim; every line gets the indent (issue #661). + Assert.Equal(" line1\r\n line2\r\n line3", result); } [Fact] diff --git a/source/Handlebars.Test/ReadmeTests.cs b/source/Handlebars.Test/ReadmeTests.cs index 8966be8e..52589baa 100644 --- a/source/Handlebars.Test/ReadmeTests.cs +++ b/source/Handlebars.Test/ReadmeTests.cs @@ -29,14 +29,14 @@ public void RegisterBlockHelper() {"Chewy", "hamster" } }; - var template = "{{#each this}}The animal, {{@key}}, {{#StringEqualityBlockHelper @value 'dog'}}is a dog{{else}}is not a dog{{/StringEqualityBlockHelper}}.\n{{/each}}"; + var template = "{{#each this}}The animal, {{@key}}, {{#StringEqualityBlockHelper @value 'dog'}}is a dog{{else}}is not a dog{{/StringEqualityBlockHelper}}.\r\n{{/each}}"; var compiledTemplate = handlebars.Compile(template); string templateOutput = compiledTemplate(animals); Assert.Equal( - "The animal, Fluffy, is not a dog.\n" + - "The animal, Fido, is a dog.\n" + - "The animal, Chewy, is not a dog.\n", + "The animal, Fluffy, is not a dog.\r\n" + + "The animal, Fido, is a dog.\r\n" + + "The animal, Chewy, is not a dog.\r\n", templateOutput ); } diff --git a/source/Handlebars.Test/ViewEngine/ViewEngineTests.cs b/source/Handlebars.Test/ViewEngine/ViewEngineTests.cs index fb3b21c0..71bfe339 100644 --- a/source/Handlebars.Test/ViewEngine/ViewEngineTests.cs +++ b/source/Handlebars.Test/ViewEngine/ViewEngineTests.cs @@ -17,7 +17,7 @@ public void CanLoadAViewWithALayout() //Given a layout in a subfolder var files = new FakeFileSystem() { - {"views\\somelayout.hbs", "layout start\n{{{body}}}\nlayout end"}, + {"views\\somelayout.hbs", "layout start\r\n{{{body}}}\r\nlayout end"}, //And a view in the same folder which uses that layout { "views\\someview.hbs", "{{!< somelayout}}This is the body"} }; @@ -28,7 +28,7 @@ public void CanLoadAViewWithALayout() var output = renderView(null); //Then the correct output should be rendered - Assert.Equal("layout start\nThis is the body\nlayout end", output); + Assert.Equal("layout start\r\nThis is the body\r\nlayout end", output); } [Fact] public void CanLoadAWriterViewWithALayout() @@ -36,7 +36,7 @@ public void CanLoadAWriterViewWithALayout() //Given a layout in a subfolder var files = new FakeFileSystem() { - {"views\\somelayout.hbs", "layout start\n{{{body}}}\nlayout end"}, + {"views\\somelayout.hbs", "layout start\r\n{{{body}}}\r\nlayout end"}, //And a view in the same folder which uses that layout { "views\\someview.hbs", "{{!< somelayout}}This is the body"} }; @@ -50,7 +50,7 @@ public void CanLoadAWriterViewWithALayout() var output = sb.ToString(); //Then the correct output should be rendered - Assert.Equal("layout start\nThis is the body\nlayout end", output); + Assert.Equal("layout start\r\nThis is the body\r\nlayout end", output); } [Fact] @@ -59,7 +59,7 @@ public void CanLoadAViewWithALayoutInTheRoot() //Given a layout in the root var files = new FakeFileSystem() { - {"somelayout.hbs", "layout start\n{{{body}}}\nlayout end"}, + {"somelayout.hbs", "layout start\r\n{{{body}}}\r\nlayout end"}, //And a view in a subfolder folder which uses that layout { "views\\someview.hbs", "{{!< somelayout}}This is the body"} }; @@ -70,7 +70,7 @@ public void CanLoadAViewWithALayoutInTheRoot() var output = render(null); //Then the correct output should be rendered - Assert.Equal("layout start\nThis is the body\nlayout end", output); + Assert.Equal("layout start\r\nThis is the body\r\nlayout end", output); } [Fact] @@ -81,9 +81,9 @@ public void CanRenderInlineBlocks() var files = new FakeFileSystem() { //Given a layout in a subfolder - { "partials/layout.hbs", "
\n{{> nav}}\n
\n
\n{{> content}}\n
"}, + { "partials/layout.hbs", "
\r\n{{> nav}}\r\n
\r\n
\r\n{{> content}}\r\n
"}, - { "template.hbs", "{{#> layout}}\n{{#*inline \"nav\"}}\n{{Text}}\n{{/inline}}\n{{#*inline \"content\"}}\nMy Content\n{{/inline}}\n{{/layout}}"} + { "template.hbs", "{{#> layout}}\r\n{{#*inline \"nav\"}}\r\n{{Text}}\r\n{{/inline}}\r\n{{#*inline \"content\"}}\r\nMy Content\r\n{{/inline}}\r\n{{/layout}}"} }; //When a viewengine renders that view @@ -95,7 +95,7 @@ public void CanRenderInlineBlocks() }); //Then the correct output should be rendered - Assert.Equal("
\n<My Nav>\n
\n
\nMy Content\n
", output); + Assert.Equal("
\r\n<My Nav>\r\n
\r\n
\r\nMy Content\r\n
", output); } [Fact] @@ -104,7 +104,7 @@ public void CanLoadAViewWithALayoutWithAVariable() //Given a layout in the root var files = new FakeFileSystem() { - {"somelayout.hbs", "{{var1}} start\n{{{body}}}\n{{var1}} end"}, + {"somelayout.hbs", "{{var1}} start\r\n{{{body}}}\r\n{{var1}} end"}, //And a view in a subfolder folder which uses that layout { "views\\someview.hbs", "{{!< somelayout}}This is the {{var2}}"} }; @@ -115,7 +115,7 @@ public void CanLoadAViewWithALayoutWithAVariable() var output = renderView(new { var1 = "layout", var2 = "body" }); //Then the correct output should be rendered - Assert.Equal("layout start\nThis is the body\nlayout end", output); + Assert.Equal("layout start\r\nThis is the body\r\nlayout end", output); } [Fact] @@ -124,7 +124,7 @@ public void CanLoadAViewWithALayoutInTheRootWithAVariable() //Given a layout in the root var files = new FakeFileSystem() { - {"somelayout.hbs", "{{var1}} start\n{{{body}}}\n{{var1}} end"}, + {"somelayout.hbs", "{{var1}} start\r\n{{{body}}}\r\n{{var1}} end"}, //And a view in a subfolder folder which uses that layout { "views\\someview.hbs", "{{!< somelayout}}This is the {{var2}}"} }; @@ -135,7 +135,7 @@ public void CanLoadAViewWithALayoutInTheRootWithAVariable() var output = render(new { var1 = "layout", var2 = "body" }); //Then the correct output should be rendered - Assert.Equal("layout start\nThis is the body\nlayout end", output); + Assert.Equal("layout start\r\nThis is the body\r\nlayout end", output); } [Fact] @@ -162,8 +162,8 @@ public void CanIgnoreCommentsContainingHtml() { var files = new FakeFileSystem() { - { "views\\layout.hbs", "Start\n{{{body}}}\nEnd" }, - { "views\\someview.hbs", "{{!< layout}}\n\nTemplate\n{{!--\n
Commented out HTML
\n--}}" }, + { "views\\layout.hbs", "Start\r\n{{{body}}}\r\nEnd" }, + { "views\\someview.hbs", "{{!< layout}}\r\n\r\nTemplate\r\n{{!--\r\n
Commented out HTML
\r\n--}}" }, }; var handlebarsConfiguration = new HandlebarsConfiguration() { FileSystem = files }; @@ -171,7 +171,7 @@ public void CanIgnoreCommentsContainingHtml() var render = handlebars.CompileView("views\\someview.hbs"); var output = render(null); - Assert.Equal("Start\n\nTemplate\n\nEnd", output); + Assert.Equal("Start\r\n\r\nTemplate\r\n\r\nEnd", output); } [Fact] @@ -179,8 +179,8 @@ public void CanUseDictionaryModelInLayout() { var files = new FakeFileSystem { - { "views\\layout.hbs", "Layout: {{property}}\n{{{body}}}" }, - { "views\\someview.hbs", "{{!< layout}}\n\nBody: {{property}}" }, + { "views\\layout.hbs", "Layout: {{property}}\r\n{{{body}}}" }, + { "views\\someview.hbs", "{{!< layout}}\r\n\r\nBody: {{property}}" }, }; var handlebarsConfiguration = new HandlebarsConfiguration { FileSystem = files }; @@ -193,7 +193,7 @@ public void CanUseDictionaryModelInLayout() } ); - Assert.Equal("Layout: Foo\n\nBody: Foo", output); + Assert.Equal("Layout: Foo\r\n\r\nBody: Foo", output); } [Fact] @@ -201,8 +201,8 @@ public void CanUseDynamicModelInLayout() { var files = new FakeFileSystem { - { "views\\layout.hbs", "Layout: {{property}}\n{{{body}}}" }, - { "views\\someview.hbs", "{{!< layout}}\n\nBody: {{property}}" }, + { "views\\layout.hbs", "Layout: {{property}}\r\n{{{body}}}" }, + { "views\\someview.hbs", "{{!< layout}}\r\n\r\nBody: {{property}}" }, }; dynamic model = new MyDynamicModel(); @@ -211,7 +211,7 @@ public void CanUseDynamicModelInLayout() var render = handlebars.CompileView("views\\someview.hbs"); var output = render(model); - Assert.Equal("Layout: Foo\n\nBody: Foo", output); + Assert.Equal("Layout: Foo\r\n\r\nBody: Foo", output); } [Fact] @@ -219,9 +219,9 @@ public void CanBindToModelInNestedLayout() { var files = new FakeFileSystem { - { "views\\parent_layout.hbs", "Parent layout: {{property}}\n{{{body}}}" }, - { "views\\layout.hbs", "{{!< parent_layout}}\nLayout: {{property}}\n{{{body}}}" }, - { "views\\someview.hbs", "{{!< layout}}\n\nBody: {{property}}" }, + { "views\\parent_layout.hbs", "Parent layout: {{property}}\r\n{{{body}}}" }, + { "views\\layout.hbs", "{{!< parent_layout}}\r\nLayout: {{property}}\r\n{{{body}}}" }, + { "views\\someview.hbs", "{{!< layout}}\r\n\r\nBody: {{property}}" }, }; var handlebarsConfiguration = new HandlebarsConfiguration { FileSystem = files }; @@ -234,7 +234,7 @@ public void CanBindToModelInNestedLayout() } ); - Assert.Equal("Parent layout: Foo\nLayout: Foo\n\nBody: Foo", output); + Assert.Equal("Parent layout: Foo\r\nLayout: Foo\r\n\r\nBody: Foo", output); } [Fact] @@ -242,8 +242,8 @@ public void CanUseNullModelInLayout() { var files = new FakeFileSystem { - { "views\\layout.hbs", "Layout: {{property}}\n{{{body}}}" }, - { "views\\someview.hbs", "{{!< layout}}\n\nBody: {{property}}" }, + { "views\\layout.hbs", "Layout: {{property}}\r\n{{{body}}}" }, + { "views\\someview.hbs", "{{!< layout}}\r\n\r\nBody: {{property}}" }, }; var handlebarsConfiguration = new HandlebarsConfiguration { FileSystem = files }; @@ -251,7 +251,7 @@ public void CanUseNullModelInLayout() var render = handlebars.CompileView("views\\someview.hbs"); var output = render(null); - Assert.Equal("Layout: \n\nBody: ", output); + Assert.Equal("Layout: \r\n\r\nBody: ", output); } [Fact] diff --git a/source/Handlebars/Compiler/Lexer/Converter/StaticConverter.cs b/source/Handlebars/Compiler/Lexer/Converter/StaticConverter.cs index ffc677b6..af015244 100644 --- a/source/Handlebars/Compiler/Lexer/Converter/StaticConverter.cs +++ b/source/Handlebars/Compiler/Lexer/Converter/StaticConverter.cs @@ -30,11 +30,7 @@ public override IEnumerable ConvertTokens(IEnumerable sequence) if (staticToken.Value != string.Empty) { - // Normalize to \n so output is platform-independent regardless of source file line endings. - var value = staticToken.Value.IndexOf('\r') >= 0 - ? staticToken.Value.Replace("\r\n", "\n").Replace("\r", "\n") - : staticToken.Value; - yield return HandlebarsExpression.Static(value); + yield return HandlebarsExpression.Static(staticToken.Value); } } } diff --git a/source/Handlebars/Compiler/Translation/Expression/PartialBinder.cs b/source/Handlebars/Compiler/Translation/Expression/PartialBinder.cs index a94152fe..8adde0aa 100644 --- a/source/Handlebars/Compiler/Translation/Expression/PartialBinder.cs +++ b/source/Handlebars/Compiler/Translation/Expression/PartialBinder.cs @@ -139,8 +139,8 @@ private static void InvokePartialWithFallback( /// to every non-empty line. All lines including the first receive the indent because the /// WhitespaceRemover already stripped the leading whitespace from the static token that preceded /// the partial tag. An empty trailing segment after the last newline does not receive an indent. - /// Newlines are normalised to \n so that output is consistent across platforms regardless - /// of whether the partial source was checked out with \r\n line endings. + /// Line endings are preserved verbatim — a \r immediately before a split point rides along + /// as part of the preceding segment, so \r\n content stays \r\n. /// private static void WriteWithIndent(EncodedTextWriter writer, string? content, string? indent) { @@ -149,24 +149,21 @@ private static void WriteWithIndent(EncodedTextWriter writer, string? content, s return; } - // Normalise line endings to \n so Windows \r\n does not produce \r artifacts. - var normalised = content.Replace("\r\n", "\n").Replace("\r", "\n"); - var pos = 0; - while (pos < normalised.Length) + while (pos < content.Length) { - var newlinePos = normalised.IndexOf('\n', pos); + var newlinePos = content.IndexOf('\n', pos); if (newlinePos < 0) { // No more newlines — write indent + rest and stop writer.Write(indent, false); - writer.Write(normalised.Substring(pos), false); + writer.Write(content.Substring(pos), false); break; } // Write indent + the segment up to and including the \n writer.Write(indent, false); - writer.Write(normalised.Substring(pos, newlinePos - pos + 1), false); + writer.Write(content.Substring(pos, newlinePos - pos + 1), false); pos = newlinePos + 1; } } From 4832a729844471a457510d08fb11f18b1e3e67ab Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Thu, 6 Aug 2026 20:38:00 -0400 Subject: [PATCH 2/2] fix: add .gitattributes to force LF checkout, fixing Windows CI failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #663 removed StaticConverter's blanket \r\n->\n rewrite (issue #661), which was incidentally papering over a second, separate problem: several tests embed multi-line template content as verbatim/raw C# string literals (actual embedded newlines in the .cs source) and assert against an explicit \n-based expected value. With no .gitattributes, Windows runners/clients default core.autocrlf=true and check those literals out as \r\n, so once Handlebars.Net stopped silently normalizing render output, TestNestedPartials and Issue519_PartialBlockUsableAsBlockAndInIf failed on windows-latest CI (confirmed via the PR's own check run). Forcing LF at checkout is the correct fix for that problem: it makes the checked-out source deterministic across platforms instead of asking the render pipeline to paper over a source-control inconsistency. The tracked blobs are already LF (verified via git show | od), so no --renormalize pass is needed — this only changes how future checkouts materialize them. Co-Authored-By: Claude Sonnet 5 --- .gitattributes | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..f28471b1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Force LF line endings for all text files, regardless of the checking-out platform's git +# config (e.g. Windows runners/clients that default core.autocrlf=true). Several tests embed +# multi-line template literals (verbatim/raw C# strings) and compare rendered output against +# an explicit \n-based expectation; without this, those literals check out as \r\n on Windows +# and the comparison fails, since Handlebars.Net no longer rewrites line endings at render time +# (see issue #661) — line endings in template source are the caller's content now, not something +# the library silently normalizes. +* text=auto eol=lf