Blistering-fast Handlebars.js templates in your .NET application.
Handlebars.js is an extension to the Mustache templating language created by Chris Wanstrath. Handlebars.js and Mustache are both logicless templating languages that keep the view and the code separated like we all know they should be.
Check out the handlebars.js documentation for how to write Handlebars templates.
Handlebars.Net doesn't use a scripting engine to run a Javascript library - it compiles Handlebars templates directly to IL bytecode. It also mimics the JS library's API as closely as possible.
dotnet add package Handlebars.Net
The following projects are extending Handlebars.Net:
- Handlebars.Net.Extension.Json (Adds
System.Text.Json.JsonDocumentsupport) - Handlebars.Net.Extension.NewtonsoftJson (Adds
Newtonsoft.Jsonsupport) - Handlebars.Net.Helpers (Additional helpers in the categories: 'Constants', 'Enumerable', 'Math', 'Regex', 'String', 'DateTime', 'Url' , 'DynamicLinq', 'Humanizer', 'Json', 'Random', 'Xeger' and 'XPath'.)
stringsource=@"<div class=""entry""> <h1>{{title}}</h1> <div class=""body""> {{body}} </div></div>";vartemplate=Handlebars.Compile(source);vardata=new{title="My new post",body="This is my first post!"};varresult=template(data);/* Would render:<div class="entry"> <h1>My New Post</h1> <div class="body"> This is my first post! </div></div>*/Handlebars.Net supports indexing and iterating true multi-dimensional (rank > 1) .NET arrays, such as int[,] or int[,,], in addition to jagged arrays (int[][]) and other list/enumerable types.
A multi-dimensional array is indexed and iterated one dimension at a time, so a 2D array is treated as an array of rows and a 3D array as an array of 2D "slabs", and so on:
stringsource="{{#each grid}}[{{#each this}}{{this}}{{/each}}]{{/each}}";vartemplate=Handlebars.Compile(source);vardata=new{grid=newint[,]{{1,2,3},{4,5,6}}};varresult=template(data);/* Would render:[123][456]*/Individual elements can also be reached directly by chaining index segments, one per dimension:
stringsource="{{ grid.[1].[2] }}";// grid[1, 2]vartemplate=Handlebars.Compile(source);vardata=new{grid=newint[,]{{1,2,3},{4,5,6}}};varresult=template(data);// "6"This is a C#-specific capability, since JavaScript/Handlebars.js has no equivalent to true multi-dimensional arrays.
stringsource=@"<h2>Names</h2>{{#names}} {{> user}}{{/names}}";stringpartialSource=@"<strong>{{name}}</strong>";Handlebars.RegisterTemplate("user",partialSource);vartemplate=Handlebars.Compile(source);vardata=new{names=new[]{new{name="Karen"},new{name="Jon"}}};varresult=template(data);/* Would render:<h2>Names</h2> <strong>Karen</strong> <strong>Jon</strong>*/Partials, including inline partials declared with {{#*inline}}, accept hash arguments at the call site. The hash values are merged into the partial's context alongside any properties inherited from the calling context:
stringsource=@"{{#*inline ""user""}}<strong>{{name}}</strong>{{/inline}}{{> user name=""Karen""}}";vartemplate=Handlebars.Compile(source);varresult=template(null);/* Would render:<strong>Karen</strong>*/Handlebars.RegisterHelper("link_to",(writer,context,parameters)=>{writer.WriteSafeString($"<a href='{context["url"]}'>{context["text"]}</a>");});stringsource=@"Click here: {{link_to}}";vartemplate=Handlebars.Compile(source);vardata=new{url="https://github.com/rexm/handlebars.net",text="Handlebars.Net"};varresult=template(data);/* Would render:Click here: <a href='https://github.com/rexm/handlebars.net'>Handlebars.Net</a>*/This will expect your views to be in the /Views folder like so:
Views\layout.hbs |<--shared as in \Views Views\partials\somepartial.hbs <--shared as in \Views\partials
Views\{Controller}\{Action}.hbs Views\{Controller}\{Action}\partials\somepartial.hbs Handlebars.RegisterHelper("StringEqualityBlockHelper",(output,options,context,arguments)=>{if(arguments.Length!=2){thrownewHandlebarsException("{{#StringEqualityBlockHelper}} helper must have exactly two arguments");}varleft=arguments.At<string>(0);varright=arguments[1]asstring;if(left==right)options.Template(output,context);elseoptions.Inverse(output,context);});varanimals=newDictionary<string,string>(){{"Fluffy","cat"},{"Fido","dog"},{"Chewy","hamster"}};vartemplate="{{#each this}}The animal, {{@key}}, {{#StringEqualityBlockHelper @value 'dog'}}is a dog{{else}}is not a dog{{/StringEqualityBlockHelper}}.\r\n{{/each}}";varcompiledTemplate=Handlebars.Compile(template);stringtemplateOutput=compiledTemplate(animals);/* Would renderThe animal, Fluffy, is not a dog.The animal, Fido, is a dog.The animal, Chewy, is not a dog.*/Block helpers can be chained with {{else}} clauses that themselves invoke another helper, similar to {{else if}}. This lets you avoid nesting an extra block and its extra closing tag - the chained helper shares the outer block's closing tag:
vartemplate="{{#StringEqualityBlockHelper value 'dog'}}is a dog{{else StringEqualityBlockHelper value 'cat'}}is a cat{{else}}is something else{{/StringEqualityBlockHelper}}";This works the same way {{#if}}/{{else if}} chaining does, and can be chained as many times as needed:
[Fact]publicvoidBasicDecorator(IHandlebarshandlebars){stringsource="{{#block @value-from-decorator}}{{*decorator 42}}{{@value}}{{/block}}";varhandlebars=Handlebars.Create();handlebars.RegisterHelper("block",(output,options,context,arguments)=>{options.Data.CreateProperty("value",arguments[0],out_);options.Template(output,context);});handlebars.RegisterDecorator("decorator",(TemplateDelegatefunction,inDecoratorOptionsoptions,inContextcontext,inArgumentsarguments)=>{options.Data.CreateProperty("value-from-decorator",arguments[0],out_);});vartemplate=handlebars.Compile(source);varresult=template(null);Assert.Equal("42",result);}For more examples see DecoratorTests.cs
- helpers registered inside of a decorator will not override existing registrations
In case you need to apply custom value formatting (e.g. DateTime) you can use IFormatter and IFormatterProvider interfaces:
publicsealedclassCustomDateTimeFormatter:IFormatter,IFormatterProvider{privatereadonlystring_format;publicCustomDateTimeFormatter(stringformat)=>_format=format;publicvoidFormat<T>(Tvalue,inEncodedTextWriterwriter){if(!(valueisDateTimedateTime))thrownewArgumentException("supposed to be DateTime");writer.Write($"{dateTime.ToString(_format)}");}publicboolTryCreateFormatter(Typetype,outIFormatterformatter){if(type!=typeof(DateTime)){formatter=null;returnfalse;}formatter=this;returntrue;}}[Fact]publicvoidDateTimeFormatter(IHandlebarshandlebars){varsource="{{now}}";varformat="d";varformatter=newCustomDateTimeFormatter(format);handlebars.Configuration.FormatterProviders.Add(formatter);vartemplate=handlebars.Compile(source);vardata=new{now=DateTime.Now};varresult=template(data);Assert.Equal(data.now.ToString(format),result);}- Formatters are resolved in reverse order according to registration. If multiple providers can provide formatter for a type the last registered would be used.
By default Handlebars will create standalone copy of environment for each compiled template. This is done in order to eliminate a chance of altering behavior of one template from inside of other one.
Unfortunately, in case runtime has a lot of compiled templates (regardless of the template size) it may have significant memory footprint. This can be solved by using SharedEnvironment.
Templates compiled in SharedEnvironment will share the same configuration.
Only runtime configuration properties can be changed after the shared environment has been created. Changes to Configuration.CompileTimeConfiguration and other compile-time properties will have no effect.
[Fact]publicvoidBasicSharedEnvironment(){varhandlebars=Handlebars.CreateSharedEnvironment();handlebars.RegisterHelper("registerLateHelper",(inEncodedTextWriterwriter,inHelperOptionsoptions,inContextcontext,inArgumentsarguments)=>{varconfiguration= options.Frame
.GetType().GetProperty("Configuration",BindingFlags.Instance|BindingFlags.NonPublic)?.GetValue(options.Frame)asICompiledHandlebarsConfiguration;varhelpers=configuration?.Helpers;conststringname="lateHelper";if(helpers?.TryGetValue(name,outvar@ref)??false){@ref.Value=newDelegateReturnHelperDescriptor(name,(c,a)=>42);}});var_0_template="{{registerLateHelper}}";var_0=handlebars.Compile(_0_template);var_1_template="{{lateHelper}}";var_1=handlebars.Compile(_1_template);varresult=_1(null);Assert.Equal("",result);// `lateHelper` is not registered yet_0(null);result=_1(null);Assert.Equal("42",result);}Compatibility feature toggles defines a set of settings responsible for controlling compilation/rendering behavior. Each of those settings would enable certain feature that would break compatibility with canonical Handlebars.
By default all toggles are set to false.
- Areas
Compile-time: takes affect at the time of template compilationRuntime: takes affect at the time of template rendering
If true enables support for Handlebars.Net helper naming rules.
This enables helper names to be not-valid Handlebars identifiers (e.g. {{ one.two }}).
Such naming is not supported in Handlebarsjs and would break compatibility.
Compile-time
[Fact]publicvoidHelperWithDotSeparatedName(){varsource="{{ one.two }}";varhandlebars=Handlebars.Create();handlebars.Configuration.Compatibility.RelaxedHelperNaming=true;handlebars.RegisterHelper("one.two",(context,arguments)=>42);vartemplate=handlebars.Compile(source);varactual=template(null);Assert.Equal("42",actual);}Used to switch between the legacy Handlebars.Net and the canonical Handlebars rules (or a custom implementation).
For Handlebars.Net 2.x.x HtmlEncoderLegacy is the default.
HtmlEncoder
Implements the canonical Handlebars rules.
HtmlEncoderLegacy
Will not encode:
= (equals)
` (backtick)
' (single quote)
Will encode non-ascii characters �, �, ...
Into HTML entities (<, â, ß, ...).
Runtime
[Fact]publicvoidUseCanonicalHtmlEncodingRules(){varhandlebars=Handlebars.Create();handlebars.Configuration.TextEncoder=newHtmlEncoder();varsource="{{Text}}";varvalue=new{Text="< �"};vartemplate=handlebars.Compile(source);varactual=template(value);Assert.Equal("< �",actual);}Compared to rendering, compiling is a fairly intensive process. While both are still measured in millseconds, compilation accounts for the most of that time by far. So, it is generally ideal to compile once and cache the resulting function to be re-used for the life of your process.
Nearly all time spent in rendering is in the routine that resolves values against the model. Different types of objects have different performance characteristics when used as models.
- The absolute fastest model is a
IDictionary<string, object>(microseconds). - The next fastest is a POCO (typically a few milliseconds for an average-sized template and model), which uses traditional reflection and is fairly fast.
- Rendering starts to get slower (into the tens of milliseconds or more) on dynamic objects.
- The slowest (up to hundreds of milliseconds or worse) tend to be objects with custom type implementations (such as
ICustomTypeDescriptor) that are not optimized for heavy reflection.
TBD
Pull requests are welcome! The guidelines are pretty straightforward:
- Only add capabilities that are already in the Mustache / Handlebars specs
- Avoid dependencies outside of the .NET BCL
- Maintain cross-platform compatibility (.NET/Mono; Windows/OSX/Linux/etc)
- Follow the established code format