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.1.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 does NOT
%% interpolate the number (you format that yourself; see "Common patterns").
<<"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">>).

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.

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 β€” ngettext gives you the form; you interpolate the number:

N = 3,
Form = erli18n:ngettext(my_domain, <<"file">>, <<"files">>, N, <<"pt_BR">>),
<<"3 arquivos">> = iolist_to_binary(io_lib:format("~b ~ts", [N, Form])).

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

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

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

{deps, [
{erli18n, "0.1.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.1.0). Per SemVer 2.0.0 Β§4, the public API is functional but may change on a minor bump (0.1.0 β†’ 0.2.0); patch bumps (0.1.0 β†’ 0.1.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