Erli18n - GNU gettext-style i18n for Erlang/OTP, now with a rebar3 catalog-tooling plugin

Hi everyone! :waving_hand:

I’d like to introduce erli18n — a GNU gettext – style internationalization library for Erlang/OTP, written in pure Erlang. It’s been on Hex for a few releases (now v0.5.0), and the latest one added a companion rebar3 plugin for the catalog workflow, so it felt like the right moment to introduce it here.

Upfront and honest: this started as a learning project — a deep dive into OTP (gen_server + supervision, persistent_term, the .po format and CLDR plural rules, PropEr property/fuzz testing, telemetry, native EEP-59 docs, and the whole Hex release pipeline). I worked hard to make it correct and thoroughly tested (100% coverage, plus a parity suite that checks output byte-for-byte against GNU msgfmt), but I’d still frame it as a serious learning project rather than battle-hardened infra — and that’s exactly why I’m here: I want blunt, critical feedback from people who’ve written a lot of Erlang.

The library — the full gettext macro family as plain Erlang functions:

application:ensure_all_started(erli18n).

{ok, _} = erli18n_server:ensure_loaded(my_domain, <<"pt_BR">>,
    <<"priv/locale/pt_BR/LC_MESSAGES/my_domain.po">>).

<<"Olá, mundo">> = erli18n:gettext(my_domain, <<"Hello, world">>, <<"pt_BR">>).

%% ngettext picks the correct plural form for N (real CLDR rules)
<<"arquivos">> = erli18n:ngettext(my_domain, <<"file">>, <<"files">>, 42, <<"pt_BR">>).

%% pgettext for context
<<"Maio">> = erli18n:pgettext(my_domain, <<"month">>, <<"May">>, <<"pt_BR">>).

