eagle-head
Hi everyone! ![]()
I just published my first Hex package, erli18n — a GNU gettext–compatible internationalization library for Erlang/OTP, written in pure Erlang (and callable from Elixir too, since it’s a normal Hex dep: :erli18n.gettext(...)).
I want to be upfront about one thing: the main goal of this project is learning. I built it to dig deep into Erlang/OTP — gen_server + supervision, ETS ownership/heir patterns, the .po format and CLDR plural rules, property-based testing with PropEr, telemetry, native EEP-59 docs, and the whole Hex release pipeline. So please read it as a 0.1.0 learning project rather than battle-tested production infra — though I worked hard to make it correct and thoroughly tested.
What it does — the full GNU gettext C-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 returns the correct plural FORM for N (you format the number yourself)
<<"arquivo">> = erli18n:ngettext(my_domain, <<"file">>, <<"files">>, 1, <<"pt_BR">>).
<<"arquivos">> = erli18n:ngettext(my_domain, <<"file">>, <<"files">>, 42, <<"pt_BR">>).
%% pgettext for context; npgettext for context + plural
<<"Maio">> = erli18n:pgettext(my_domain, <<"month">>, <<"May">>, <<"pt_BR">>).
A few things I focused on:
-
Drop-in .po/.pot— loads the files translators already produce in Poedit, Crowdin, Weblate, orxgettext. -
Real CLDR pluralization — an actual Plural-Formsevaluator, CLDR rules inlined for 49 locales. -
Lock-free lookups — reads run straight from ETS in the calling process; only writes go through a gen_server, so there’s no bottleneck on the hot path. -
Optional telemetry — 7 events (catalog spans, lookup misses, plural divergence, memory warnings); telemetryis an optional dependency. -
Heavily tested — Common Test + PropEr + fuzzing, plus a parity suite that checks output byte-for-byte against GNU msgfmtas a ground-truth oracle.
(Pure Erlang, OTP 27+, Apache-2.0.)
Why I’m posting here:
-
I’d genuinely love feedback — on the API design, the OTP patterns, anything that makes a seasoned BEAM dev wince. Since I’m here to learn, blunt and critical opinions are exactly what I’m after.
-
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:
-
GitHub:
If you find it useful or even just interesting to read, a
on GitHub would mean a lot and helps me gauge whether it’s worth continuing. Thanks for reading — any feedback, however harsh, is hugely appreciated! ![]()
Trending in Announcing
Other Trending Topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #blog-post
- #phoenix_html
- #iex
- #ai
- #graphql
- #genstage
- #elixirconf-us
- #websockets
- #supervisor
- #advent-of-code
- #distillery
- #processes
- #api
- #forms
- #metaprogramming
- #security
- #hex











