erli18n_erlydtl (erli18n v0.8.0)

Copy Markdown View Source

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 nodeerli18n 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 locale render 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 via erli18n:which_locale/0, or erli18n:default_locale/0 when 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_fun render option: https://github.com/erlydtl/erlydtl
  • erli18n — the gettext facade this delegates to (gettext, pgettext, ngettext, npgettext, and the per-process setlocale/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.

The gettext call decode/2 selects, with its arguments already binarized.

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

dtl_locale()

-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).

phrase()

-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.

plan()

-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

decode/2

-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.

translation_fun(Domain)

-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">>}]).