%% named %{var} interpolation via the f-suffix family — note: (domain, msgid, locale, bindings)
<<"Olá, Ana">> = erli18n:gettextf(my_domain, <<"Hello, %{name}">>, <<"pt_BR">>,
                                  #{name => <<"Ana">>}).

What I focused on:

  • :package: Drop-in .po / .pot — loads what translators already produce in Poedit, Weblate, Crowdin, or xgettext; output is checked byte-for-byte against GNU msgfmt as a ground-truth oracle.
  • :globe_showing_europe_africa: Real CLDR pluralization — an actual Plural-Forms evaluator with CLDR rules inlined, not a naive n == 1 split.
  • :input_latin_letters: Named %{var} interpolation — the f-suffix family (gettextf / ngettextf / pgettextf / …) layered on top of the positional gettext API.
  • :compass: Locale negotiation + fallbackAccept-Language parsing (BCP-47, q-values) and an opt-in RFC 4647 fallback chain (pt_BRpt), both kept off the exact-hit hot path.
  • :high_voltage: Copy-free reads — catalogs live in persistent_term, one term per {domain, locale}, so a lookup returns a pointer into the literal area instead of copying the term onto the caller’s heap. (Migrated from ETS in 0.4.0 after forum feedback — lock-free isn’t copy-free; the one tradeoff, a literal-area GC per reload, is documented up front.)
  • :bar_chart: Optional telemetry — catalog spans, lookup misses, plural divergence, memory warnings; telemetry is an optional dependency.
  • :white_check_mark: Heavily tested — Common Test + PropEr + fuzzing, 100% coverage, and the msgfmt parity oracle.

New in 0.5.0 — rebar3_erli18n, a build-time catalog-tooling plugin ({plugins, [rebar3_erli18n]}, build-only — it never ships in your release):

rebar3 erli18n extract   # scan source for gettext call sites -> .pot templates
rebar3 erli18n merge     # .pot -> .po, preserving msgstr, fuzzy-matching, obsolete
rebar3 erli18n check     # CI gate: fail if catalogs drift from source
rebar3 erli18n report    # translated / fuzzy / missing, per {domain, locale}

It’s the xgettext + msgmerge half of the gettext workflow, native to rebar3: extract message ids straight from your source, merge them into catalogs without clobbering existing translations, and gate freshness in CI. (One honest caveat, same as gettext: only compile-time literal msgids are statically discoverable — a runtime-computed id still translates, it just can’t be auto-extracted.)

(Pure Erlang, OTP 27+, Apache-2.0. It’s a normal Hex dependency, so it’s callable from Elixir too — but its natural home is an Erlang / rebar3 project.)

Why I’m posting: I’d genuinely love feedback — on the API design, the OTP patterns, the persistent_term tradeoffs, the plugin’s extractor, anything that makes a seasoned BEAM dev wince. And if you have an Erlang project that wants gettext-style i18n without routing through Elixir’s build, please try it and tell me where it breaks.

Links:

If you find it useful — or even just interesting to read — a :star: on GitHub helps me gauge whether it’s worth continuing. Thanks for reading, and any feedback, however harsh, is hugely appreciated! :folded_hands:

4 Likes

erli18n 0.6.0 is out on Hex, along with rebar3_erli18n 0.1.1.

The new piece is optional per-request locale middleware: erli18n_cowboy (a cowboy_middleware) and erli18n_elli (an elli_middleware). Each negotiates the request locale from an ordered set of sources — query string, then cookie, then the Accept-Language header by default, configurable — and calls erli18n:setlocale/1 before the handler runs, so handlers translate with no locale argument. The chosen locale is also written into the Cowboy Env and, by default, into logger metadata.

Both delegate to a new pure, framework-agnostic core, erli18n_http (negotiate_locale/3, cookie_value/2, query_value/2), which you can call directly to wire a framework the bundled adapters don’t cover.

cowboy and elli are declared in optional_applications only — they are not runtime dependencies — so the published package still builds and runs on kernel + stdlib alone; you add whichever framework you already use. Also new: erli18n:loaded_locales/0, the authoritative available-locale set, backed by a persistent_term index.

Two correctness fixes: interpolation truncation now cuts on UTF-8 codepoint boundaries, and erli18n_po:escape_string/1 is total over any binary. rebar3_erli18n 0.1.1 makes the extract and merge providers return a structured error instead of crashing on a catalog write failure.

erli18n 0.7.0 is out on Hex, along with rebar3_erli18n 0.2.0.

The new piece is optional compile-time catalog codegen. The rebar3 plugin gains a compile provider: rebar3 erli18n compile reads each (Domain, Locale) .po, parses it and compiles its Plural-Forms rule ahead of time, and emits a small generated carrier module (erli18n_cc_<Domain>__<Locale>) that holds the already-parsed entries and the already-compiled rule in the BEAM literal pool. At boot the consuming app calls erli18n:register_compiled_catalogs/1 once in its start/2, which installs those catalogs with no .po read, no parse, and no plural compile.

It is opt-in and additive. The provider does nothing unless {compiled_catalogs, true} is set in rebar.config, the default runtime path (erli18n:ensure_loaded/3,4 over .po files in priv/) is unchanged, and the read hot path is byte-for-byte identical — a project that does not opt in sees no change. register_compiled_catalogs/1 composes with runtime loading and is idempotent: a catalog already present reports {ok, already}.

Two build-time additions ride along: an opt-in key-existence check (off | warn | strict) that flags facade call sites whose {Context, Msgid} has no matching compiled key, and size/entry caps that mirror the runtime loader so a compiled carrier can never carry more than a runtime load would accept. rebar3_erli18n 0.2.0 requires erli18n ~> 0.7 — the codegen targets the new register_compiled_catalogs/1 and its public types.

erli18n 0.8.0 is out on Hex. This one is library-only — the rebar3_erli18n plugin is unchanged at 0.2.0.

The new piece is an optional erlydtl template bridge, erli18n_erlydtl. It lets an erlydtl template translate its {% trans %} / {% blocktrans %} tags through erli18n’s gettext core — contexts, CLDR plurals, per-request locale — while erlydtl keeps ownership of {{ var }} interpolation and auto-escaping. erli18n_erlydtl:translation_fun/1 returns a render-time fun bound to a gettext domain that you pass in erlydtl’s render/2 options; the pure decode/2 maps {% trans %}gettext, contextpgettext, a counted {% blocktrans %}ngettext, and context + count → npgettext.

To be straight about the Elixir angle: erlydtl is Erlang’s Django-style template engine, and most Elixir apps reach for EEx/HEEx with Gettext instead — so this is niche here. It matters if you actually render erlydtl templates: a mixed Erlang/Elixir app, or a ported Django project. erli18n has no Elixir dependency; from Elixir you would build the fun with :erli18n_erlydtl.translation_fun(:web) and hand it to :erlydtl’s render options. If you know Gettext, the tags map onto the same four calls Gettext exposes (gettext / pgettext / ngettext / npgettext), and the locale composes with the per-process :erli18n.setlocale(locale) you would set in a Plug — the same spot as Gettext.put_locale/1.

The integration is inverted: erli18n_erlydtl references zero erlydtl functions (erlydtl calls into the fun), so it is not an optional_applications entry and the published package still builds on kernel + stdlib alone — erlydtl is a test-only dependency you pull in only if you use it. A runnable example is in examples/erli18n_erlydtl_demo.