No description
Find a file
dxtr 9bfcccd9c6
All checks were successful
CI / test (27) (push) Successful in 2m8s
CI / test (28) (push) Successful in 2m14s
CI / test (29) (push) Successful in 2m17s
ci: use shared fakehacker/actions composite actions (#1)
Replace the inline node-less checkout and rebar3 build/test steps with the
shared composite actions fakehacker/actions/checkout and rebar3-ci. The matrix
and erlang container stay in the workflow since composite actions can't declare
them.

Reviewed-on: #1
2026-06-17 21:56:45 +02:00
.forgejo/workflows ci: use shared fakehacker/actions composite actions (#1) 2026-06-17 21:56:45 +02:00
include Initial implementation of erlstache (Mustache v1.4.3) 2026-06-16 21:23:01 +02:00
src Initial implementation of erlstache (Mustache v1.4.3) 2026-06-16 21:23:01 +02:00
test Add property-based tests (PropEr) 2026-06-17 14:49:05 +02:00
.gitignore Initial implementation of erlstache (Mustache v1.4.3) 2026-06-16 21:23:01 +02:00
CHANGELOG.md Initial implementation of erlstache (Mustache v1.4.3) 2026-06-16 21:23:01 +02:00
LICENSE Initial implementation of erlstache (Mustache v1.4.3) 2026-06-16 21:23:01 +02:00
README.md Add property-based tests (PropEr) 2026-06-17 14:49:05 +02:00
rebar.config Add property-based tests (PropEr) 2026-06-17 14:49:05 +02:00
rebar.lock Initial implementation of erlstache (Mustache v1.4.3) 2026-06-16 21:23:01 +02:00

erlstache

Mustache spec OTP

A spec-compliant Mustache templating engine for Erlang.

erlstache implements the entire Mustache specification v1.4.3 — all six core modules and all three optional modules (lambdas, dynamic names, and template inheritance). Every test case in the specification is executed against the engine; see Conformance.

Highlights

  • Complete v1.4.3 conformance. 194/194 spec cases pass, including the notoriously tricky inheritance re-indentation cases that most engines skip.
  • Hand-written, non-regex parser. No leex, no yecc, no regular expressions. The scanner walks the template with binary:match/2 and slices with binary:part/3, so no character data is copied while scanning.
  • Zero runtime dependencies. The library depends only on kernel and stdlib.
  • Unambiguous data model. Maps are hashes, lists are arrays, binaries are strings — no proplist-or-hash or string-or-list guessing.
  • Compiled templates. Parse once, render many times.

Installation

erlstache requires Erlang/OTP 27 or newer (it uses the EEP-48 documentation attributes and the float_to_binary/2 short form).

rebar3

%% rebar.config
{deps, [
    {erlstache, {git, "https://fakehacker.cc/dxtr/erlstache", {branch, "main"}}}
]}.

Mix / Elixir

# mix.exs
{:erlstache, git: "https://fakehacker.cc/dxtr/erlstache", branch: "main"}

Usage

1> erlstache:render(<<"Hello, {{name}}!">>, #{<<"name">> => <<"world">>}).
<<"Hello, world!">>

Compiling once, rendering many times

1> {ok, T} = erlstache:parse(<<"{{#items}}- {{.}}\n{{/items}}">>).
2> erlstache:render_template(T, #{<<"items">> => [<<"a">>, <<"b">>]}).
<<"- a\n- b\n">>

Partials

Partials are supplied via the partials option, as template source or as a compiled template:

1> erlstache:render(
..     <<"{{>greeting}}">>,
..     #{<<"who">> => <<"there">>},
..     #{partials => #{<<"greeting">> => <<"Hi {{who}}!">>}}).
<<"Hi there!">>

Data model

Mustache value Erlang term
hash / object a map (keys may be binaries, atoms or strings)
list / array a list (always iterated by a section)
string a binary
number an integer or float
boolean true / false
null the atom null (or undefined)
lambda a fun

false, null, undefined and the empty list [] are falsey. Every other value — including 0, 0.0 and the empty binary — is truthy.

Strings must be binaries and hashes must be maps. This avoids the classic Erlang ambiguities (is "foo" a string or a list of integers? is [{a, 1}] a hash or a one-element list?). Map keys are matched against the tag name as a binary first, then as an atom, then as a string.

Lambdas

A fun in the context is a lambda:

  • Interpolation ({{fn}}): the fun takes no arguments. Its result is parsed with the default delimiters, rendered, and interpolated.
  • Section ({{#fn}}…{{/fn}}): the fun takes one argument — the raw, unprocessed section body as a binary. Its result is parsed with the delimiters active at the section, rendered, and interpolated.
1> Bold = fun(Body) -> <<"<b>", Body/binary, "</b>">> end,
1> erlstache:render(<<"{{#bold}}{{x}}{{/bold}}">>,
..                  #{<<"bold">> => Bold, <<"x">> => <<"hi">>}).
<<"<b>hi</b>">>

Lambdas may return a binary, an iolist/charlist, a number or an atom.

Template inheritance

erlstache implements the optional inheritance module — parent templates ({{<parent}}) and overridable blocks ({{$block}}…{{/block}}):

1> Partials = #{<<"layout">> => <<"<h1>{{$title}}Untitled{{/title}}</h1>">>},
1> erlstache:render(
..     <<"{{<layout}}{{$title}}Home{{/title}}{{/layout}}">>, #{},
..     #{partials => Partials}).
<<"<h1>Home</h1>">>

Options

render/3 and render_template/3 take an options map:

Key Type Default
partials #{binary() => iodata() | template()} #{}
escape fun((binary()) -> binary()) HTML escaping (& < > " ')
%% Render without any HTML escaping:
erlstache:render(Template, Data, #{escape => fun(B) -> B end}).

Error handling

  • parse/1 returns {ok, Template} or {error, Reason} — use it for templates from untrusted or dynamic sources.
  • render/2,3 raise error({erlstache, Reason}) if a template (or partial) fails to parse.

Reason is one of {scan, {unclosed_tag, Pos}}, {scan, {bad_delimiters, Raw}}, {parse, {unclosed_section, Name}}, {parse, {mismatched_close_tag, Open, Close}} or {parse, {unexpected_close_tag, Name}}.

Architecture

Module Responsibility
erlstache Public API.
erlstache_scanner Delimiter-aware tokenizer (no regex, no leex/yecc).
erlstache_parser Standalone-line trimming and nesting into an AST.
erlstache_render AST + context → output, including indentation & lambdas.
erlstache_data Context-stack resolution, truthiness, escaping.

Because Set Delimiter tags can change the delimiters mid-template, Mustache is not a static grammar — which is exactly why a hand-written, delimiter-aware scanner is used instead of generated parser tables.

Conformance

The full v1.4.3 specification is vendored under test/spec/ and run as the test suite. The eight data-driven spec files are executed via yamerl (a test-only dependency); the ~lambdas.yml cases are ported in erlstache_lambda_tests because their lambda bodies are embedded as source in other languages and cannot be loaded as data.

$ rebar3 eunit
…
  All 235 tests passed.

In addition to the spec and unit tests, the suite includes property-based tests (PropEr) covering parser robustness against random input, escaping invariants, agreement between the rendering entry points, and delimiter independence across randomly generated templates. They run as part of rebar3 eunit.

Building and testing

rebar3 compile        # compile (warnings are errors)
rebar3 eunit          # run the spec, unit and property tests
rebar3 dialyzer       # type analysis
rebar3 xref           # cross-reference checks
rebar3 ex_doc         # generate HTML documentation

Author

dxtr <dxtr@fakehacker.cc> — https://fakehacker.cc/dxtr/erlstache

License

MIT © 2026 dxtr. See LICENSE.