Handlebars templating
This page details Handlebars templating.
Dependency
<dependency>
<groupId>io.yupiik.fusion</groupId>
<artifactId>fusion-handlebars</artifactId>
<version>${fusion.version}</version>
</dependency>
Usage
It starts from HandlebarsCompiler (available as a fusion bean) which compiles a template + its helpers and partials as a Template which supports to render the template. The template is parsed once into a precomputed part tree: nothing is parsed, tokenized or looked up at render time.
Data can be map of primitives (or data which have a toString()) or lists for now. It tolerates that you pass a Supplier too as map value for a lazy evaluation.
// once per app
final var compiler = new HandlebarsCompiler();
final var tpl = compiler.compile(new HandlebarsCompiler.CompilationContext(
new HandlebarsCompiler.Settings()
.helpers(Map.of( // some helpers - can be Map.of() if you don't use them
"uppercase", ctx -> ((String) ctx.args().get(0)).toUpperCase(ROOT)))
.partials(Map.of(
"person", "{{person.name}} is {{person.age}} years old.")),
"{{firstname}} {{uppercase lastname}}"));
// at runtime
final var rendering = tpl.render(Map.of(...));
Syntax reference
| Syntax | Description |
|---|---|
|
|
Outputs |
|
|
Outputs |
|
|
Outputs the current data. |
|
|
Nested path resolution. |
|
|
Resolves a path (or data variable) against the parent context frame. |
|
|
Conditional block with optional else. |
|
|
|
|
|
Negated conditional. |
|
|
Inverted section: renders when |
|
|
Iteration with optional empty branch. |
|
|
Iteration with block params bound to the item (or entry value for maps) and the index (or entry key). |
|
|
Changes the current data, else when null. |
|
|
Binds the value to a block param. |
|
|
Keyword-less block: when |
|
|
Custom block helper. |
|
|
Custom block helper with block params. |
|
|
Hash arguments on sections. |
|
|
Inline helper (output is HTML-escaped). |
|
|
Multiple positional + hash arguments. |
|
|
Helper receiving the current data as its single arg. |
|
|
Sub-expression usable in any argument position. |
|
|
Renders a partial with the current data. |
|
|
Renders a partial with remapped data. |
|
|
Comments, the trailing EOL is consumed. |
|
|
Raw block: the body is rendered verbatim (no interpolation). |
Sub-expressions can nest: {{#if (and (eq a 1) (gt b 0))}}yes{{else}}no{{/if}}.
Escaping
Double-brace interpolation ({{name}} and inline helper results) HTML-escapes & < > " ' \ =` plus the standard HTML named entities (Latin-1, Greek, punctuation, arrows, math symbols). Triple-brace interpolation ({{{name}}} and {{{helper args}}} calls) and block helper results are rendered raw.
Data access
Data is resolved through an Accessor (defaults to io.yupiik.fusion.framework.handlebars.compiler.accessor.MapAccessor): maps, nested maps, list index paths ({{people.0.name}}) and Supplier values are supported. Inside {{#each}}, a missing variable is looked up in the parent data first, then in the current item. A custom Accessor (or a RoutingAccessor routing by data type) can be passed to the HandlebarsCompiler constructor to support records or any other data shape.
Truthiness
A value is falsy when it is null, Boolean#FALSE, a blank string, a number equal to zero or an empty collection. if, unless (fixed: false is now falsy like in handlebars.js), and, or, not, default and the {{#varName}} blocks all follow this rule.
Data variables
{{#each xxx}} supports the following data-variables:
| Variable | Description |
|---|---|
|
|
boolean, is the current element the first one. |
|
|
boolean, is the current element the last one. |
|
|
integer, 0-based index of current element. |
If the xxx variable is a Map, the additional @key and @value data-variables are available, and plain names resolve against the entry value (like handlebars.js object iteration). Note that @index/@first/@last reflect the true positions: with limit=/offset= the index keeps counting from the beginning of the collection.
Built-in helpers
The following helpers are registered by default and can be overridden by the user helpers (Settings.helpers() wins over the built-in by name).
| Helper | Description |
|---|---|
|
|
Equality, numbers compare by value ( |
|
|
Not equal. |
|
|
Numeric comparison (numbers or numeric strings). |
|
|
All arguments truthy. |
|
|
At least one argument truthy. |
|
|
Negates the truthiness. |
|
|
|
|
|
Map key or list index access. |
|
|
Now, formatted with |
|
|
Formats the value (Date, Instant, epoch millis, LocalDate/Time/DateTime, Offset/ZonedDateTime). |
|
|
Now with an explicit |
|
|
Value with an explicit pattern. |
All of them are block-aware: {{#eq a b}}equal{{else}}different{{/eq}}, {{#gt age 18}}adult{{else}}minor{{/gt}} render the block or its else branch, and used inline or in a sub-expression they return a boolean. time, default and lookup used as blocks render the block with their result as the current data.
Custom helpers
A helper is a Function<HelperContext, Object> where HelperContext (record) carries:
| Component | Description |
|---|---|
|
|
the evaluated positional arguments (empty when none). |
|
|
the evaluated |
|
|
renders the block body with the passed data (null for inline/sub-expression calls). |
|
|
renders the |
Arguments accept string literals with single or double quotes ('abc', "a b", backslash escapes), integer, decimal (-1.2) and boolean (true/false) literals, variable paths (a.b, this.path where this is the current data), parent paths (../name) and sub-expressions (helper args).
A helper may return a io.yupiik.fusion.framework.handlebars.helper.SafeString to bypass HTML escaping in a double-brace call (like handlebars.js SafeString).
helpers(Map.of(
"uppercase", ctx -> ctx.args().isEmpty() ? null : ctx.args().get(0).toString().toUpperCase(ROOT),
"list", ctx -> {
final var items = ctx.args().get(0);
if (!(items instanceof Collection<?> list)) {
return "failed, not a list";
}
if (list.isEmpty()) {
return ctx.inverseRenderer() == null ? "" : ctx.inverseRenderer().apply(items);
}
return list.stream()
.map(ctx.blockRenderer())
.map(it -> "<li>" + it + "</li>")
.collect(joining("\n", "<ul>\n", "\n</ul>"));
},
"print_person", ctx -> ...)) // {{print_person}} -> the current data is args.get(0)
Inline and sub-expression helper results are HTML-escaped, block helper results are rendered raw.
Partials
Partials are registered with Settings.partials() and rendered with {{> name}} using the current data ({{#each people}}...{{>person person=.}}...{{/each}} remaps the current item to the person variable). Parameters can be literals, variables or sub-expressions: {{>person name="Nils" age=20}} makes the partial use these values instead of its own data lookups; {{>person person=.}} maps the current data to person (so {{person.name}} inside the partial reads the current item). The remapping applies to the whole partial body, including nested sections and else branches. Set Settings.cachePartials(true) to compile partials once and reuse them across compile() calls (default is to compile them per template).
Breaking changes
Version 1.2.0 aligns the engine with handlebars.js and changes the custom helper API and a few template behaviors compared to previous releases.
Helper signature
Helpers are now Function<HelperContext, Object> (previously Function<Object, String>). The old contract passed a single argument directly, several arguments as a List, and block helpers received a BlockHelperContext(data, blockRenderer) which was removed. HelperContext is created by the engine for each call and exposes args() (evaluated positional arguments), hash() (evaluated key=value arguments) and, for block helpers, blockRenderer() and inverseRenderer() to render the block and its {{else}} body. A helper result can now be any object (it is stringified at render time) instead of only a String.
| Case | Before | After |
|---|---|---|
|
Inline helper, one argument |
the value directly |
|
|
Inline helper, several arguments |
a |
|
|
Bare call |
the current data |
|
|
Block helper |
|
|
|
|
not renderable |
|
// before
"uppercase", o -> o.toString().toUpperCase(ROOT)
// after
"uppercase", ctx -> ((String) ctx.args().get(0)).toUpperCase(ROOT)
Inline helper output is now escaped
Inline helper results in double braces ({{helper ...}}) are now HTML-escaped like variables; they used to be inserted as-is. Use {{{helper ...}}} or return a io.yupiik.fusion.framework.handlebars.helper.SafeString to render raw. Block helper output remains unescaped.
Template behaviors
-
falseis now falsy inif/unless/and/or/not/defaultand{{#varName}}blocks (see Truthiness ); it used to be truthy. -
Accessing a property on non-map data no longer throws and resolves to
null(rendered as empty), like an undefined variable. -
Keyword-less blocks
{{#varName}}now render their block with the value as the new context (handlebars.jsblockHelperMissing); they used to render with the enclosing data. -
{{#each}}now renders its{{else}}branch when the source is null or empty, and supportslimit=/offset=andas |value key|. -
{{else}},{{else if}}and inverted sections ({{^foo}}) are now supported. -
Comments and block boundaries consume the surrounding end-of-line, which can slightly change the rendered whitespace.
Limitations
The engine implements a carefully chosen subset of handlebars.js:
-
No
[dotted.keys]literal-key access. -
No whitespace-control mustaches (
{{~ ~}}). -
No dynamic partials (
{{> (whichPartial)}}), no context lambdas (functions as data values; onlySuppliers). -
{{else if}}chains are supported;{{else unless}}and{{else <custom-helper>}}are supported too (the latter must be a registered helper).