Optional bridge that lets an erlydtl
template translate its {% trans %} / {% blocktrans %} tags through erli18n —
full gettext (contexts, CLDR plurals, hot-reloadable persistent_term catalogs,
per-request locale) with erlydtl keeping ownership of {{ var }} interpolation
and auto-escaping.
The integration is inverted relative to the Cowboy/Elli adapters. erlydtl does
not read .po files: at render time it invokes a caller-supplied
translation_fun and interpolates/escapes whatever that fun returns. erli18n is
exactly a (msgid, locale) -> binary() function that echoes the msgid on a miss.
So this module is a pure adapter fun, not a framework seam — it references
zero erlydtl functions (it only calls erli18n and unicode). That is why,
unlike erli18n_cowboy / erli18n_elli, it is not listed in
optional_applications, needs no xref_ignores, and carries no
dialyzer/eqwalizer/elp suppressions: every line stays under full static
analysis. erlydtl is a test-profile dependency here (so the suite can drive the
real compiler); it is never a dependency of the published erli18n package.
Install it
Add erlydtl to your application's deps, then build a translation fun once and
pass it in the render options:
%% Load the template-facing catalogs once at boot, into a dedicated domain.
application:ensure_all_started(erli18n),
{ok, _} = erli18n_server:ensure_loaded(web, <<"pt_BR">>, "priv/.../web.po"),
{ok, home} = erlydtl:compile_template(TemplateBin, home),
TFun = erli18n_erlydtl:translation_fun(web), %% build ONCE, reuse the value
%% (a) explicit locale — fastest, spawn/pool-safe:
{ok, Html} = home:render(Ctx, [{translation_fun, TFun}, {locale, erli18n:which_locale()}]),
%% (b) zero-config — locale comes from the per-process state that
%% erli18n_cowboy / erli18n_elli already set on the request process:
{ok, Html2} = home:render(Ctx, [{translation_fun, TFun}]).How template tags map to erli18n
translation_fun/1 returns a fun/2 (arity 2 is mandatory: a fun/1 never
receives the locale, and {% blocktrans %} requires fun/2). erlydtl hands the
fun a phrase and a locale; decode/2 turns that into the matching gettext
call against the closed-over Domain:
| erlydtl node | erli18n call |
|---|---|
{% trans "X" %} / _("X") | erli18n:gettext(Domain, <<"X">>, L) |
{% trans "X" context "c" %} | erli18n:pgettext(Domain, <<"c">>, <<"X">>, L) |
{% blocktrans %}Hi {{ name }}… | erli18n:gettext(Domain, <<"Hi {{ name }}">>, L) |
… count … plural … | erli18n:ngettext(Domain, <<"s">>, <<"p">>, count(N), L) |
… count … context "x" … | erli18n:npgettext(Domain, <<"x">>, <<"s">>, <<"p">>, count(N), L) |
{% trans "X" noop %} | fun not called; erlydtl emits the source directly |
The msgid for a blocktrans is the unparsed block source, so its
{{ var }} markers are part of the key. The catalog msgstr carries the same
markers; the fun returns that string verbatim and erlydtl substitutes the render
context and auto-escapes the values. The fun must therefore not interpolate —
if it did, erlydtl would escape nothing. This also means template-facing catalogs
are authored in erlydtl's {{ var }} dialect, not erli18n's %{var}: use a
dedicated domain (the demo uses web) and do not share msgids with hand-written
erli18n call sites. erli18n's own %{var} interpolation (the *f family) is not
involved on this path.
Locale: render-time only
Only the render-time hook is supported. erlydtl's compile-time translation bakes
a frozen per-locale clause and re-parses the fun's return as template source,
which both defeats per-request negotiation and re-parses catalog content — so this
bridge targets render/2 exclusively.
resolve_locale/1 picks the locale in one pass:
- a binary or string
localerender option is honored (the string form is the erlydtl idiom{locale, "pt_BR"}); - anything else — including erlydtl's default atom
default— falls back to the per-process locale viaerli18n:which_locale/0, orerli18n:default_locale/0when none is set.
Passing {locale, erli18n:which_locale()} is the recommended form: erlydtl binds
the locale once per render and hands the same binary to every node, so the fast
is_binary/1 branch is taken with no per-node process-dictionary read, and it is
safe across a spawn. The zero-config form (omit the option) composes with the
Cowboy/Elli middleware for free, but only when render/2 runs in the same
process the middleware set the locale on: per-process state is not inherited
across a spawn, so a pooled/offloaded render worker starts at
which_locale() = undefined and must be passed the locale explicitly. The locale
option must be a binary or a string: a non-default atom ({locale, de}) is not
honored (it falls back to the per-process locale), and a tuple locale is not
merely ignored — it is decoded as {Locale, Context} (so {locale, {a, b}} makes
a plain {% trans %} resolve as pgettext under context b), which no erlydtl
render actually produces but is worth knowing when passing the option by hand.
Count normalization
erlydtl performs no coercion of {% blocktrans count c=n %}; n reaches the fun
as whatever it resolved to — an integer, a float, or undefined when the template
variable is missing. count/1 normalizes it to the integer() erli18n's
ngettext requires: an integer is used as-is, a float selects via trunc/1
(erlydtl still displays the original value, so a fractional count's selection and
display can differ), and anything else (a missing variable, a binary) selects the
singular form. This keeps the fun total: a non-integer count can never reach — and
crash — erli18n's is_integer/1 guard.
Fallback on a miss
On a miss erli18n echoes the msgid, so the fun always returns a binary(), never
the atom default. For {% trans %} that echoed msgid is the source text —
identical to erlydtl's native fallback. For a plural {% blocktrans %} the two
differ by design: native erlydtl renders the singular block regardless of
count, whereas this bridge returns the count-appropriate form (C convention: the
plural msgid for N /= 1). The bridge's result is the more correct one, but it is
a deliberate behavior change, noted here so it is not mistaken for an identity.
Totality and security
The fun is total over term() by construction, which is what makes erlydtl's two
render-crash paths unreachable. erlydtl calls the fun outside its throw-only
wrapper, so any error-class exception would crash the whole render; bin/1 is
try-wrapped (a runtime _(Var) value can be a non-chardata term, and
unicode:characters_to_binary/1 raises badarg on a non-chardata type rather
than returning an error tuple) and count/1 never lets a non-integer through, so
no exception escapes.
The threat boundary is catalog ingestion, never render. erlydtl emits catalog
literal text raw and auto-escapes only the interpolated {{ var }} values, so
whoever authors a msgstr is a de-facto template author (this is erlydtl's model
for any translation_fun). Treat .po msgstrs as reviewed template-author input
and never route user or request input into a msgstr. The same rule applies to the
lookup key: a {{ _(Var) }} whose Var is untrusted and misses the catalog is
echoed verbatim (that is the miss contract) and then emitted raw by erlydtl, so
_(Var) must not carry request input either. Because the bridge is render-time
only, the returned bytes are never re-parsed, so catalog content cannot inject
template syntax.
References
- erlydtl i18n tags and the
translation_funrender option: https://github.com/erlydtl/erlydtl erli18n— the gettext facade this delegates to (gettext,pgettext,ngettext,npgettext, and the per-processsetlocale/1/which_locale/0).
Summary
Types
The erlydtl locale a render passes as the fun's second argument: the bare
locale render option, or {Locale, Context} when the tag carried a
context "…". term() because the option is caller-supplied (commonly a binary,
a string, or erlydtl's default atom default).
An erlydtl phrase as seen at the translation_fun boundary. It is term() on
purpose: a {% trans %} / {% blocktrans %} literal arrives as chardata, a
counted block as {Singular, {Plural, Count}}, and a runtime _(Var) as whatever
the variable resolved to (any term). decode/2 and bin/1 stay total over all of
them.
Functions
Decodes an erlydtl (Phrase, Locale) pair into the plan() — the gettext call to
make, with its arguments already binarized and its count normalized. Pure and
total — exported so the mapping can be property-tested without erlydtl (or a
running erli18n) loaded.
Builds a render-time translation_fun for erlydtl bound to the gettext Domain.
Types
-type dtl_locale() :: term().
The erlydtl locale a render passes as the fun's second argument: the bare
locale render option, or {Locale, Context} when the tag carried a
context "…". term() because the option is caller-supplied (commonly a binary,
a string, or erlydtl's default atom default).
-type phrase() :: term().
An erlydtl phrase as seen at the translation_fun boundary. It is term() on
purpose: a {% trans %} / {% blocktrans %} literal arrives as chardata, a
counted block as {Singular, {Plural, Count}}, and a runtime _(Var) as whatever
the variable resolved to (any term). decode/2 and bin/1 stay total over all of
them.
-type plan() :: {gettext, erli18n:msgid()} | {pgettext, erli18n:context(), erli18n:msgid()} | {ngettext, erli18n:msgid(), erli18n:msgid_plural(), integer()} | {npgettext, erli18n:context(), erli18n:msgid(), erli18n:msgid_plural(), integer()}.
The gettext call decode/2 selects, with its arguments already binarized.
Functions
-spec decode(phrase(), dtl_locale()) -> plan().
Decodes an erlydtl (Phrase, Locale) pair into the plan() — the gettext call to
make, with its arguments already binarized and its count normalized. Pure and
total — exported so the mapping can be property-tested without erlydtl (or a
running erli18n) loaded.
The plural tuple {Singular, {Plural, Count}} selects the n-family; a
{Locale, Context} second argument selects the contextual family (the locale half
of that pair is irrelevant here — resolve_locale/1 handles it). Every msgid and
context is binarized and every count normalized, so the returned plan() already
satisfies erli18n's argument guards.
-spec translation_fun(erli18n:domain()) -> fun((phrase(), dtl_locale()) -> erli18n:translation()).
Builds a render-time translation_fun for erlydtl bound to the gettext Domain.
Pass the returned fun/2 (build it once and reuse the value) as the
translation_fun render option; supply the locale via a binary/string locale
render option, or let it fall back to the per-process locale. See the module docs
for the tag mapping, locale strategy, and count/miss semantics.
TFun = erli18n_erlydtl:translation_fun(web),
{ok, Html} = home:render(Ctx, [{translation_fun, TFun}, {locale, <<"pt_BR">>}]).