Modern, GNU gettextβcompatible internationalization (i18n) for Erlang/OTP β in pure Erlang.
Why erli18n?
It's first-class
gettexti18n for Erlang/OTP, natively β no polyglot build, no routing through Elixir, no stalled dependency.
- π¦ Drop-in
.po/.potβ load the exact files your translators already produce in Poedit, Crowdin, Transifex, Weblate, orxgettext.- π Real CLDR pluralization β a true
Plural-Formsevaluator, with CLDR rules inlined for 49 locales.- β‘ Lock-free lookups β reads run straight from ETS in your own process; only writes go through a
gen_server. No bottleneck on the hot path.
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 localeFormat 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:
- Locale is per-process.
erli18n:setlocale(<<"pt_BR">>)sets the locale for the calling process (stored in its process dictionary);which_locale/0reads it back. It is not inherited by processes youspawn. When a process hasn't set one, lookups fall back to the application-wide default. Passing the locale explicitly always wins. - Catalogs are keyed by domain + locale. A domain is a gettext text domain (e.g.
my_domain) β your way of grouping translations. You load each(domain, locale)catalog once; lookups then target a domain explicitly or use the default. - The
.poheader drives pluralization. Each catalog'sPlural-Formsheader is the runtime source of truth for plural selection. CLDR rules (inlined for 49 locales) are consulted only at load time β to emit a telemetry warning when a header diverges from CLDR, never to override it. - Misses degrade gracefully. A lookup with no catalog, no entry, or an empty translation returns the original
msgid(ormsgid_plural), so your UI never shows a blank. And a crash of the catalog server does not wipe loaded translations: the ETS table is held by a dedicated owner/heir, so it survives and is handed back intact on restart.
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.
- Drop-in
.po/.potcompatibility β a hand-written parser that handles real-world catalogs: contexts, plurals, fuzzy entries, charsets, BOMs, and obsolete entries. Works with Poedit, Crowdin, Transifex, Weblate, andmsgfmtout of the box. (The exact.po-semantics decisions are documented inCHANGELOG.md.) - CLDR-backed pluralization β a real evaluator for the
Plural-FormsC-expression, with CLDR plural rules inlined for 49 locales. - The full gettext API β
gettext/ngettext/pgettext/npgettext, plus thed/dcdomain-explicit variants. - Optional, first-class observability β 7
telemetryevents (catalog load/reload/unload spans, lookup misses, plural divergence, rate-limited memory warnings).telemetryis an optional dependency: events fire only when your app ships it. - A lock-free hot path β
lookup_*reads run directly from ETS in the calling process; only writes (loading and reloading catalogs) go through the owninggen_server. No process bottleneck on the read side. - Heavily tested β Common Test suites, PropEr property-based tests, fuzzing, and a parity suite that checks output byte-for-byte against GNU
msgfmtas a ground-truth oracle. 100% behavioral coverage.
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
- API reference β published on HexDocs, generated from the native
-doc/-moduledocattributes (OTP 27+ documentation). Every public module and function is documented there. - Changelog & design decisions β
CHANGELOG.mdrecords each release, the versioning policy, and the.po-semantics and pluralization decisions behind the implementation. - Examples β the
.pofixtures undertest/cover plural forms, contexts, fuzzy entries, encodings, and edge cases β a practical reference for whaterli18naccepts.
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
- GNU gettext manual β
.poformat and runtime semantics. - Unicode CLDR plural rules β pluralization data source.
telemetryβ the observability framework.gettexterβ historical Erlang gettext library whose API surfaceerli18nmirrors for easy migration.