erli18n

Hex.pmHexDocsCILicense: Apache 2.0OTP 27+

Modern, GNU gettext–compatible internationalization (i18n) for Erlang/OTP β€” in pure Erlang.

Why erli18n?

It's first-class gettext i18n for Erlang/OTP, natively β€” no polyglot build, no routing through Elixir, no stalled dependency.

Quickstart

Add the dependency to rebar.config:

{deps, [{erli18n, "0.3.0"}]}.

Then load a catalog and translate:

application:ensure_all_started(erli18n).
%% Load a `.po` catalog for a (domain, locale). Parse -> compile plural rule ->
%% validate against CLDR -> insert: one atomic step. Returns {ok, NewlyLoaded}
%% (or {ok, already} if it was already loaded).
{ok, _Loaded} = erli18n_server:ensure_loaded(my_domain, <<"pt_BR">>,
<<"priv/locale/pt_BR/LC_MESSAGES/my_domain.po">>).
%% Singular.
<<"OlΓ‘, mundo">> = erli18n:gettext(my_domain, <<"Hello, world">>, <<"pt_BR">>).
%% Plural. ngettext returns the correct plural FORM for N (it selects the
%% form; the `f` family below splices the number in β€” see "Interpolation").
<<"arquivo">> = erli18n:ngettext(my_domain, <<"file">>, <<"files">>, 1, <<"pt_BR">>).
<<"arquivos">> = erli18n:ngettext(my_domain, <<"file">>, <<"files">>, 42, <<"pt_BR">>).
%% Contextual. The same source word, disambiguated by a msgctxt.
<<"Maio">> = erli18n:pgettext(my_domain, <<"month">>, <<"May">>, <<"pt_BR">>).
<<"pode">> = erli18n:pgettext(my_domain, <<"verb">>, <<"May">>, <<"pt_BR">>).
%% Interpolating. The `f`-suffix family resolves the translation, then
%% splices named `%{var}` placeholders from a Bindings map (see below).
<<"3 arquivos">> = erli18n:ngettextf(my_domain, <<"%{count} file">>,
<<"%{count} files">>, 3, <<"pt_BR">>, #{}). %% count => 3 auto-bound

That is the whole surface: gettext (singular), ngettext (plural), pgettext (contextual), and npgettext (contextual + plural), each with d / dc domain-explicit variants β€” the full GNU gettext C-macro family, as Erlang functions. Each also has an interpolating f-suffix sibling (gettextf, ngettextf, pgettextf, npgettextf) that splices named %{var} values into the resolved string.

Common patterns

Set the locale once per process (e.g. one web request) β€” then every lookup in that process uses it, with no locale argument to thread around. App-wide, set_default_locale/1 does the same for processes that never call setlocale/1:

erli18n:setlocale(<<"pt_BR">>), %% this process
%% erli18n:set_default_locale(<<"pt_BR">>), %% (or: app-wide default)
<<"OlΓ‘, mundo">> = erli18n:gettext(my_domain, <<"Hello, world">>),
<<"arquivos">> = erli18n:ngettext(my_domain, <<"file">>, <<"files">>, 42).

Set a default domain so the shortest forms work without naming it each time:

erli18n:textdomain(my_domain),
<<"OlΓ‘, mundo">> = erli18n:gettext(<<"Hello, world">>). %% default domain + resolved locale

Format a pluralized count β€” use the f-suffix ngettextf: it selects the plural form and splices the number in. The count is auto-bound as %{count}, so the translator controls where the number lands in each language:

%% Source: msgid "%{count} file" / msgid_plural "%{count} files"
%% pt_BR: msgstr[0] "%{count} arquivo" / msgstr[1] "%{count} arquivos"
<<"3 arquivos">> = erli18n:ngettextf(my_domain,
<<"%{count} file">>, <<"%{count} files">>, 3, <<"pt_BR">>, #{}).

Context + plural together (npgettext β€” domain, context, singular, plural, N, locale):

<<"comentΓ‘rios">> = erli18n:npgettext(my_domain, <<"ui">>,
<<"comment">>, <<"comments">>, 5, <<"pt_BR">>).

Load several catalogs at startup in one batch:

%% Each entry is {Domain, Locale, PoPath, Opts}; the result is one
%% {Domain, Locale, {ok, NewlyLoaded} | {ok, already} | {error, _}} per entry.
Results = erli18n_server:ensure_loaded_many([
{my_domain, <<"pt_BR">>, <<"priv/locale/pt_BR/LC_MESSAGES/my_domain.po">>, #{}},
{my_domain, <<"en_US">>, <<"priv/locale/en_US/LC_MESSAGES/my_domain.po">>, #{}}
]).

Observe at runtime with telemetry (optional) β€” for example, get notified whenever a lookup falls through to the source string:

telemetry:attach(<<"erli18n-misses">>, [erli18n, lookup, miss],
fun(_Event, _Measurements, Metadata, _Config) ->
logger:info("i18n miss: ~p", [Metadata])
end, undefined).

Interpolation

Every lookup family has an interpolating f-suffix sibling β€” gettextf, ngettextf, pgettextf, npgettextf (plus the d / dc aliases) β€” that takes a trailing Bindings :: map(). Each f function resolves the translation exactly like its non-f sibling, then substitutes named %{var} placeholders in the result:

erli18n:setlocale(<<"pt_BR">>),
%% Source msgid "Hello, %{name}!" with pt_BR msgstr "OlΓ‘, %{name}!"
<<"OlΓ‘, Ada!">> = erli18n:gettextf(my_domain, <<"Hello, %{name}!">>,
#{name => <<"Ada">>}).

Named placeholders (rather than positional ~s) decouple the wording from argument order: a translator can move %{name} anywhere in the sentence β€” or repeat it β€” and the binding still resolves by name. Binding keys are atoms; values may be a binary, an iolist/string, an integer, a float, or an atom, and are coerced to UTF-8 text. Plural members auto-bind count => N, so %{count} is always available without passing it yourself (a caller-supplied count wins):

%% pt_BR msgstr[1] "%{count} arquivos" β€” count auto-bound to 42
<<"42 arquivos">> = erli18n:ngettextf(my_domain,
<<"%{count} file">>, <<"%{count} files">>, 42, <<"pt_BR">>, #{}).

Escaping. A literal percent is %%; to emit a literal %{name} un-substituted, write %%{name} (the %% collapses to %, leaving {name} untouched):

<<"100% sure">> = erli18n:gettextf(<<"100%% sure">>, #{}).
<<"use %{name}">> = erli18n:gettextf(<<"use %%{name}">>, #{name => <<"X">>}).

Missing bindings β€” lenient vs strict. The f functions on erli18n are lenient: an unbound %{name} is left in place literally and nothing crashes. Interpolation is total and fail-soft β€” for any input and any bindings it returns a binary and never raises. When you want an unbound placeholder to be a hard error instead, call erli18n_interp:format/3 directly with the strict policy:

%% Lenient (the f-family default): unknown placeholder stays literal.
<<"Hi %{who}">> = erli18n:gettextf(<<"Hi %{who}">>, #{}).
%% Strict: opt in via erli18n_interp:format/3 β€” raises on a missing binding.
erli18n_interp:format(<<"Hi %{who}">>, #{}, #{on_missing => strict}).
%% ** exception error: {erli18n_interp, {missing_binding, who}}

Bidi / RTL caveat

Interpolation does not auto-insert Unicode bidi isolation marks (U+2066–U+2069) around spliced values. Placing an RTL value (Arabic, Hebrew) into an LTR sentence β€” or the reverse β€” can reorder neighbouring punctuation under the Unicode Bidirectional Algorithm. If you mix directions, isolate the values yourself until a future version offers opt-in isolation.

Locale negotiation & fallback (opt-in)

Catalogs are keyed by exact binary, so by default a pt_BR request only matches a pt_BR catalog. Phase 2 adds two opt-in pieces that close the common gaps β€” without changing the default exact-match behavior or touching the lock-free hot path.

1. Request-time negotiation (erli18n_negotiate, exposed on the facade). Pick the best locale a client supports from those you have loaded. parse_accept_language/1 turns an HTTP header into a priority-ordered list; negotiate/2 resolves it (with BCP-47 canonicalization and base-language fallback) against your available set, always returning a usable locale:

Available = [<<"en">>, <<"pt">>, <<"de">>],
%% Hyphenated, mixed-case, and legacy tags all canonicalize to match.
{ok, <<"pt">>} = erli18n:negotiate([<<"pt-BR">>], Available),
%% Straight from an Accept-Language header (q-values honored, q=0 dropped).
{ok, <<"de">>} = erli18n:negotiate(
erli18n:parse_accept_language(<<"fr-CH, de;q=0.9, en;q=0.5">>),
Available),
%% One-off tag canonicalization to the catalog-key shape.
<<"pt_BR">> = erli18n:canonicalize_locale(<<"PT-br.UTF-8">>).

A typical web handler negotiates once per request and calls setlocale/1:

Prefs = erli18n:parse_accept_language(AcceptLanguageHeader),
{ok, Locale} = erli18n:negotiate(Prefs, my_supported_locales()),
erli18n:setlocale(Locale).

2. Lookup-time fallback chain (erli18n.locale_fallback, default off). When enabled, a lookup that misses the exact locale walks a canonicalization-aware BCP-47 chain before falling back to the msgid, so a pt_BR user reads a loaded pt catalog:

%% Only a "pt" catalog is loaded.
<<"Hello">> = erli18n:gettext(my_domain, <<"Hello">>, <<"pt_BR">>), %% off: raw msgid
erli18n:set_locale_fallback(base_language),
<<"OlΓ‘"/utf8>> = erli18n:gettext(my_domain, <<"Hello">>, <<"pt_BR">>). %% pt_BR -> pt

locale_fallback accepts off (default), base_language (pt_BR β†’ pt β†’ default_locale), or {explicit, Map} where Map :: #{locale() => [locale()]} overrides specific locales (unlisted ones fall through to base_language). The chain runs only on a miss and only when enabled, so an exact hit stays a single ets:lookup with zero added cost. Canonicalization covers separator (pt-BR/pt_BR) and case normalization plus a closed legacy-alias set (iwβ†’he, inβ†’id, jiβ†’yi, jwβ†’jv, moβ†’ro). Script⇄region Likely Subtags inference (zh_Hans ⇄ zh_CN) is an explicit non-goal β€” load catalogs under the keys your clients send, or use an {explicit, Map}.

Core concepts

A few things worth knowing before you reach for the API:

Why erli18n

Most Erlang projects today either reach for the venerable but largely-stalled gettexter, or route strings through Elixir's gettext (which forces a polyglot build). erli18n is for projects that want first-class i18n in pure Erlang/OTP without giving up compatibility with the standard gettext translation tooling.

String extraction uses the standard GNU xgettext CLI β€” the same model as Spring MessageSource, Django, Rails I18n, and Symfony Translation. Compile-time key checking is intentionally out of scope; runtime lookup plus tests is the mainstream pattern.

Installation

{deps, [
{erli18n, "0.3.0"}
]}.

For telemetry observability (optional β€” erli18n runs fine without it), add it too:

{deps, [
{erli18n, "0.3.0"},
{telemetry, "~> 1.3"}
]}.

Compatibility

OTP 27 (minimum)OTP 28
Tier-1 (CI)βœ…βœ…

OTP 27 is the floor because the public modules use the native -doc / -moduledoc documentation attributes (EEP-59), which only compile on OTP 27+; on OTP 25.3 / 26 the compiler rejects them with attribute doc after function definitions. CI exercises OTP 27 and 28 on every push.

Status

Initial development (0.3.0). Per SemVer 2.0.0 Β§4, the public API is functional but may change on a minor bump (0.3.0 β†’ 0.4.0); patch bumps (0.3.0 β†’ 0.3.1) stay backward-compatible. The criteria for a stable 1.0.0 are in CHANGELOG.md.

Documentation

Development

git clone git@github.com:eagle-head/erli18n.git
cd erli18n
rebar3 compile
bin/quality-gate.sh --fast # ~30s: compile + xref + erlfmt + elvis + hank + elp lint
bin/quality-gate.sh --full # ~5min: + dialyzer + eqwalize-all + Common Test (+ coverage)

See CONTRIBUTING.md for the full setup: toolchain pinning with mise, git hooks, local CI emulation with act, and the contribution workflow.

Security

To report a vulnerability, see SECURITY.md β€” please do not open a public GitHub issue for security reports.

License

Apache License 2.0 (SPDX: Apache-2.0).

References