From 153bb8060062e9cefa781db9e716426531e8f2fc Mon Sep 17 00:00:00 2001 From: Matthew Abbott Date: Fri, 8 Nov 2024 16:46:19 +0000 Subject: [PATCH] fix: Refactor partial template rendering with TextWriter, fixes #20 Updated README.md for better readability. Refactored HandlebarPartialTemplate delegate to use TextWriter. Added RunPartial method in Handlebars class for TextWriter support. Updated HandlebarsService to implement new RunPartial method. Modified IHandlebarsService interface to include RunPartial. Updated PartialBlockRenderer to use new RunPartial method. Added Issue20 test class to verify correct block node rendering. --- README.md | 284 +++++++++--------- libs/FuManchu/HandlebarPartialTemplate.cs | 3 +- libs/FuManchu/Handlebars.cs | 12 + libs/FuManchu/HandlebarsService.cs | 32 +- libs/FuManchu/IHandlebarsService.cs | 8 + .../FuManchu/Renderer/PartialBlockRenderer.cs | 6 +- tests/FuManchu.Tests/Issues/Issue20.cs | 32 ++ 7 files changed, 219 insertions(+), 158 deletions(-) create mode 100644 tests/FuManchu.Tests/Issues/Issue20.cs diff --git a/README.md b/README.md index 0f5fbdf..50b3ed4 100644 --- a/README.md +++ b/README.md @@ -9,20 +9,20 @@ Quick Start First thing, install FuManchu from Nuget: - Install-Package FuManchu + Install-Package FuManchu Next, add a namespace using: - using FuManchu; + using FuManchu; Then, you're good to go: - Handlebars.Compile("", "Hello {{world}}!"); - string result = Handlebars.Run("", new { world = "World" }); + Handlebars.Compile("", "Hello {{world}}!"); + string result = Handlebars.Run("", new { world = "World" }); There is also a shorthand: - string result = Handlebars.CompileAndRun("", "Hello {{world}}!", new { world = "World" }); + string result = Handlebars.CompileAndRun("", "Hello {{world}}!", new { world = "World" }); Documentation ------------- @@ -35,16 +35,16 @@ The static `Handlebars` class provides a singleton instance of the `IHandlebarsS Let's define a template and pass it to the service: - string template = "Hello {{name}}"; - var templateFunc = Handlebars.Compile("name", template); + string template = "Hello {{name}}"; + var templateFunc = Handlebars.Compile("name", template); The `Compile` function returns a `HandlebarTemplate` delegate which you can call, passing in your model to return you're result. - string result = templateFunc(new { name = "Matt" }); + string result = templateFunc(new { name = "Matt" }); This is equivalent to the following: - string result = Handlebars.Run("name", new { name = "Matt" }); + string result = Handlebars.Run("name", new { name = "Matt" }); When you call `Compile` your template is parsed and can be executed multiple times with new data. @@ -56,47 +56,47 @@ Block tags are define using the `{{#tag}}{{/tag}}` syntax. There are three types You can use the `if`, `elseif`, `else` tags to provide conditional logic to your templates. - {{#if value}} - True - {{/if}} + {{#if value}} + True + {{/if}} - {{#if value}} - True - {{else}} - False - {{/if}} + {{#if value}} + True + {{else}} + False + {{/if}} - {{#if value1}} - Value 1 - {{#elseif value2}} - Value 2 - {{else}} - None - {{/if}} + {{#if value1}} + Value 1 + {{#elseif value2}} + Value 2 + {{else}} + None + {{/if}} We resolve the truthfulness of values using the same semantics of *truthy/falsely* logic of JavaScript, therefore, values that are `null`, or the number zero, empty enumerables and empty strings, are all considered false. Everything else is considered true. - The `{{else}}` tag can be also be written as `{{^}}` in your templates +The `{{else}}` tag can be also be written as `{{^}}` in your templates - {{#if value}} - True - {{^}} - False - {{/if}} + {{#if value}} + True + {{^}} + False + {{/if}} **Built-ins: unless, else** `unless` is the negated version of `if`. It will allow you to assume truthful values, and present output if the value is falsey. - {{#unless value}} - Value is not true! - {{/unless}} + {{#unless value}} + Value is not true! + {{/unless}} - {{#unless value}} - Value is not true! - {{else}} - Value was true! - {{/unless}} + {{#unless value}} + Value is not true! + {{else}} + Value was true! + {{/unless}} The `{{else}}` tag can be also be written as `{{^}}` in your templates @@ -104,56 +104,56 @@ We resolve the truthfulness of values using the same semantics of *truthy/falsel The `each` tag allows you to iterate over enumerable objects. -
    - {{#each items}} -
  • {{value}}
  • - {{/each}} -
+
    + {{#each items}} +
  • {{value}}
  • + {{/each}} +
The `each` block tag creates a scope around the target model (therefore each child of `items`, above), to allow you to use the `{{value}}` expressions, where `value` is a property of a child of `items`. A more concrete example could be: - var people = new [] { new Person() { Name = "Matt" }, new Person() { Name = "Stuart" } }; - string template = "
    {{#each this}}
  • {{Name}}
  • {{/each}}
"; + var people = new [] { new Person() { Name = "Matt" }, new Person() { Name = "Stuart" } }; + string template = "
    {{#each this}}
  • {{Name}}
  • {{/each}}
"; - // result:
  • Matt
  • Stuart
- string result = Handlebars.CompileAndRun("name", template, people); + // result:
  • Matt
  • Stuart
+ string result = Handlebars.CompileAndRun("name", template, people); The `each` tag also supports the variables `@index`, `@first`, `@last`. If you enumerate over an `IDictionary`, you also have access to `@key`. - var people = new [] { new Person() { Name = "Matt" }, new Person() { Name = "Stuart" } }; - string template = "
    {{#each this}}
  • {{@index}}: {{Name}}
  • {{/each}}
"; + var people = new [] { new Person() { Name = "Matt" }, new Person() { Name = "Stuart" } }; + string template = "
    {{#each this}}
  • {{@index}}: {{Name}}
  • {{/each}}
"; - // result:
  • 0: Matt
  • 1: Stuart
- string result = Handlebars.CompileAndRun("name", template, people); + // result:
  • 0: Matt
  • 1: Stuart
+ string result = Handlebars.CompileAndRun("name", template, people); `@index` tracks the current index of the item in the enumerable. `@first` and `@last` represent true/false values as to whether you are enumerating the first or last value in an enumerable. `@key` represents the dictionary key. You can provided a `{{else}}` switch to provide an output when the enumerable is empty: - {{#each items}} - Item {{@index}} - {{else}} - No items! - {{/each}} + {{#each items}} + Item {{@index}} + {{else}} + No items! + {{/each}} The `{{else}}` tag can be also be written as `{{^}}` in your templates **Built-ins: with, else** The `with` block creates a scope around the parameter argument. - var model = new { person = new { forename = "Matt", surname = "Abbott" } }; + var model = new { person = new { forename = "Matt", surname = "Abbott" } }; - {{#with person}} - Name: {{forename}} {{surname}} - {{/with}} + {{#with person}} + Name: {{forename}} {{surname}} + {{/with}} Again, as with `each`, you can use the `{{else}}` switch to provide an output if the value passed into the `with` tag is falsey: - {{#with person}} - Name: {{forename}} {{surname}} - {{else}} - Nobody :-( - {{/with}} + {{#with person}} + Name: {{forename}} {{surname}} + {{else}} + Nobody :-( + {{/with}} The `{{else}}` tag can be also be written as `{{^}}` in your templates @@ -162,75 +162,75 @@ Implicit Block Tags You can use shorthand `{{#tag}}{{/tag}}` where "tag" is the name of a property on your model, instead of using full tags, e.g. - var model = new { person = new { forename = "Matt", surname = "Abbott" } }; + var model = new { person = new { forename = "Matt", surname = "Abbott" } }; - {{#person}} - Name: {{forename}} {{surname}} - {{/person}} + {{#person}} + Name: {{forename}} {{surname}} + {{/person}} The above is equivalent to: - {{#if person}} - {{#with person}} - Name: {{forename}} {{surname}} - {{/with}} - {{/if}} + {{#if person}} + {{#with person}} + Name: {{forename}} {{surname}} + {{/with}} + {{/if}} If you're property also happens to be an enumerable, then the implicit block tag works like `each`: - {{#people}} -
  • {{@index}}: {{forename}} {{surname}}
  • - {{/people}} + {{#people}} +
  • {{@index}}: {{forename}} {{surname}}
  • + {{/people}} Inverted Block Tags - Inverted block tags follow the rules for implicit block tags, but are used to provided content when the tag expression resolves to *falsey*. - {{^power}} - You have no power here! - {{/power}} + {{^power}} + You have no power here! + {{/power}} Block Helpers - You can register custom helpers using the `Handlebars` service. You need to register a helper ahead of time, which you can then call from your template. - Handlebars.RegisterHelper("list", options => { - var enumerable = options.Data as IEnumerable ?? new[] { (object)options.Data }; + Handlebars.RegisterHelper("list", options => { + var enumerable = options.Data as IEnumerable ?? new[] { (object)options.Data }; - return "
      " - + string.Join("", enumerable.OfType().Select(options.Fn)) - + ""; - }); + return "
        " + + string.Join("", enumerable.OfType().Select(options.Fn)) + + ""; + }); For block helpers, the `options` parameter provides access to the content of your block helper, therefore given the following usage: - {{#list people}} -
      • {{forename}} {{surname}}
      • - {{/list}} + {{#list people}} +
      • {{forename}} {{surname}}
      • + {{/list}} When calling `options.Fn` (or `options.Render`), the content of your custom helper block is rendered, scoped to the value passed to the render function. **Arguments and Hash parameters** You can pass additional information to your helpers using your helper block, e.g.: - var model = new { - people = new List(), - message = "Hello World" - }; + var model = new { + people = new List(), + message = "Hello World" + }; - Handlebars.RegisterHelper("list", options => { - var people = options.Data as List; - string message = options.Arguments[1] as string; - string cssClass = options.Hash["class"]; + Handlebars.RegisterHelper("list", options => { + var people = options.Data as List; + string message = options.Arguments[1] as string; + string cssClass = options.Hash["class"]; - return "
          " - + "
        • " + message + "
        • " - + string.Join("", enumerable.OfType().Select(options.Fn)) - + ""; - }); + return "
            " + + "
          • " + message + "
          • " + + string.Join("", enumerable.OfType().Select(options.Fn)) + + ""; + }); - {{#list people message class="nav nav-pills"}}...{{/list}} + {{#list people message class="nav nav-pills"}}...{{/list}} An instance of `HelperOptions` is passed as the value of `options`, which provides the input arguments (`people` and `message`) and a has (`IDictionary`, `class="nav nav-pills"`) which are accessible. `options.Data` provides a shorthand access to `options.Arguments[0]` as `dynamic`, and `options.Hash` provides readonly access to `options.Parameters`. Both `options.Data` and `options.Hash` and provided for API compatability with HandlebarsJS. @@ -238,83 +238,83 @@ Expression Tags - Expression tags are simple `{{value}}` type tags that allow you to render content into your templates, by binding values from your input models (or 'contexts' in HandlebarsJS speak). There are a variety of ways you can call these properties, so given: - var model = new { - person = new { - forename = "Matt", - surname = "Abbott", - age = 30, - job = new { - title = "Developer" - } - } - }; - - {{person.forename}} - {{./person.forename}} - {{this.person.forename}} - {{this/person/forename}} - {{@root.person.forename}} - - And also within scopes: - - {{#with person}} - {{#with job}} - {{../forename}} {{../surname}} - {{/with}} + var model = new { + person = new { + forename = "Matt", + surname = "Abbott", + age = 30, + job = new { + title = "Developer" + } + } + }; + + {{person.forename}} + {{./person.forename}} + {{this.person.forename}} + {{this/person/forename}} + {{@root.person.forename}} + + And also within scopes: + + {{#with person}} + {{#with job}} + {{../forename}} {{../surname}} {{/with}} + {{/with}} You can use these same 'context paths' as arguments and hash parameters in your block tags too: - {{#if ../people}} - {{#with @root.people}} + {{#if ../people}} + {{#with @root.people}} And with your custom helpers too: - {{#list people class=@root.cssClass}} + {{#list people class=@root.cssClass}} Expression Helpers - Just like Block Helpers, you can create Expression Helpers too, using the same function as before: - Handlebars.RegisterHelper("name", options => { - return string.Format("{0} {1}", options.Data.forename, options.data.surname); - }); + Handlebars.RegisterHelper("name", options => { + return string.Format("{0} {1}", options.Data.forename, options.data.surname); + }); Called using the expression syntax, this time with arguments: - var model = new { forename = "Matt", surname = "Abbott" }; + var model = new { forename = "Matt", surname = "Abbott" }; - {{name this}} + {{name this}} Partial Templates - Partial templates allow you to break your Handlebars templates into discreet units. To register a partial, you call the `Handlebars.RegisterPartial` method - Handlebars.RegisterPartial("name", "{{forename}} {{surname}}"); + Handlebars.RegisterPartial("name", "{{forename}} {{surname}}"); You can then call your partial from your template: - var model = new { forename = "Matt", surname = "Abbott" }; + var model = new { forename = "Matt", surname = "Abbott" }; - {{>name}} + {{>name}} You can override the model passed to your template, by providing an additional arguments: - var model = new { person = new { forename = "Matt", surname = "Abbott" } }; + var model = new { person = new { forename = "Matt", surname = "Abbott" } }; - {{>name person}} + {{>name person}} You can alternatively pass through parameters, if you need to provide a parameter-like experience: - var model = new { person = new { forename = "Matt", surname = "Abbott" } }; + var model = new { person = new { forename = "Matt", surname = "Abbott" } }; - {{>name firstName=person.forename lastName=person.surname}} + {{>name firstName=person.forename lastName=person.surname}} Text Encoding - Like HandlebarsJS, FuManchu encodes values by default, therefore all calls, such as `{{forename}}` etc, will be encoded. If you need to render the raw value, you can use the triple-brace syntax: - {{{forename}}} {{{surname}}} + {{{forename}}} {{{surname}}} Singleton vs Instance Services - diff --git a/libs/FuManchu/HandlebarPartialTemplate.cs b/libs/FuManchu/HandlebarPartialTemplate.cs index 6a1c6dc..79a7c9c 100644 --- a/libs/FuManchu/HandlebarPartialTemplate.cs +++ b/libs/FuManchu/HandlebarPartialTemplate.cs @@ -9,5 +9,6 @@ namespace FuManchu; /// Represents a compiled Handlebars partial template. /// /// The parent render context. +/// The output text writer. /// The template result. -public delegate string HandlebarPartialTemplate(RenderContext context); +public delegate void HandlebarPartialTemplate(RenderContext context, TextWriter writer); diff --git a/libs/FuManchu/Handlebars.cs b/libs/FuManchu/Handlebars.cs index adee768..c284204 100644 --- a/libs/FuManchu/Handlebars.cs +++ b/libs/FuManchu/Handlebars.cs @@ -104,4 +104,16 @@ public static string RunPartial(string name, RenderContext context) { return _handlebarsService.Value.RunPartial(name, context); } + + /// + /// Runs a pre-compiled partial template, outputing against the given text writer. + /// + /// The name of the partial template. + /// The render context. + /// The text writer + /// The template result. + public static void RunPartial(string name, RenderContext context, TextWriter writer) + { + _handlebarsService.Value.RunPartial(name, context, writer); + } } diff --git a/libs/FuManchu/HandlebarsService.cs b/libs/FuManchu/HandlebarsService.cs index c45a36e..aad3600 100644 --- a/libs/FuManchu/HandlebarsService.cs +++ b/libs/FuManchu/HandlebarsService.cs @@ -111,20 +111,15 @@ public HandlebarPartialTemplate CompilePartial(string template) var whitespace = new WhiteSpaceCollapsingParserVisitor(); document.Accept(whitespace); - return (context) => + return (context, writer) => { - using (var writer = new StringWriter()) + var render = new RenderingParserVisitor(writer, context, ModelMetadataProvider ?? new DefaultModelMetadataProvider()) { - var render = new RenderingParserVisitor(writer, context, ModelMetadataProvider ?? new DefaultModelMetadataProvider()) - { - Service = this - }; + Service = this + }; - // Render the document. - document.Accept(render); - - return writer.GetStringBuilder().ToString(); - } + // Render the document. + document.Accept(render); }; } @@ -206,11 +201,24 @@ public string Run( /// public string RunPartial(string name, RenderContext context) + { + using (var writer = new StringWriter()) + { + RunPartial(name, context, writer); + + return writer.GetStringBuilder().ToString(); + } + } + + /// + public void RunPartial(string name, RenderContext context, TextWriter writer) { HandlebarPartialTemplate func; if (_partials.TryGetValue(name, out func)) { - return func(context); + func(context, writer); + + return; } throw new ArgumentException("No partial template called '" + name + "' has been compiled."); diff --git a/libs/FuManchu/IHandlebarsService.cs b/libs/FuManchu/IHandlebarsService.cs index fc1e994..364baa6 100644 --- a/libs/FuManchu/IHandlebarsService.cs +++ b/libs/FuManchu/IHandlebarsService.cs @@ -71,6 +71,14 @@ public interface IHandlebarsService /// The template result. string RunPartial(string name, RenderContext context); + /// + /// Runs a pre-compiled partial template, outputing against the given text writer. + /// + /// The name of the partial template. + /// The render context. + /// The text writer + void RunPartial(string name, RenderContext context, TextWriter writer); + /// /// Runs a registered helper. /// diff --git a/libs/FuManchu/Renderer/PartialBlockRenderer.cs b/libs/FuManchu/Renderer/PartialBlockRenderer.cs index 404329d..0778572 100644 --- a/libs/FuManchu/Renderer/PartialBlockRenderer.cs +++ b/libs/FuManchu/Renderer/PartialBlockRenderer.cs @@ -38,19 +38,19 @@ protected override void Render(Block block, Arguments? arguments, Map? maps, Ren { using (var scope = context.BeginScope(model)) { - Write(scope.ScopeContext, writer, new SafeString(context.Service.RunPartial(name, scope.ScopeContext))); + context.Service.RunPartial(name, scope.ScopeContext, writer); } } else if (maps is { Count: > 0 }) { using (var scope = context.BeginScope(maps)) { - Write(scope.ScopeContext, writer, new SafeString(context.Service.RunPartial(name, scope.ScopeContext))); + context.Service.RunPartial(name, scope.ScopeContext, writer); } } else { - Write(context, writer, new SafeString(context.Service.RunPartial(name, context))); + context.Service.RunPartial(name, context, writer); } } } diff --git a/tests/FuManchu.Tests/Issues/Issue20.cs b/tests/FuManchu.Tests/Issues/Issue20.cs new file mode 100644 index 0000000..973721b --- /dev/null +++ b/tests/FuManchu.Tests/Issues/Issue20.cs @@ -0,0 +1,32 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace FuManchu.Tests.Issues; + +using Xunit; + +public class Issue20 +{ + /// + /// NRF executing basic IS template + /// https://github.com/Antaris/FuManchu/issues/15 + /// + [Fact] + public void BlockNodeContent_InPartial_ShouldNot_BeRenderedOutOfOrder_InParentTemplate() + { + // Arrange + string partialTemplate = @" + {{#if title}}{{title}}{{/if}} +"; + + string template = @"{{>partial title=""Hello World""}}"; + Handlebars.RegisterPartial("partial", partialTemplate); + + // Act + string content = Handlebars.CompileAndRun("test", template, new { }); + + // Assert + Assert.NotNull(content); + Assert.Equal("\r\n\tHello World\r\n", content); + } +}