Template engines for Erlang that compile templates into Erlang modules. Mustache and Jinja2, side by side.
Templates are turned into .erl files at build time by a rebar3 plugin, so at
run time rendering is a plain function call: no process, no ETS table, no
lookup of any kind.
- Passes all 136 cases of the six required mustache spec modules
- Zero dependencies: the library and its test suite need nothing but OTP
- Static template text lives in the module's literal pool and is shared across processes by reference
| mustache | jinja | |
|---|---|---|
| Suffix / prefix | .mustache / view_ |
.j2 / j2_ |
| Provider | rebar3 mustache |
rebar3 jinja |
| Options | mustache_opts |
jinja_opts |
| Conformance | the 136 official spec cases | 520 fixtures generated from CPython jinja2 3.1 |
| Documentation | this README | docs/jinja.md |
They are peers, not alternatives. One project can use both, sharing a views
directory and an out_dir; each generated file names its engine, and each
provider only collects its own orphans.
The two languages disagree, and it is worth knowing where:
| mustache | jinja | |
|---|---|---|
| Variable lookup | dynamic, walks the context stack | lexical scope |
0 and #{} |
true | false |
| Turning escaping off | {{{x}}} |
|safe |
| Reuse | {{> p}} |
include / extends / macro / import |
true prints as |
true |
True |
| A list prints as | its characters | [1, 2] |
{provider_hooks, [{pre, [{compile, mustache}, {compile, jinja}]}]}.
{mustache_opts, [{views, "views"}, {prefix, "view_"}]}.
{jinja_opts, [{views, "views"}, {suffix, ".j2"}, {prefix, "j2_"}]}.The Jinja2 engine, rebar3 jinja, ai_jinja_transform and a shared layer
underneath both engines. Mustache behaviour is unchanged. Full notes in
CHANGELOG.md; the engine itself is documented in
docs/jinja.md.
0.4.0 is a full rewrite. Templates and calling code both need changes.
Previously a template used a flat context with full-path lookups. It now uses the standard Mustache context stack, which is what every other Mustache implementation does.
- {{#user}}{{user.name}}{{/user}}
+ {{#user}}{{name}}{{/user}}
- {{#items}}{{+ items.current}}<li>{{items.name}}</li>{{/ items.current}}{{/items}}
+ {{#items}}{{+ current}}<li>{{name}}</li>{{/ current}}{{/items}}The rule: inside {{#X}}, drop the X. prefix from references to X's own
fields. rebar3 mustache migrate does the mechanical part and reports what it
could not decide.
There is deliberately no compatibility switch. Supporting both would fork the compiler's scope resolution, which costs more over time than migrating once.
| Removed | Replacement |
|---|---|
ai_mustache:bootstrap/0,1 |
nothing -- templates are compiled by the build |
ai_mustache:reload/0 |
ai_mustache_dev:reload/1 (dev only) |
ai_mustache:render(Name, Ctx) where Name is a string |
view_name:render(Ctx), or ai_mustache:render(view_name, Ctx) |
application:start(aihtml) |
nothing -- aihtml is a library application |
ai_dom_node, ai_dom_render |
removed; they were unrelated to Mustache |
& < > " ' are escaped. /, = and ` are not.
The old set corrupted URLs: href="/a/b" came out as href="/a/b".
The old & replacement was also missing its semicolon, producing &
instead of &; that is fixed.
When {{> partial}} sits alone on an indented line, that indent is applied to
every line the partial emits, as the spec requires. The old implementation
indented only the first line.
Everything aihtml used from it is now implemented in ai_mustache_rt, which
depends only on OTP.
{{.}}, the implicit iterator, works. It previously always resolved to nothing.{{> a.b}}and other partial paths containing dots no longer crash the parser.{{#a.b}}no longer discardsa's other keys while iterating.
views/index.mustache
|
+- ai_mustache_scanner lexer: text and tag tokens, standalone lines, delimiters
+- ai_mustache_parser recursive descent -> AST
+- ai_mustache_ast merge text, drop empty bodies, resolve partials
|
+- ai_mustache_compiler AST -> Erlang abstract forms (the only such implementation)
|
+- rebar3_aihtml erl_prettypr -> _gen/view_index.erl -> .beam
+- parse_transform forms injected into the calling module
views/index.mustache becomes roughly this:
-module(view_index).
-mustache_source(#{path => <<"views/index.mustache">>, stamp => <<...>>,
mtime => 1757203845, vsn => 1, opts => #{...}}).
-export([render/1, render_iolist/1, render_stack/1, render_stack/2, partials/0]).
render(Ctx) -> erlang:iolist_to_binary(render_stack([Ctx], <<>>)).
partials() -> [view_shared_item].
render_stack(S, I) ->
[I, <<"<h1>">>,
ai_mustache_rt:escape(ai_mustache_rt:lookup([header], S)),
<<"</h1>\n">>,
sec_1(S, I)].
sec_1(S, I) ->
case ai_mustache_rt:lookup([items], S) of
[] -> [];
L when is_list(L) -> [sec_1_body([E | S], I) || E <- L];
M when is_map(M) -> sec_1_body([M | S], I);
true -> sec_1_body(S, I);
F when is_function(F, 2) -> F(erlang:iolist_to_binary(sec_1_body(S, I)), hd(S));
F when is_function(F, 1) -> ai_mustache_rt:section(F(hd(S)), fun sec_1_body/2, S, I);
V -> ai_mustache_rt:section(V, fun sec_1_body/2, S, I)
end.
sec_1_body(S, I) -> [<<" ">>, view_shared_item:render_stack(S, I)].Three consequences worth naming:
Static text is a literal. It goes into the module's literal pool and is
shared between processes by reference. The previous design kept the parsed
template in ETS, and every ets:lookup/2 deep-copied the whole tree into the
calling process.
A partial is a cross-module call. Editing a partial therefore does not require recompiling the templates that include it, and two templates may include each other -- the old tree-walking interpreter would have recursed forever.
Iterating a section pushes onto a list. [Item | Stack] allocates one
cons cell. The old runner built a fresh context with maps:merge/2 on every
iteration, which copied the whole map and, for a dotted section name, silently
dropped the sibling keys.
You need two entries: the plugin, which compiles the templates at build
time, and the aihtml dependency, whose ai_mustache_rt the generated
modules call at run time.
%% rebar.config -- the whole of it, for a single-app project
{erl_opts, [debug_info, {src_dirs, ["src", "_gen"]}]}.
{deps, [{aihtml, {git, "https://github.com/DavidAlphaFox/aihtml.git",
{tag, "v0.4.0"}}}]}.
{plugins, [{rebar3_aihtml, {git_subdir, "https://github.com/DavidAlphaFox/aihtml.git",
{tag, "v0.4.0"}, "rebar3_aihtml"}}]}.
{provider_hooks, [{pre, [{compile, mustache}]}]}.That is genuinely all of it -- there is no mustache_opts above because every
option has a working default. The plugin lives in a subdirectory of the aihtml
repository, hence git_subdir rather than git.
Add _gen/ to .gitignore; it is build output.
myapp/
rebar.config
src/
myapp.app.src
myapp.erl %% calls view_index:render/1
views/ %% <- {views, "views"}, the default
index.mustache %% -> view_index
layout/
head.mustache %% -> view_layout_head
shared/
post.mustache %% -> view_shared_post
_gen/ %% generated, gitignored
view_index.erl
view_layout_head.erl
view_shared_post.erl
{src_dirs, ["src", "_gen"]} is the one line that is easy to forget: without
it rebar3 never compiles the generated modules and view_index is undefined.
Partials are named by their path under views/, without the suffix:
{{> layout/head}}
{{> shared/post}}{mustache_opts, [
{views, "views"}, % template root, relative to the app
{suffix, ".mustache"},
{out_dir, "_gen"}, % where the generated .erl files go
{prefix, "view_"}, % module name prefix
{line_map, true}, % map generated line numbers back to the template
{extensions, []}, % custom tag modules, see parse_transform below
{ext_opts, #{}}, % configuration passed to those modules
{warnings_as_errors, false}
]}.An unrecognised key is reported rather than ignored, so a typo does not silently do nothing.
Put the plugin, the dep and the hook at the project root; each application
keeps its own views/ and generates into its own _gen/.
myproject/
rebar.config %% plugins, deps, provider_hooks, shared mustache_opts
apps/
web/
rebar.config %% {erl_opts, [{src_dirs, ["src", "_gen"]}]} + any overrides
views/ ...
_gen/ ...
admin/
rebar.config
views/ ...
_gen/ ...
Two things to know:
{src_dirs, ["src", "_gen"]}has to be in each application's ownrebar.config, not just the root one.- Give each application its own
prefixif they have templates with the same path.web/views/index.mustacheandadmin/views/index.mustacheboth map toview_index, which is one module name for two modules. The plugin detects the clash across applications and tells you which prefixes are involved;{prefix, "admin_"}in one of them resolves it, and the module generated under the old name is collected as an orphan on the next build.
rebar3 compile now runs the mustache provider first. It only recompiles
templates whose content, options or compiler version actually changed -- the
check reads the -mustache_source attribute out of the previously generated
.erl, so there is no cache file to go stale.
The generated source is readable, greppable and visible to dialyzer, and stack traces from a template error point at real line numbers.
views/index.mustache -> view_index
views/shared/item.mustache -> view_shared_item
views/layout/default.mustache -> view_layout_default
/, - and . all become _, so shared/item.mustache and
shared_item.mustache would collide. The plugin detects that and fails rather
than silently overwriting one with the other.
rebar3 mustache migrate # print a diff
rebar3 mustache migrate --write # apply itIt rewrites in place, preserving comments, whitespace and custom delimiters. Anything it cannot decide -- a reference to a sibling section's variable, say -- is left alone and listed in the report for you to handle.
BUILD_DEPS = rebar3_aihtml
DEP_PLUGINS = rebar3_aihtml
dep_rebar3_aihtml = git https://github.com/DavidAlphaFox/aihtml.git v0.4.0Supported, but not the primary path; CI builds with rebar3.
view_index:render(Ctx) -> binary().
view_index:render_iolist(Ctx) -> iolist().
%% When the template is only known at run time -- picking a layout by route:
ai_mustache:render(view_index, Ctx) -> binary().
ai_mustache:render_iolist(view_index, Ctx) -> iolist().
%% An inline template (see parse_transform below):
ai_mustache:inline(~"Hello {{name}}!", #{name => Name}) -> binary().render_iolist/1 can go straight into a cowboy response body, which skips
building the flat binary entirely.
The context may be any term, not just a map: the spec has a case whose entire
data is the integer 85, reachable as {{.}}.
Template files are read as UTF-8 and need nothing special. But a literal in your own source does:
%% WRONG: each codepoint is truncated to 8 bits and the text is destroyed
#{title => <<"我的博客">>}
%% Right
#{title => <<"我的博客"/utf8>>}This is ordinary Erlang, not something aihtml introduces, but it is the most common way to end up with mojibake in a page whose templates are fine.
ai_mustache_dev:check() % one-shot environment self-test
ai_mustache_dev:stale(view_index)
ai_mustache_dev:reload(view_index)
ai_mustache_dev:reload(all)check/0 is not a switch. This module holds no state at all -- no ETS, no
persistent_term, no process, no cache file -- so there is nowhere for an
"enabled" flag to live. Nothing watches your files. You call reload/1
yourself, from a dev-only middleware, an editor hook, or the top of a request
handler.
Staleness is decided by hashing the file, never by its mtime: POSIX mtime has one-second resolution and the edit-then-refresh loop happens well inside one second.
Keys are atoms, in the context and in the template:
#{user => #{name => <<"David Gao">>, level => 1}, stars => 10}Template bodies and paths are UTF-8 binaries. An entry point will accept
any unicode:chardata() and normalise it once, but a template that is not
valid UTF-8 is rejected with {invalid_utf8, ByteOffset} rather than passed
through as bytes -- that failure is much easier to diagnose at the door than
as mangled output from a generated module later. Paths are normalised but
never rejected, since a non-UTF-8 filesystem can still hand back a path that
names a real file.
{{name}} walks the context stack from the top outwards and takes the value
from the first frame that has that key.
{{a.b}} resolves a by walking the stack, then takes b strictly inside
a. If a has no b, the result is empty; it does not keep searching
outwards.
{{.}} is the implicit iterator: the value on top of the stack.
Exactly five: undefined, false, [], <<>>, null.
This trips people up, so it is worth being explicit:
%% {{#count}}You have {{.}} messages{{/count}}
#{count => 0} %% the section RUNS and renders "You have 0 messages"
#{count => []} %% the section is skippedIf you want "zero means hide", test it in your code and pass a boolean.
{{#x}} behaves according to the run-time type of x:
x |
Behaviour | Pushes a scope |
|---|---|---|
[] |
skipped | -- |
| non-empty list | body runs once per element | yes, per element |
| map | body runs once | yes |
true |
body runs once | no |
fun/2 |
called as F(RenderedBody, CurrentFrame) |
no |
fun/1 |
called as F(CurrentFrame), result dispatched again |
depends on the result |
| falsy | skipped | -- |
| anything else | body runs once | yes, so {{.}} works |
{{^x}} runs the body when x is falsy and never pushes a scope.
Both look like conditionals. The difference is scope.
{{#user}}{{name}}{{/user}} renders the user's name -- {{#}} pushes user onto the stack
{{+user}}{{user.name}}{{/user}} renders the same thing -- {{+}} does not push, so the full path is needed{{#}} means with / for each: it enters a scope and iterates lists.
{{+}} means if: it tests truthiness and runs the body once, in the
surrounding scope. {{-}} is {{+}} negated.
Use {{+}} when you want a condition without changing what the names inside
refer to:
{{#items}}
{{+ current}}<li class="on">{{name}}</li>{{/ current}}
{{- current}}<li>{{name}}</li>{{/ current}}
{{/items}}{{name}} refers to the item in both branches. With {{#current}} it would
refer to whatever is inside current.
Both accept a fun/1, called with the current frame:
#{has_friends => fun(Frame) -> maps:get(friends, Frame, []) =/= [] end}{{> x}} is unconditional on its own; wrap it in a section:
{{#user}}
{{> shared/card}}
{{/user}}
{{+is_admin}}
{{> shared/panel}}
{{/is_admin}}
{{^items}}
{{> shared/empty}}
{{/items}}Which section you choose decides how names resolve inside the partial:
{{#}} pushes a scope, {{+}} does not.
Give the partial a line of its own. Squeezed onto one line it is not standalone and loses its indentation:
{{#show}}
{{> row}} correct: every line of row gets the 2-space indent
{{/show}}
{{#show}}{{> row}}{{/show}} indentation lost, plus a stray blank lineTo choose which partial at run time (the spec's Dynamic Names are not implemented): mutually exclusive sections, or a lambda calling the module directly -- partials compile to real modules, so Erlang can call them:
Pick = fun(Frame) ->
Mod = case maps:get(kind, Frame) of
text -> view_shared_text;
image -> view_shared_image
end,
Mod:render_iolist(Frame) %% an iolist, and not escaped
end.The template says {{*body}}.
{{*name}} is an aihtml extension. Its output is not escaped -- producing
markup is the point.
%% fun/1: receives the current frame
#{yield => fun(Frame) -> render_something(Frame) end}
%% fun/2 plus a value: called as Fun(Value, Frame)
#{yield => [fun render_layout/2, <<"index">>]}Since 0.4.0 the fun receives the top of the stack, not a flat global
context. A lambda at the top level of a template still sees the root context,
but move it inside a section and it will not. Pass what you need through the
fun/2 form rather than relying on where the tag sits.
{{> shared/user}}resolves to view_shared_user and is compiled into a direct call. Partials
render with the current stack, so a partial used inside {{#items}} sees
the item:
{{! views/index.mustache }}
<ul>{{#items}}{{> shared/row}}{{/items}}</ul>
{{! views/shared/row.mustache }}
<li>{{name}}</li>A partial alone on an indented line has that indent applied to every line it emits. Interpolated values are not re-indented, so a value containing newlines keeps its own shape.
Full guide: docs/parse-transform.md.
-module(my_views).
-compile({parse_transform, ai_mustache_transform}).
%% (a) declare a custom tag; see below
-mustache_tag({$@, my_i18n}).
%% (b) inline template, expanded at compile time
greet(Name) -> ai_mustache:inline(~"Hello {{name}}!", #{name => Name}).
%% (c) compile a template file into index/1 and index_iolist/1
-mustache_template({index, "views/index.mustache"}).Erlang's sigils are a fixed set; a custom one does not lex. Inline templates
are therefore recognised as calls to ai_mustache:inline/2 whose first
argument is a binary literal. ~"..." is OTP 27's ordinary string sigil and
produces exactly that.
If the transform is not applied, or the first argument is not a literal, the call still works -- it falls back to compiling at run time. Same output, just slower. The one exception is custom tags: those are expanded by compile-time callbacks and cannot run in the fallback path.
A non-literal first argument warns, since writing inline usually means you
wanted the zero-cost version; nowarn_mustache_inline in erl_opts turns that
off. Inline templates cannot use {{> partial}} -- there is no views directory
to resolve one against -- and that is a compile error.
-module(my_i18n).
-behaviour(ai_mustache_ext).
-export([markers/0, compile_tag/4]).
markers() -> [$@].
compile_tag($@, [Key], _Body, Opts) ->
%% Return an abstract expression evaluating to iodata()
...# ^ / > ! = & { } + - * are taken; a custom marker must be something else,
and two extensions may not claim the same character.
-mustache_tag declares and checks; it does not wire anything up. A
parse_transform only sees one module, so the module compiling your templates
cannot learn about the declaration from it. Actual registration goes in
rebar.config:
{mustache_opts, [{extensions, [my_i18n]},
{ext_opts, #{my_i18n => #{default_locale => en}}}]}.What the attribute buys you is that a mistake -- a module that does not implement the behaviour, or a marker it does not claim -- becomes a compile error in the module that declared it instead of a puzzling failure later.
There is one exception to "declares but does not wire up": forms (b) and (c)
compile inside the very module that carries the attribute, so a -mustache_tag
there is used for that module's own inline and -mustache_template
templates. It still says nothing about .mustache files the plugin compiles.
If extensions is configured and a declared module is missing from it, the
transform warns.
rebar3 cannot see that my_views.erl depends on views/index.mustache; a
parse_transform has no way to register an extra file dependency. The plugin
compensates by scanning for -mustache_template attributes and touching the
.erl when the template changes. Forms (a) and (b) are unaffected.
The required modules -- Comments, Delimiters, Interpolation, Inverted, Partials, Sections -- all pass. These are deliberate deviations:
{{+x}} / {{-x}} |
aihtml extensions; the spec has no such tags |
{{*x}} |
aihtml extension |
| Key type | atoms, where the spec uses strings |
| Lambdas | return values are not re-parsed as templates |
| Dynamic Names, Blocks | not implemented (optional spec modules) |
' escaping |
escaped as ', which the spec does not require |
bench/run.sh compares against v0.3.7 on a page both versions render
byte-identically -- the harness refuses to report timings if the outputs ever
diverge. Median of seven runs on OTP 28, Linux x86-64:
| Page | v0.3.7 | 0.4.0 render/1 |
0.4.0 render_iolist/1 |
|---|---|---|---|
| 20 items, 1843 bytes | 632.5 us | 11.8 us (53x) | 10.0 us |
| 100 items, 8885 bytes | 3235.0 us | 51.9 us (62x) | 46.0 us |
The gap grows with the number of section iterations: the old runner rebuilt
the context with maps:merge/2 for every element, which copies a map whose
size does not shrink, while pushing onto the stack is one cons cell.
See bench/README.md for the methodology and where the rest of the difference comes from.
examples/ is a complete project: templates under examples/views/, a driver
in examples/src/complex.erl, and the plugin wired up in
examples/rebar.config.
sh examples/run.shIt builds in a temporary copy, because aihtml and rebar3_aihtml have to reach
the example through _checkouts (rebar3 has no path resource) and
symlinking the repository root into a directory inside that repository would
be circular. The script prints the generated module list and the rendered
page.
The example is deliberately dense: it covers partial indentation, {{+}} and
{{-}}, a true section that does not push a scope, an inverted section, a
0 that is truthy, {{.}}, and a fun/2 lambda. test/examples_tests.erl
pins its output byte for byte.
- aiwiki -- a very simple blog. Its templates predate 0.4.0 and need migrating.
- Using the parse_transform -- extension tags, inline templates, file templates
- Benchmark -- methodology and results
- Design notes -- why the engine is built this way
The scanner's tag-splitting logic derives from bbmustache by Hinagiku Soranoba, used under the MIT licence.
MIT. See LICENSE.