Showing Posts 1 to 9- Show Best Posts
- Show All (oldest first)
- Show All (newest first)
eagle-head
Quick update for anyone following erli18n: v0.2.0 is on Hex.
This release adds named
%{var}interpolation on top of the gettext API. It’sfully additive — the existing
gettext/ngettext/pgettext/npgettextfamilies are unchanged, so upgrading is a no-op for code you already wrote.
There’s a new
f-suffix family (gettextf,ngettextf,pgettextf,npgettextf,plus the
d/dcvariants). Each one resolves the translation exactly like itsnon-
fsibling, then splices values from a trailing bindings map by name. Bindingby name means a translator can reorder or repeat a variable and it still resolves —
wording is decoupled from argument order. It’s total and fail-soft (never raises;
an unbound
%{name}is left literal), with anti-DoS output/expansion caps.Basic — one named variable:
Advanced — pluralization with an auto-bound
%{count}, plus name-based reordering:Callable from Elixir too, since it’s a normal Hex dep:
:erli18n.gettextf(...)eagle-head
Quick update for anyone following erli18n: v0.3.0 is on Hex.
This release adds opt-in locale negotiation and a lookup-time fallback chain.
Both are additive and off by default — upgrading is a no-op, the exact-match
lookup hot path is byte-for-byte unchanged, and every existing call behaves exactly
as in 0.2.0.
Two new pieces:
ones you actually loaded, with BCP-47 canonicalization (hyphen/underscore, casing,
a closed legacy-alias set).
parse_accept_language/1turns an HTTP header into apriority-ordered list;
negotiate/2resolves it and always returns a usablelocale (it defaults to your
default_localeon no match).erli18n.locale_fallback, defaultoff) — whenenabled, a lookup that misses the exact locale walks a canonicalization-aware
RFC 4647 chain before returning the
msgid, so apt_BRuser reads a loadedptcatalog instead of seeing the raw key.Basic — negotiate the best locale from an
Accept-Languageheader:Advanced — the opt-in lookup fallback chain (with canonicalization and an explicit override):
A typical web handler negotiates once per request, then sets the locale:
There’s also a new opt-in
[erli18n, locale, fallback]telemetry event (under theexisting
emit_lookup_telemetryflag) that fires when a non-exact locale serves atranslation, with a
chain_depthmeasurement — so you can see how often clients leanon the fallback. The negotiation engine is pure, total, and dependency-free, and the
whole feature stays off the exact-hit path.
(Callable from Elixir too, since it’s a normal Hex dep:
:erli18n.negotiate(...).)Full notes are in the CHANGELOG. As always, feedback — especially the critical
kind — is hugely appreciated.
LostKobrakai
ETS might be lock free access, but still does a copy per access. For rarely changing values like translations
:persistent_termcan remove the term copying on reads by requiring a global garbage collection on writes. Unless you’re doing heavy runtime modifications to translations that tradeoffs is probably worthwhile.eagle-head
@LostKobrakai — thank you, this was exactly right, and it changed the project’s route.
You nailed the distinction I’d glossed over: “lock-free” is not “copy-free.” ETS
read_concurrencyremoves the contention, butets:lookup/2still copies the matched term onto the calling process heap on every read — and for the short binaries that UI strings usually are, that copy is real on the common path.persistent_term, as you said, lets reads return a pointer into the literal area with no copy at all, paid for by a node-wide GC on writes. And your closing caveat was the heart of it: that tradeoff is worthwhile unless you’re doing heavy runtime modifications to translations.So I did the measured-before-you-swap thing rather than rushing it: I wrote a dedicated ETS-vs-
persistent_termbenchmark for the read hot path. For this read-mostly, load-once workload,persistent_termreads came out ~55% faster (copy-free), which matched your reasoning, so I shipped the migration.erli18n 0.4.0 is now live on Hex with exactly the improvement you suggested:
the catalog storage engine moved from ETS to
persistent_term. One persistent term per{Domain, Locale}catalog now holds a single map of the entries plus its header; reads are copy-free and lock-free from the calling process, and a whole-catalog (re)load is a singlepersistent_term:put.And I took your tradeoff on honestly rather than hiding it:
reload/3,4andunload/2now pay the node-wide literal-area GC you described — once per (re)load. That’s negligible for the load-once-at-boot case erli18n targets and costlier under heavy runtime catalog churn, so it’s documented as a tradeoff in the CHANGELOG and the module docs. The public gettext API and all the lookup/fallback semantics are unchanged.Full write-up in the 0.4.0 post below. Genuinely grateful — this is the kind of feedback I was hoping for.
eagle-head
Quick update for anyone following erli18n: v0.4.0 is on Hex.
This release is an internal storage-engine migration: the translation-catalog
substrate moved from ETS to
persistent_term. It’s fully invisible tothe public API — the
gettext/ngettext/pgettext/npgettextfamilies,the
f-suffix interpolation family, and all lookup / fallback / idempotencysemantics are byte-for-byte unchanged, so upgrading is a no-op for code you
already wrote.
This one came straight from forum feedback (thank you, @LostKobrakai — more
on that below). The short version: lock-free is not copy-free. The old design
read catalog rows from a lock-free ETS table, but
ets:lookup/2still copiesthe matched term onto the calling process heap on every read. For UI strings —
often short, rarely changing, loaded once — that per-read copy is real cost on
the hot path.
What changed:
erli18n_pt_store— one persistent term per{Domain, Locale}catalog, holding a single map of the catalog’s entries plus its header. Reads
are now
persistent_term:get/2+maps:get/3, both copy-free: the termlives in the literal area and lookups return a pointer into it, so even short
binaries are no longer copied onto the caller heap. A whole-catalog (re)load is
a single
persistent_term:put/2.persistent_termbenchmarkconfirmed the win:
persistent_termreads are ~55% faster on the read hotpath for this read-mostly, load-once workload.
persistent_termis node-global and runtime-owned,a worker crash destroys nothing — so the entire ETS crash-durability machinery
is gone: the
erli18n_table_ownerheir module, theETS-TRANSFER/give_awayhandoff, and the secondary catalog index all retired. The supervisor collapses
to a single
erli18n_serverchild underone_for_one.The honest tradeoff (the one @LostKobrakai named):
reload/3,4andunload/2now trigger a node-wide
persistent_termliteral-area GC — paid once per(re)load. For erli18n’s target — load catalogs once at boot — this is
negligible. Under heavy runtime catalog churn (aggressive reload) it’s a real
cost the previous ETS storage didn’t have. It’s documented up front in the
CHANGELOG and in the
erli18n_server/erli18n_pt_storemodule docs, neverhidden.
(The project already used
persistent_termfor the read-mostly CLDR plural AST,so this just extends an idiomatic pattern to the catalogs themselves.)
(Callable from Elixir too, since it’s a normal Hex dep — nothing about the call
surface changed:
:erli18n.gettext(...).)Full notes are in the CHANGELOG. As always, feedback — especially the critical
kind — is hugely appreciated.
eagle-head
erli18n 0.5.0 is out on Hex, along with a new companion rebar3 plugin for the catalog workflow.
The new piece is
rebar3_erli18n, a build-only (opt-in) plugin that adds four rebar3 commands:extract— scans source for gettext/ngettext/pgettext call sites and writes a.potper domainmerge— merges a.potinto a.po, keeping existing translations, fuzzy-matching renamed ids and marking removed ones obsoletereport— translated/fuzzy/missing counts per {domain, locale}check— fails in CI when the committed catalogs have drifted from the sourceSame limitation as gettext: only literal message ids are statically extractable.
gettext(<<"Hello">>)is found;gettext(Var)still translates at runtime but won’t be picked up by the scan.The runtime library bump is minor and additive:
erli18n_po:escape_string/1is now exported so the library and the tooling share the same PO serializer, plus some PO-parser hardening. The lookup/interpolation/fallback API is unchanged, so upgrading is a no-op.eagle-head
erli18n 0.6.0 is out on Hex, along with rebar3_erli18n 0.1.1.
The new piece is optional per-request locale middleware for Cowboy and Elli. It negotiates the request locale (query string, then cookie, then the Accept-Language header — configurable) and calls erli18n:setlocale/1 before your handler runs, so handlers translate with no locale argument.
For a Phoenix/Plug stack: erli18n has no Elixir dependency. Use the Cowboy adapter, or call :erli18n.setlocale(locale) from a small Plug in the request process — the same spot you would call Gettext.put_locale/1. The canonical locale form (“pt_BR”) already matches Gettext’s, so the two agree if you set both from that one place.
cowboy and elli are optional (declared in optional_applications), so the published package still builds on kernel + stdlib alone — you add whichever you already use. The adapters share a pure core, erli18n_http, you can call directly for frameworks they don’t cover, and there is a new erli18n:loaded_locales/0 for the set of locales you have actually loaded.
Also fixed: interpolation truncation now respects UTF-8 codepoint boundaries (no split codepoints at the size cap), and erli18n_po:escape_string/1 is now total over any binary. rebar3_erli18n 0.1.1 makes extract and merge return a clean error instead of crashing when a catalog file can’t be written.
eagle-head
erli18n0.7.0 is out on Hex, along withrebar3_erli18n0.2.0.The new piece is optional compile-time catalog codegen. The rebar3 plugin’s new compile provider (
rebar3 erli18n compile) reads each(Domain, Locale).po, parses it and compiles itsPlural-Formsrule ahead of time, and emits a small generated carrier module that holds the already-parsed entries and the already-compiled rule. The app callserli18n:register_compiled_catalogs/1once at startup, and boot does no.poread, no parse, and no plural compile.If you know Gettext: this is the opt-in “translations baked in at build time” path, in the same spirit as Gettext embedding your PO data at compile time — except
erli18nkeeps runtime.poloading (erli18n:ensure_loaded/3,4) as the default, so you choose per project.erli18nhas no Elixir dependency; from a Phoenix/Plug app you would call:erli18n.register_compiled_catalogs(:my_app)from your applicationstart/2, the same one-time boot setup you already do.It is opt-in and additive: the provider is a no-op unless
{compiled_catalogs, true}is set inrebar.config, the runtime path and the read hot path are unchanged, andregister_compiled_catalogs/1is idempotent and composes with runtime loading. Two build-time extras: an opt-in key-existence check (off|warn|strict) for facade call sites with no matching compiled key, and size/entry caps that mirror the runtime loader.rebar3_erli18n0.2.0 requireserli18n ~> 0.7.eagle-head
erli18n0.8.0 is out on Hex. This one is library-only — therebar3_erli18nplugin 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 througherli18n’s gettext core — contexts, CLDR plurals, per-request locale — while erlydtl keeps ownership of{{ var }}interpolation and auto-escaping.erli18n_erlydtl:translation_fun/1returns a render-time fun bound to a gettext domain that you pass in erlydtl’srender/2options; the puredecode/2maps{% trans %}→gettext,context→pgettext, a counted{% blocktrans %}→ngettext, andcontext+ 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.
erli18nhas 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 asGettext.put_locale/1.The integration is inverted:
erli18n_erlydtlreferences zero erlydtl functions (erlydtl calls into the fun), so it is not anoptional_applicationsentry and the published package still builds onkernel+stdlibalone — erlydtl is a test-only dependency you pull in only if you use it. A runnable example is inexamples/erli18n_erlydtl_demo.