From b2c1ca1311fdb81e51085bb4cb1502d45cbaff7f Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Sat, 20 Jun 2026 08:35:07 -0400 Subject: [PATCH 1/2] fix: preserve partial indentation (issue #614) When a partial invocation ({{> partial}}) is the only non-whitespace content on a line, the leading whitespace is now captured as an indent string and prepended to every line of the rendered partial output. This matches Handlebars.js / Mustache standalone-partial behaviour. Implementation: - PartialExpression gains an Indent property - WhitespaceRemover.ProcessTokens extracts the leading whitespace from the preceding static token and stores it on a new PartialExpression before discarding the whitespace from the token stream - PartialBinder.InvokePartial* renders into a temporary StringWriter and calls WriteWithIndent to prefix every line with the captured indent before writing to the real output writer - Two pre-existing tests updated to reflect the new correct output (StandalonePartials and TestNestedPartials) Co-Authored-By: Claude Sonnet 4.6 --- source/Handlebars.Test/IssueTests.cs | 16 +- .../Handlebars.Test/Issues/Issue614Tests.cs | 190 ++++++++++++++++++ source/Handlebars.Test/WhitespaceTests.cs | 4 +- .../Lexer/Converter/WhitespaceRemover.cs | 31 +++ .../Structure/HandlebarsExpression.cs | 5 + .../Compiler/Structure/PartialExpression.cs | 10 +- .../Translation/Expression/PartialBinder.cs | 133 +++++++++--- 7 files changed, 353 insertions(+), 36 deletions(-) create mode 100644 source/Handlebars.Test/Issues/Issue614Tests.cs diff --git a/source/Handlebars.Test/IssueTests.cs b/source/Handlebars.Test/IssueTests.cs index 31136117..359a21dc 100644 --- a/source/Handlebars.Test/IssueTests.cs +++ b/source/Handlebars.Test/IssueTests.cs @@ -147,17 +147,11 @@ End outer partial block
var callback = handlebars.Compile(view); string result = callback(new object()); - const string expected = @"Begin outer partial
- Begin outer partial block -
- Begin inner partial
- Begin inner partial block
- View
- End inner partial block
- End inner partial
- End outer partial block
- End outer partial"; - + // Issue #614: partial indentation is now preserved (Handlebars.js behaviour). + // Each standalone {{>@partial-block}} applies its own leading whitespace as indent + // to every line of the rendered block content. + const string expected = "Begin outer partial
\n Begin outer partial block\n
\n Begin inner partial
\n Begin inner partial block
\n View
\n End inner partial block
\n End inner partial
\n End outer partial block
\n End outer partial"; + Assert.Equal(expected, result); } diff --git a/source/Handlebars.Test/Issues/Issue614Tests.cs b/source/Handlebars.Test/Issues/Issue614Tests.cs new file mode 100644 index 00000000..ed5a98f1 --- /dev/null +++ b/source/Handlebars.Test/Issues/Issue614Tests.cs @@ -0,0 +1,190 @@ +using System.IO; +using Xunit; + +namespace HandlebarsDotNet.Test +{ + /// + /// Tests for issue #614 — Partial indentation not preserved. + /// When {{> partial}} is indented with spaces/tabs on its own line, every line of the + /// rendered partial should be prefixed with that same indentation, matching Handlebars.js behavior. + /// + public class Issue614Tests + { + private readonly IHandlebars _handlebars; + + public Issue614Tests() + { + _handlebars = Handlebars.Create(); + } + + /// + /// Spec section 20.12: Template " {{> p}}" + Partial "line1\nline2" => " line1\n line2" + /// The two leading spaces become the indentation for every line of the partial output. + /// + [Fact] + public void InlinePartialSpecExample() + { + var source = " {{> p}}"; + var partialSource = "line1\nline2"; + + using (var reader = new StringReader(partialSource)) + { + _handlebars.RegisterTemplate("p", _handlebars.Compile(reader)); + } + + var result = _handlebars.Compile(source)(new { }); + + Assert.Equal(" line1\n line2", result); + } + + /// + /// The two-space indent before {{> user}} is applied to each line of the partial output. + /// The trailing newline after the standalone partial invocation is stripped (standalone behaviour). + /// + [Fact] + public void PartialIndentationWithMultiLinePartial() + { + var source = "Start\n {{> content}}\nEnd"; + var partialSource = "line1\nline2\nline3"; + + using (var reader = new StringReader(partialSource)) + { + _handlebars.RegisterTemplate("content", _handlebars.Compile(reader)); + } + + var result = _handlebars.Compile(source)(new { }); + + // TrimAfter strips the \n between the partial tag and "End", so the output is: + // "Start\n" + " line1\n line2\n line3" + "End" + Assert.Equal("Start\n line1\n line2\n line3End", result); + } + + /// + /// A tab character before the partial invocation is used as the indentation. + /// + [Fact] + public void PartialIndentationWithTabCharacter() + { + var source = "Start\n\t{{> content}}\nEnd"; + var partialSource = "line1\nline2"; + + using (var reader = new StringReader(partialSource)) + { + _handlebars.RegisterTemplate("content", _handlebars.Compile(reader)); + } + + var result = _handlebars.Compile(source)(new { }); + + Assert.Equal("Start\n\tline1\n\tline2End", result); + } + + /// + /// A partial with no preceding whitespace receives no indentation. + /// The newline that follows the standalone partial tag is stripped. + /// + [Fact] + public void PartialWithNoIndentationUnchanged() + { + var source = "Hello\n{{> greeting}}\nBye"; + var partialSource = "World"; + + using (var reader = new StringReader(partialSource)) + { + _handlebars.RegisterTemplate("greeting", _handlebars.Compile(reader)); + } + + var result = _handlebars.Compile(source)(new { }); + + // Standalone with no indent: TrimAfter strips \nBye → Bye, no indent added. + Assert.Equal("Hello\nWorldBye", result); + } + + /// + /// The indentation is applied inside a block helper iteration. + /// A single-line partial produces indented output per iteration; + /// iterations are not separated because the newline after {{> user}} is stripped. + /// + [Fact] + public void PartialIndentationIsAppliedInsideBlock() + { + var source = "

Names

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

Names

\n Karen Jon", result); + } + + /// + /// A partial whose source uses Windows-style \r\n line endings (e.g. checked out on Windows + /// with git autocrlf=true, or produced by a StringWriter whose NewLine is \r\n) is normalised + /// to \n in the indented output. The library always emits \n as the line separator so that + /// rendered output is identical across platforms. + /// + [Fact] + public void PartialWithCrLfLineEndingsNormalisedToLf() + { + var source = " {{> p}}"; + var partialSource = "line1\r\nline2\r\nline3"; // Windows-style \r\n + + using (var reader = new StringReader(partialSource)) + { + _handlebars.RegisterTemplate("p", _handlebars.Compile(reader)); + } + + var result = _handlebars.Compile(source)(new { }); + + // \r\n in the partial source is normalised to \n; every line gets the indent. + Assert.Equal(" line1\n line2\n line3", result); + } + + /// + /// A multi-line partial called inside an iteration — each line of each iteration is indented. + /// + [Fact] + public void MultiLinePartialIndentationInsideBlock() + { + var source = "{{#items}}\n {{> row}}\n{{/items}}"; + var partialSource = "- {{name}}\n ({{desc}})"; + + using (var reader = new StringReader(partialSource)) + { + _handlebars.RegisterTemplate("row", _handlebars.Compile(reader)); + } + + var template = _handlebars.Compile(source); + var data = new + { + items = new[] + { + new { name = "A", desc = "alpha" }, + new { name = "B", desc = "beta" } + } + }; + + var result = template(data); + + // Each 2-line partial gets " " prepended to both lines. + // The newline between the partial tag and the next iteration/closing tag is stripped. + Assert.Equal(" - A\n (alpha) - B\n (beta)", result); + } + + } +} diff --git a/source/Handlebars.Test/WhitespaceTests.cs b/source/Handlebars.Test/WhitespaceTests.cs index 725cf3dd..ef86bb16 100644 --- a/source/Handlebars.Test/WhitespaceTests.cs +++ b/source/Handlebars.Test/WhitespaceTests.cs @@ -212,7 +212,9 @@ public void StandalonePartials() } var result = template(data); - Assert.Equal("Here are:\nMarcMarc", result); + // Issue #614: standalone partial indentation is now preserved (Handlebars.js behaviour). + // " " indent for the first partial, " " indent for the second. + Assert.Equal("Here are:\n Marc Marc", result); } } diff --git a/source/Handlebars/Compiler/Lexer/Converter/WhitespaceRemover.cs b/source/Handlebars/Compiler/Lexer/Converter/WhitespaceRemover.cs index ec4395f3..e9bb6664 100644 --- a/source/Handlebars/Compiler/Lexer/Converter/WhitespaceRemover.cs +++ b/source/Handlebars/Compiler/Lexer/Converter/WhitespaceRemover.cs @@ -14,6 +14,8 @@ internal class WhitespaceRemover : TokenConverter private static readonly Regex MatchFirstEndsWithWhitespace = new Regex(@"(^|\r?\n)\s*?$", RegexOptions.Compiled); private static readonly Regex MatchEndsWithWhitespace = new Regex(@"\r?\n\s*?$"); private static readonly Regex TrimEndRegex = new Regex(@"[ \t]+\z", RegexOptions.Compiled); + // Captures trailing whitespace (spaces/tabs) after the last newline — this is the partial's indentation + private static readonly Regex ExtractIndentRegex = new Regex(@"(?:^|\r?\n)([ \t]*)$", RegexOptions.Compiled); private static readonly WhitespaceRemover Remover = new WhitespaceRemover(); @@ -59,6 +61,26 @@ private static void ProcessTokens(IList list) if (IsStandalone(statement) && IsNextWhitespace(list, i) && IsPrevWhitespace(list, i)) { + // For standalone partials, extract the preceding indentation and store it on the + // PartialExpression so it can be applied to every line of the rendered output. + if (statement.Body is PartialExpression partialExpr) + { + var indent = ExtractIndent(list, i); + if (!string.IsNullOrEmpty(indent)) + { + var indentedPartial = HandlebarsExpression.Partial( + partialExpr.PartialName, + partialExpr.Argument, + partialExpr.Fallback, + indent); + list[i] = HandlebarsExpression.Statement( + indentedPartial, + statement.IsEscaped, + statement.TrimBefore, + statement.TrimAfter); + } + } + if (!statement.TrimBefore) { TrimBefore(list, i, false); @@ -71,6 +93,15 @@ private static void ProcessTokens(IList list) } } + private static string ExtractIndent(IList list, int index) + { + if (index < 1) return string.Empty; + if (!(list[index - 1] is StaticToken prev)) return string.Empty; + + var match = ExtractIndentRegex.Match(prev.Value); + return match.Success ? match.Groups[1].Value : string.Empty; + } + private static bool IsNextWhitespace(IList list, int index) { if (index >= list.Count - 1) diff --git a/source/Handlebars/Compiler/Structure/HandlebarsExpression.cs b/source/Handlebars/Compiler/Structure/HandlebarsExpression.cs index 7830834c..12cf8074 100644 --- a/source/Handlebars/Compiler/Structure/HandlebarsExpression.cs +++ b/source/Handlebars/Compiler/Structure/HandlebarsExpression.cs @@ -102,6 +102,11 @@ public static PartialExpression Partial(Expression partialName, Expression argum return new PartialExpression(partialName, argument, fallback); } + public static PartialExpression Partial(Expression partialName, Expression argument, Expression fallback, string indent) + { + return new PartialExpression(partialName, argument, fallback, indent); + } + public static BoolishExpression Boolish(Expression condition) { return new BoolishExpression(condition); diff --git a/source/Handlebars/Compiler/Structure/PartialExpression.cs b/source/Handlebars/Compiler/Structure/PartialExpression.cs index bb25eda6..187633bf 100644 --- a/source/Handlebars/Compiler/Structure/PartialExpression.cs +++ b/source/Handlebars/Compiler/Structure/PartialExpression.cs @@ -4,11 +4,12 @@ namespace HandlebarsDotNet.Compiler { internal class PartialExpression : HandlebarsExpression { - public PartialExpression(Expression partialName, Expression argument, Expression fallback) + public PartialExpression(Expression partialName, Expression argument, Expression fallback, string indent = null) { PartialName = partialName; Argument = argument; Fallback = fallback; + Indent = indent; } public override ExpressionType NodeType => (ExpressionType)HandlebarsExpressionType.PartialExpression; @@ -18,6 +19,13 @@ public PartialExpression(Expression partialName, Expression argument, Expression public Expression Argument { get; } public Expression Fallback { get; } + + /// + /// The whitespace that preceded the partial tag on its line. + /// When non-null/non-empty, this indentation is prepended to every line of the rendered partial output, + /// matching Handlebars.js standalone partial indentation behavior. + /// + public string Indent { get; } } } diff --git a/source/Handlebars/Compiler/Translation/Expression/PartialBinder.cs b/source/Handlebars/Compiler/Translation/Expression/PartialBinder.cs index 57467832..3f0bf3b5 100644 --- a/source/Handlebars/Compiler/Translation/Expression/PartialBinder.cs +++ b/source/Handlebars/Compiler/Translation/Expression/PartialBinder.cs @@ -1,7 +1,10 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq.Expressions; +using System.Text; using Expressions.Shortcuts; +using HandlebarsDotNet.IO; using HandlebarsDotNet.PathStructure; using HandlebarsDotNet.Polyfills; using static Expressions.Shortcuts.ExpressionShortcuts; @@ -26,36 +29,37 @@ public PartialBinder(CompilationContext compilationContext) protected override Expression VisitPartialExpression(PartialExpression pex) { IReadOnlyList decorators = ArrayEx.Empty(); - var partialBlockTemplate = pex.Fallback != null - ? FunctionBuilder.Compile(new[] { pex.Fallback }, CompilationContext, out decorators) + var partialBlockTemplate = pex.Fallback != null + ? FunctionBuilder.Compile(new[] { pex.Fallback }, CompilationContext, out decorators) : null; - + if (decorators.Count > 0) { var bindingContext = CompilationContext.Args.BindingContext; var writer = CompilationContext.Args.EncodedWriter; - + var parentContext = bindingContext; if (pex.Argument != null || partialBlockTemplate != null) { var value = pex.Argument != null ? Arg(FunctionBuilder.Reduce(pex.Argument, CompilationContext, out _)) : bindingContext.Property(o => o.Value); - + var partialTemplate = Arg(partialBlockTemplate); bindingContext = bindingContext.Call(o => o.CreateChildContext(value, partialTemplate)); } var partialName = Cast(pex.PartialName); var configuration = Arg(CompilationContext.Configuration); + var indent = Arg(pex.Indent); var templateDelegate = FunctionBuilder.Compile( new [] { Call(() => - InvokePartialWithFallback(partialName, bindingContext, writer, (ICompiledHandlebarsConfiguration) configuration) + InvokePartialWithFallback(partialName, bindingContext, writer, (ICompiledHandlebarsConfiguration) configuration, indent) ).Expression - }, - CompilationContext, + }, + CompilationContext, out _ ); @@ -67,22 +71,23 @@ out _ { var bindingContext = CompilationContext.Args.BindingContext; var writer = CompilationContext.Args.EncodedWriter; - + if (pex.Argument != null || partialBlockTemplate != null) { var value = pex.Argument != null ? Arg(FunctionBuilder.Reduce(pex.Argument, CompilationContext, out _)) : bindingContext.Property(o => o.Value); - + var partialTemplate = Arg(partialBlockTemplate); bindingContext = bindingContext.Call(o => o.CreateChildContext(value, partialTemplate)); } var partialName = Cast(pex.PartialName); var configuration = Arg(CompilationContext.Configuration); - + var indent = Arg(pex.Indent); + return Call(() => - InvokePartialWithFallback(partialName, bindingContext, writer, (ICompiledHandlebarsConfiguration) configuration) + InvokePartialWithFallback(partialName, bindingContext, writer, (ICompiledHandlebarsConfiguration) configuration, indent) ); } } @@ -91,27 +96,76 @@ private static void InvokePartialWithFallback( string partialName, BindingContext context, EncodedTextWriter writer, - ICompiledHandlebarsConfiguration configuration) + ICompiledHandlebarsConfiguration configuration, + string indent = null) { partialName = partialName != null ? ChainSegment.Create(partialName).TrimmedValue : null; - if (InvokePartial(partialName, context, writer, configuration)) return; + if (InvokePartial(partialName, context, writer, configuration, indent)) return; if (context.PartialBlockTemplate == null) { if (configuration.MissingPartialTemplateHandler == null) throw new HandlebarsRuntimeException($"Referenced partial name {partialName} could not be resolved"); - + configuration.MissingPartialTemplateHandler.Handle(configuration, partialName, writer); return; } - context.PartialBlockTemplate(writer, context); + if (!string.IsNullOrEmpty(indent)) + { + using var innerWriter = ReusableStringWriter.Get(writer.UnderlyingWriter.FormatProvider); + using var textWriter = new EncodedTextWriter(innerWriter, configuration.TextEncoder, FormatterProvider.Current, true); + context.PartialBlockTemplate(textWriter, context); + WriteWithIndent(writer, innerWriter.ToString(), indent); + } + else + { + context.PartialBlockTemplate(writer, context); + } + } + + /// + /// Writes to , prepending + /// 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. + /// + private static void WriteWithIndent(EncodedTextWriter writer, string content, string indent) + { + if (string.IsNullOrEmpty(content)) + { + 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) + { + var newlinePos = normalised.IndexOf('\n', pos); + if (newlinePos < 0) + { + // No more newlines — write indent + rest and stop + writer.Write(indent, false); + writer.Write(normalised.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); + pos = newlinePos + 1; + } } private static bool InvokePartial( string partialName, BindingContext context, EncodedTextWriter writer, - ICompiledHandlebarsConfiguration configuration) + ICompiledHandlebarsConfiguration configuration, + string indent = null) { if (partialName.Equals(SpecialPartialBlockName)) { @@ -124,7 +178,17 @@ private static bool InvokePartial( try { context.PartialBlockTemplate = context.ParentContext.PartialBlockTemplate; - partialBlockTemplate(writer, context); + if (!string.IsNullOrEmpty(indent)) + { + using var innerWriter = ReusableStringWriter.Get(writer.UnderlyingWriter.FormatProvider); + using var textWriter = new EncodedTextWriter(innerWriter, configuration.TextEncoder, FormatterProvider.Current, true); + partialBlockTemplate(textWriter, context); + WriteWithIndent(writer, innerWriter.ToString(), indent); + } + else + { + partialBlockTemplate(writer, context); + } } finally { @@ -145,7 +209,17 @@ void IncreaseDepth() IncreaseDepth(); try { - partial(writer, context); + if (!string.IsNullOrEmpty(indent)) + { + using var innerWriter = ReusableStringWriter.Get(writer.UnderlyingWriter.FormatProvider); + using var textWriter = new EncodedTextWriter(innerWriter, configuration.TextEncoder, FormatterProvider.Current, true); + partial(textWriter, context); + WriteWithIndent(writer, innerWriter.ToString(), indent); + } + else + { + partial(writer, context); + } } finally { @@ -153,12 +227,12 @@ void IncreaseDepth() } return true; } - + // Partial is not found, so call the resolver and attempt to load it. if (!configuration.RegisteredTemplates.ContainsKey(partialName)) { var handlebars = Handlebars.Create(configuration); - if (configuration.PartialTemplateResolver == null + if (configuration.PartialTemplateResolver == null || !configuration.PartialTemplateResolver.TryRegisterPartial(handlebars, partialName, (string) context.Extensions.Optional("templatePath"))) { // Template not found. @@ -169,8 +243,21 @@ void IncreaseDepth() IncreaseDepth(); try { - using var textWriter = writer.CreateWrapper(); - configuration.RegisteredTemplates[partialName](textWriter, context); + if (!string.IsNullOrEmpty(indent)) + { + using var innerWriter = ReusableStringWriter.Get(writer.UnderlyingWriter.FormatProvider); + using (var encodedInner = new EncodedTextWriter(innerWriter, configuration.TextEncoder, FormatterProvider.Current, true)) + { + using var textWriter = encodedInner.CreateWrapper(); + configuration.RegisteredTemplates[partialName](textWriter, context); + } + WriteWithIndent(writer, innerWriter.ToString(), indent); + } + else + { + using var textWriter = writer.CreateWrapper(); + configuration.RegisteredTemplates[partialName](textWriter, context); + } return true; } catch (Exception exception) From 3a009c274db7f155443642cde7bff193d6e571fc Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Sat, 20 Jun 2026 14:01:17 -0400 Subject: [PATCH 2/2] fix: normalize CRLF to LF in StaticConverter for platform-independent output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Templates with \r\n line endings (Windows CRLF source files or explicit \r\n in string literals) now always produce \n in rendered output. Previously WriteWithIndent normalized the captured partial content but the surrounding static template text was written verbatim, producing mixed \r\n/\n output on Windows and breaking tests that expected \n. The root was that normalization happened too late and only in one path. Fix: normalize \r\n → \n once in StaticConverter, the single point where all StaticToken values become StaticExpression nodes. This runs after WhitespaceRemover (which already uses \r?\n in all its regexes) and ensures every static text segment emitted to the writer uses \n regardless of the source file's line endings. Update affected tests to use \n in both template strings and expected values: ViewEngineTests, ReadmeTests, ComplexIntegrationTests, BasicIntegrationTests (split separator), and IssueTests. Co-Authored-By: Claude Sonnet 4.6 --- .../Handlebars.Test/BasicIntegrationTests.cs | 4 +- .../ComplexIntegrationTests.cs | 12 ++-- source/Handlebars.Test/IssueTests.cs | 4 +- source/Handlebars.Test/ReadmeTests.cs | 8 +-- .../ViewEngine/ViewEngineTests.cs | 58 +++++++++---------- .../Lexer/Converter/StaticConverter.cs | 6 +- 6 files changed, 45 insertions(+), 47 deletions(-) diff --git a/source/Handlebars.Test/BasicIntegrationTests.cs b/source/Handlebars.Test/BasicIntegrationTests.cs index e6b9b659..bb2882c2 100644 --- a/source/Handlebars.Test/BasicIntegrationTests.cs +++ b/source/Handlebars.Test/BasicIntegrationTests.cs @@ -437,7 +437,7 @@ public void PathRelativeBinding(IHandlebars handlebars) }; var result = handlebarsTemplate(data); - var actual = string.Join(" ", result.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries).Select(o => o.Trim(' '))); + var actual = string.Join(" ", result.Split(new[] { "\n" }, StringSplitOptions.RemoveEmptyEntries).Select(o => o.Trim())); Assert.Equal("Garry Finch gazraa Karen Finch photobasics", actual); } @@ -501,7 +501,7 @@ public void PathRelativeBinding_WithDefaultValue(IHandlebars handlebars) }; var result = handlebarsTemplate(data); - var actual = string.Join(" ", result.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries).Select(o => o.Trim(' '))); + var actual = string.Join(" ", result.Split(new[] { "\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 2f365b86..bed68944 100644 --- a/source/Handlebars.Test/ComplexIntegrationTests.cs +++ b/source/Handlebars.Test/ComplexIntegrationTests.cs @@ -56,14 +56,10 @@ public void DeepIf() var resultTrueFalse = template(trueFalse); var resultFalseTrue = template(falseTrue); var resultFalseFalse = template(falseFalse); - Assert.Equal(@"a is true -", resultTrueTrue); - Assert.Equal(@"a is false -", resultTrueFalse); - Assert.Equal(@"b is true -", resultFalseTrue); - Assert.Equal(@"b is false -", resultFalseFalse); + 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); } [Fact] diff --git a/source/Handlebars.Test/IssueTests.cs b/source/Handlebars.Test/IssueTests.cs index 6a5c56f7..ae57657c 100644 --- a/source/Handlebars.Test/IssueTests.cs +++ b/source/Handlebars.Test/IssueTests.cs @@ -225,9 +225,7 @@ public void RenderingWithUnusedPartial() var transformed = navTemplate(context).Trim(); - Assert.Equal(@"
-
Menu Item: Getting Started
-
", transformed); + Assert.Equal("
\n
Menu Item: Getting Started
\n
", transformed); } // issue: https://github.com/Handlebars-Net/Handlebars.Net/issues/394 diff --git a/source/Handlebars.Test/ReadmeTests.cs b/source/Handlebars.Test/ReadmeTests.cs index 3b71e080..c41d9a3c 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}}.\r\n{{/each}}"; + var template = "{{#each this}}The animal, {{@key}}, {{#StringEqualityBlockHelper @value 'dog'}}is a dog{{else}}is not a dog{{/StringEqualityBlockHelper}}.\n{{/each}}"; var compiledTemplate = handlebars.Compile(template); string templateOutput = compiledTemplate(animals); Assert.Equal( - "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", + "The animal, Fluffy, is not a dog.\n" + + "The animal, Fido, is a dog.\n" + + "The animal, Chewy, is not a dog.\n", templateOutput ); } diff --git a/source/Handlebars.Test/ViewEngine/ViewEngineTests.cs b/source/Handlebars.Test/ViewEngine/ViewEngineTests.cs index 40182e12..a7bdebc1 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\r\n{{{body}}}\r\nlayout end"}, + {"views\\somelayout.hbs", "layout start\n{{{body}}}\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\r\nThis is the body\r\nlayout end", output); + Assert.Equal("layout start\nThis is the body\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\r\n{{{body}}}\r\nlayout end"}, + {"views\\somelayout.hbs", "layout start\n{{{body}}}\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\r\nThis is the body\r\nlayout end", output); + Assert.Equal("layout start\nThis is the body\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\r\n{{{body}}}\r\nlayout end"}, + {"somelayout.hbs", "layout start\n{{{body}}}\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\r\nThis is the body\r\nlayout end", output); + Assert.Equal("layout start\nThis is the body\nlayout end", output); } [Fact] @@ -81,9 +81,9 @@ public void CanRenderInlineBlocks() var files = new FakeFileSystem() { //Given a layout in a subfolder - { "partials/layout.hbs", "
\r\n{{> nav}}\r\n
\r\n
\r\n{{> content}}\r\n
"}, + { "partials/layout.hbs", "
\n{{> nav}}\n
\n
\n{{> content}}\n
"}, - { "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}}"} + { "template.hbs", "{{#> layout}}\n{{#*inline \"nav\"}}\n{{Text}}\n{{/inline}}\n{{#*inline \"content\"}}\nMy Content\n{{/inline}}\n{{/layout}}"} }; //When a viewengine renders that view @@ -95,7 +95,7 @@ public void CanRenderInlineBlocks() }); //Then the correct output should be rendered - Assert.Equal("
\r\n<My Nav>\r\n
\r\n
\r\nMy Content\r\n
", output); + Assert.Equal("
\n<My Nav>\n
\n
\nMy Content\n
", output); } [Fact] @@ -104,7 +104,7 @@ public void CanLoadAViewWithALayoutWithAVariable() //Given a layout in the root var files = new FakeFileSystem() { - {"somelayout.hbs", "{{var1}} start\r\n{{{body}}}\r\n{{var1}} end"}, + {"somelayout.hbs", "{{var1}} start\n{{{body}}}\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\r\nThis is the body\r\nlayout end", output); + Assert.Equal("layout start\nThis is the body\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\r\n{{{body}}}\r\n{{var1}} end"}, + {"somelayout.hbs", "{{var1}} start\n{{{body}}}\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\r\nThis is the body\r\nlayout end", output); + Assert.Equal("layout start\nThis is the body\nlayout end", output); } [Fact] @@ -162,8 +162,8 @@ public void CanIgnoreCommentsContainingHtml() { var files = new FakeFileSystem() { - { "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--}}" }, + { "views\\layout.hbs", "Start\n{{{body}}}\nEnd" }, + { "views\\someview.hbs", "{{!< layout}}\n\nTemplate\n{{!--\n
Commented out HTML
\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\r\n\r\nTemplate\r\n\r\nEnd", output); + Assert.Equal("Start\n\nTemplate\n\nEnd", output); } [Fact] @@ -179,8 +179,8 @@ public void CanUseDictionaryModelInLayout() { var files = new FakeFileSystem { - { "views\\layout.hbs", "Layout: {{property}}\r\n{{{body}}}" }, - { "views\\someview.hbs", "{{!< layout}}\r\n\r\nBody: {{property}}" }, + { "views\\layout.hbs", "Layout: {{property}}\n{{{body}}}" }, + { "views\\someview.hbs", "{{!< layout}}\n\nBody: {{property}}" }, }; var handlebarsConfiguration = new HandlebarsConfiguration { FileSystem = files }; @@ -193,7 +193,7 @@ public void CanUseDictionaryModelInLayout() } ); - Assert.Equal("Layout: Foo\r\n\r\nBody: Foo", output); + Assert.Equal("Layout: Foo\n\nBody: Foo", output); } [Fact] @@ -201,8 +201,8 @@ public void CanUseDynamicModelInLayout() { var files = new FakeFileSystem { - { "views\\layout.hbs", "Layout: {{property}}\r\n{{{body}}}" }, - { "views\\someview.hbs", "{{!< layout}}\r\n\r\nBody: {{property}}" }, + { "views\\layout.hbs", "Layout: {{property}}\n{{{body}}}" }, + { "views\\someview.hbs", "{{!< layout}}\n\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\r\n\r\nBody: Foo", output); + Assert.Equal("Layout: Foo\n\nBody: Foo", output); } [Fact] @@ -219,9 +219,9 @@ public void CanBindToModelInNestedLayout() { var files = new FakeFileSystem { - { "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}}" }, + { "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}}" }, }; var handlebarsConfiguration = new HandlebarsConfiguration { FileSystem = files }; @@ -234,7 +234,7 @@ public void CanBindToModelInNestedLayout() } ); - Assert.Equal("Parent layout: Foo\r\nLayout: Foo\r\n\r\nBody: Foo", output); + Assert.Equal("Parent layout: Foo\nLayout: Foo\n\nBody: Foo", output); } [Fact] @@ -242,8 +242,8 @@ public void CanUseNullModelInLayout() { var files = new FakeFileSystem { - { "views\\layout.hbs", "Layout: {{property}}\r\n{{{body}}}" }, - { "views\\someview.hbs", "{{!< layout}}\r\n\r\nBody: {{property}}" }, + { "views\\layout.hbs", "Layout: {{property}}\n{{{body}}}" }, + { "views\\someview.hbs", "{{!< layout}}\n\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: \r\n\r\nBody: ", output); + Assert.Equal("Layout: \n\nBody: ", output); } [Fact] diff --git a/source/Handlebars/Compiler/Lexer/Converter/StaticConverter.cs b/source/Handlebars/Compiler/Lexer/Converter/StaticConverter.cs index af015244..ffc677b6 100644 --- a/source/Handlebars/Compiler/Lexer/Converter/StaticConverter.cs +++ b/source/Handlebars/Compiler/Lexer/Converter/StaticConverter.cs @@ -30,7 +30,11 @@ public override IEnumerable ConvertTokens(IEnumerable sequence) if (staticToken.Value != string.Empty) { - yield return HandlebarsExpression.Static(staticToken.Value); + // 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); } } }