All notable changes to erli18n will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning 2.0.0.
Versioning policy
Per SemVer 2.0.0 §4, this project is in the 0.x.y initial-development phase:
0.x.y→0.x.y+1(patch): backward-compatible bug fixes only.0.x.y→0.x+1.0(minor): may introduce backward-incompatible changes, announced in advance via CHANGELOG. Additive changes (new functions, new arities, new opt-in flags, new telemetry events) are the norm.- Telemetry events are versioned per the schema policy documented in the
erli18n_telemetrymodule-moduledoc; events marked@stablecannot change schema within0.xseries, events marked@unstablemay.
Criteria for 1.0.0
The 1.0.0 release commits to API stability. Tag bumps to 1.0.0 only when all of the following hold:
- At least one external project uses
erli18nin production for ≥ 6 months without reporting breaking issues. - The Post-0.1.0 Roadmap items that affect public API surface (charset support, hot upgrade behavior, async load) are either implemented or formally rejected with rationale.
- Parity SUITE (
erli18n_parity_SUITE) passes end-to-end against the real GNUgettext/ngettextCLI (gettext-tools≥ 0.21) as oracle (currently 6 scenarios; target ≥ 20 covering the full PSD-001…009 semantics matrix). - No unfixed
@unstabletelemetry events remain — all events either promoted to@stableor removed. - CHANGELOG documents zero behavioral changes for at least 2 consecutive minor releases.
Unreleased
[0.6.1] — 2026-06-28
Documentation and packaging patch. No public API or runtime behavior change — the shipped CLDR plural rules are byte-identical to 0.6.0.
Added
- Committed CLDR plural seed (
priv/gettext/plural_forms.eterm) plus a generator (bin/gen-plural-table.escript) that regenerates the inlineerli18n_plural:cldr_data/0table from it. The seed is the single source of truth and the diffable target for a live GNUgettextparity gate, which rebuilds the expected table from the real toolchain on every run and fails on any divergence.
Changed
- Documentation now describes the CLDR plural rules by their source — the
upstream GNU
gettext/ Unicode CLDR data the table tracks — instead of a fixed locale count, and drops forward-looking feature promises. The README, the narrative guides, and the module docs are aligned with the OTP 27/28/29 gate.
0.6.0 — 2026-06-25
Phase 5: per-request localization middleware for Cowboy and Elli, plus the
pure negotiation core, structural performance/correctness fixes on the new
per-request path, and two general latent-bug fixes surfaced by a test-adequacy
audit (UTF-8 truncation in the interpolator, non-UTF-8 byte escaping in the PO
serializer). Additive under the 0.x SemVer policy — new optional adapter
modules, a new public core module, and one new facade function; the default
kernel + stdlib build is unchanged.
Added
Per-request localization middleware for Cowboy and Elli (roadmap Phase 5). Two new optional adapter modules make per-request locale negotiation turnkey:
erli18n_cowboy— acowboy_middlewarethat negotiates the request locale and callserli18n:setlocale/1before the handler runs.erli18n_elli— the Ellielli_middlewarecounterpart (preprocess/2).
Both delegate to the existing
erli18n_negotiateengine via a new pure, framework-agnostic core,erli18n_http, which resolves the locale from an ordered set of sources — default precedence query > cookie >Accept-Languageheader > default (configurable), with cookie/query overrides canonicalized and the header parsed by the fail-soft RFC 9110 parser. The chosen locale is also placed in the CowboyEnv(erli18n_locale) and, by default, inloggerprocess metadata.Per-request resolution is lazy and short-circuiting: each source is extracted only when it is reached, and negotiation stops at the first source that yields a supported locale — so a request answered by an earlier-precedence source never pays for the cookie split or header parse of the later ones. The adapters resolve
available/defaultlazily too:erli18n:loaded_locales/0is forced only once a source actually yields a value,erli18n:default_locale/0only on a total miss, and an explicitly-suppliedavailable/defaultis zero-cost. Both the Cowboy and Elli query seams are total and fail-soft: each adapter feeds the raw query binary (from the framework's own total accessor —cowboy_req:qs/1,elli_request:query_str/1) to a single pure-core parser,erli18n_http:query_value/2, instead of the framework's raising query decoder. A value-less?localeand a malformed percent-escape (?locale=%ZZ, a bare?%, a truncated?locale=%E0%) are skipped rather than crashing the request. Per-request option values are validated at therun/2boundary: a malformeddefault(non-binary) oravailable(not a list, or a list with bad elements) is dropped so the documented default applies (erli18n:default_locale/0/erli18n:loaded_locales/0), emitting a one-timelogger:warning— operator misconfiguration is fail-soft-and-observable, never request-fatal.cowboyandelliare optional in the same way astelemetry: they are declared inoptional_applicationsand are not runtime dependencies of the published package, which still builds and runs onkernel+stdlibalone. The module docs document the per-process / not-inherited-across-spawn locale model and the broader cross-process handoff hazard (pooled workers, sharedgen_servers,Task-style spawns, Cowboy stream handlers that offload), the mitigations, and a Phoenix interop note (no Elixir dependency).erli18n:loaded_locales/0— returns the distinct, sorted locales that have at least one catalog loaded: the authoritative available set for negotiation (the defaultavailableset the new adapters use). It is backed by a dedicated loaded-locale index kept as its own keyedpersistent_termand maintained on every catalog add/remove path (load/reload/put/merge-that-creates/unload/ erase_all), so the read is a single copy-free keyed lookup plus ausortrather than a scan of every term on the node. Index writes are compare-before-put: reloading an already-indexed catalog (or unloading an absent pair) leaves the index term untouched and skips the node-wide literal-area GC that apersistent_term:putwould otherwise trigger.erli18n_http— public framework-agnostic negotiation core. Exposesnegotiate_locale/3(resolve the request locale from an ordered candidate list against an available set, with adefaultfall-through),negotiate_locale_lazy/4(the lazy, short-circuiting engine the adapters drive, taking an on-demand extraction callback andavailable/defaultthunks),cookie_value/2(total, fail-soft single-cookie extraction from a rawCookieheader, bounded against abuse), andquery_value/2(total, fail-soft single-parameter extraction from a raw query binary, percent-decoding the matched value fail-soft and bounded against abuse). It is pure (nosetlocale, no logger, no I/O) and is the supported entry point for wiring frameworks the bundled adapters do not cover. The canonical available-locale index is built once per negotiation call and reused across every candidate; the cookie parser bounds the split itself (peeling at mostMAX_COOKIE_PAIRS;-segments and dropping the tail unscanned, O(cap) rather than O(header length)); and alocale="pt_BR"cookie in RFC 6265 quoted-string form is unquoted byte-level and total.
Added (negotiation core)
erli18n_negotiate:available_index/1+negotiate_with_index/2— the canonical available-locale index (#{canonicalize(Original) => Original}) is now a public, reusable value: build it once withavailable_index/1and negotiate many preference lists against it withnegotiate_with_index/2, instead of rebuilding the index per call.negotiate/2is exactlynegotiate_with_index(Preferred, available_index(Available)); its semantics are unchanged.- The
?MAX_RANGESanti-DoS cap onto_locale_list/2is now honest on every consumed cell. The budget is a per-consumed-cell cap (at most 32 input cells inspected) rather than a per-accepted-entry one: the wildcard-skip and oversized-tag-skip branches now also decrement the budget, so a skip-heavy adversarial preference list stops at 32 cells instead of walking the whole list. Now reachable through the newly-publicnegotiate/2/negotiate_with_index/2. Output is byte-identical for any input whose first 32 consumed cells are all acceptable; it differs only when acceptable entries appear after 32 consumed (including skipped) cells — which is exactly the documented anti-DoS contract.
Fixed
erli18n_interptruncation now cuts on a UTF-8 codepoint boundary. Both the per-value clamp (clamp_value/1, at?MAX_VALUE_BYTES) and the output cap (append_and_check/2, at?MAX_OUTPUT_BYTES) previously truncated with a fixed-offsetbinary:part/3; because neither cap is codepoint-aligned, a cut could split a multi-byte codepoint and leave a dangling partial sequence — invalid UTF-8. A new totaltruncate_utf8/2(withcodepoint_start/2/is_utf8_continuation/1) backs off to the codepoint's lead byte when the cut lands inside a multi-byte sequence, so a value that was valid UTF-8 stays valid after clamping or truncation. Output for any value within the cap is unchanged.erli18n_po:escape_string/1is now total over anybinary(). A byte that is not part of a valid UTF-8 sequence (e.g. a lone0xFF) matched no clause and raisedfunction_clause, crashingdump/1on a catalog value carrying arbitrary bytes. A final byte-wise clause now passes such a byte through verbatim — the same way the PO reader tolerates raw bytes on parse — honoring the-spec binary() -> binary()totality contract. The five GNU gettext escapes and all valid-UTF-8 output are byte-for-byte unchanged.
0.5.0 — 2026-06-22
Packaging and public-API minor. Two coupled changes drive the minor bump under
the 0.x SemVer policy above: a new public export (erli18n_po:escape_string/1,
detailed under Added) and the repository's move to a rebar3 umbrella in which
erli18n is now a fully self-contained Hex package (detailed under
Packaging).
Added
erli18n_po:escape_string/1is now exported as public API — a runtime/published-module change toerli18n_po(not a layout-only one). It applies the five GNU gettext PO escapes (backslash, double-quote, newline, tab, carriage return) and is the exact escapingdump/1already used internally. It is promoted to public API so the separaterebar3_erli18nplugin can serialize the PO metadata it owns (the#|previous-msgid lines) byte-identically todump/1across the published{deps, [erli18n]}boundary, instead of vendoring a duplicate escaper that would have to stay in lock-step forever. Additive only; no existing behavior changes.
Security
erli18n_po:parse/1,2plural-index validation no longer allocates a list sized by the untrustednplurals=header (anti-DoS). The PSD-009 cross-check invalidate_plural_indices/3previously builtlists:seq(0, Nplurals - 1), whereNpluralscomes straight from the.poPlural-Formsheader. The loader only caps that value's DIGIT COUNT (7 digits, up to 9,999,999), so a ~158-byte adversarial.podeclaringnplurals=9999999plus a singlemsgstr[0]line forced a ~10-million-element list (~80 MB, reproduced at ~340 ms versus ~0.1 ms for a real catalog) before reporting the mismatch. The validation now checks the index set without ever materializing the header-sized sequence — it requires the present indices to be a dense 0-based prefix (sized by the bytes actually in the file) whose length equalsNplurals— so the same malicious input is rejected in bounded time. The structured{plural_count_mismatch, Msgid, Nplurals, Indices}error a genuine count mismatch returns is byte-for-byte unchanged; only the resource bound is fixed.
Fixed
erli18n_po:parse/1,2continuation-line accumulation is now genuinely O(total). Amsgid/msgstr/msgctxt/msgid_plural/msgstr[N]field spread across many continuation lines was accumulated by appending a growing binary held inside the parser's per-entry record (<<Prev/binary, Bin/binary>>); because that accumulator had more than one reference, the runtime's in-place binary-append optimization did not apply and the build degraded to super-linear on a many-continuation field. Each continuation segment is now prepended onto a reversed list in O(1) and the whole field is joined exactly once at finalization (iolist_to_binary/1), so the per-field build is linear in the total byte count by construction rather than depending on a runtime heuristic. The parsed bytes are unchanged.
Packaging
erli18nis now a self-contained Hex package inside the umbrella. ItsREADME.md,CHANGELOG.md, andLICENSEwere relocated from the repo root intoapps/erli18n/so the published tarball ships them, and the package'sex_doc/{hex, [{doc, #{provider => ex_doc}}]}configuration moved from the rootrebar.configintoapps/erli18n/rebar.config. The root keeps only umbrella-wide and shared-community files. Required becauserebar3_hexcomputes the package file set strictly inside the app directory: with the package files at the repo root, the0.4.0tarball shipped onlyinclude/erli18n.hrl,rebar.config, andsrc/*.erl— noREADME/CHANGELOG/LICENSE. No runtime module behavior changed.
Changed
- The test suite no longer makes a runtime
eqwalizer:dynamic_cast/1call. The nine property/fuzz/CT modules that bridged PropEr's statically-term()generator boundaries (erli18n_po_props,erli18n_negotiate_props,erli18n_lookup_props,erli18n_plural_props,erli18n_interp_props,erli18n_po_fuzz,erli18n_server_SUITE,erli18n_pt_store_SUITE,erli18n_loader_SUITE) now reconcile those boundaries with a static-eqwalizer({nowarn_function, F/A}).annotation on each affected function — the same zero-runtime-dep pattern already used in the runtime moduleserli18n_serveranderli18n_pt_store— instead of calling theeqwalizer:dynamic_cast/1helper at run time. The previous runtime callundef-crashed under Common Test because theeqwalizer_supportgit_subdircheckout lands the helper's beam at a double-nested path that rebar3's ct provider never adds to the code path. The suites are green again with no skips, coverage stays at 100% on every touched module, and no runtime/published module was edited for this change. eqwalizer_supportis RETAINED as the eqwalizer toolchain dependency (not dropped). It is the requiredgit_subdirdep every eqwalizer project declares per the official getting-started instructions; it anchors the OTP/stdlib type overlayselp eqwalize-allneeds. Removing it was tried and rejected: without it,elp eqwalize-allcannot narrow stdlib results and reportsincompatible_typesagainstterm()across everysrcmodule (a locally-reproduced 174-error degrade of an otherwise-green type gate). It is now justified solely as the build-time type-checker anchor — it is no longer on the test suites' runtime code path (see the previous entry), so itsgit_subdirdouble-nesting no longer causes{undef, dynamic_cast}.bin/quality-gate.sh --fullnow hard-requireselp. A newrequire_elpstep records a real FAIL (counted in the gate total, forcing a non-zero exit) whenelpis not found, instead of letting the eqwalizer andelp lintsteps silently SKIP-to-green. In--fullthose two steps now run strictly (a missingelpis a FAIL, not a SKIP); only the cheap--fastlane keeps the soft-skip with an install hint. This closes the SKIP-passes hole so a machine withoutelpcan no longer pass the strict gate.- Repository converted to a rebar3 umbrella. The runtime library now
lives in
apps/erli18n/(itssrc/,test/, anderli18n.app.srcmoved verbatim) instead of the repo root. This is a layout-only change with no runtime module edits: the publishederli18npackage's modules and public API are byte-for-byte unchanged. The Hex publish path iscd apps/erli18n && rebar3 hex publish package(each package is published from its own self-contained app directory, not via--appfrom the umbrella root). Contributors should note that the lib's runtime dependency (telemetry ~> 1.3), compile options, doc config, and its own{project_plugins, [rebar3_hex, rebar3_ex_doc]}now live inapps/erli18n/rebar.config; the rootrebar.configcarries only umbrella-wide tooling (dev/test plugins, thetestprofile, and the dialyzer/xref/hank/erlfmt policy). - Documentation swept to the two-package umbrella reality.
README.md,CONTRIBUTING.md, the plugin'sapps/rebar3_erli18n/README.md, and.github/workflows/release.ymlnow describe the shipped layout consistently: the umbrella project tree (apps/erli18n/,apps/rebar3_erli18n/,examples/erli18n_demo/); the Erlang-nativerebar3 erli18nextractor as a separate, opt-in{plugins, [rebar3_erli18n]}package depending on the library in the plugin → lib direction; the proven cross-package_checkouts/{erli18n, rebar3_erli18n}load-path requirement; the scoped xref host-seam ignore and why (the rebar3 host modules are escript-internal, not a fetchable Hex dep); and the--fullgate's hardelprequirement (soft-skip only in--fast). The release workflow publishes both packages from per-package prefixed tags (erli18n-vX.Y.Z,rebar3_erli18n-vX.Y.Z),erli18nfirst. Prose is en-US throughout. Documentation only; no runtime or published-module edits.
Added
- Catalog tooling promoted to a separate publish-ready plugin package,
rebar3_erli18n(apps/rebar3_erli18n/). The four catalog providers (rebar3 erli18n extract|merge|check|report) now ship as their own rebar3 plugin Hex package rather than being bundled into the runtime library — the dominant rebar3 idiom for a tool with a real runtime consumer (the gpb/rebar3_gpb_pluginpattern). The plugin declares a real dependency on this library ({deps, [{erli18n, "~> 0.5"}]}) and reuses the published PO API across that boundary. Consumers opt in with{plugins, [rebar3_erli18n]}. The plugin carries its ownREADME/CHANGELOG/LICENSE(Apache-2.0) and is published as a separate Hex package, after this library, against{erli18n, "~> 0.5"}. Seeapps/rebar3_erli18n/CHANGELOG.md. - Real downstream-consumer example,
examples/erli18n_demo/. A separate rebar3 project (deliberately underexamples/, NOTapps/, so the umbrella does not auto-discover it) that consumes BOTH umbrella packages exactly as a real downstream app would: itsrebar.configdeclares{deps, [{erli18n, "~> 0.5"}]}and{plugins, [rebar3_erli18n]}, and its production modules (erli18n_demo_greeting,erli18n_demo_errors,erli18n_demo_accounts) contain genuine compile-time-literalerli18n:gettext/ngettext/pgettextf/npgettext/dgettext/gettextfcall sites across thedefault,errors, andaccountsdomains. Runningrebar3 erli18n extract → merge --locale pt_BR → checkagainst it produces the committed baseline.pottemplates and the translatedpt_BR.pocatalogs underexamples/erli18n_demo/priv/gettext/, which therebar3 erli18n checkgate compares against (it FAILS on drift, PASSES in sync — the non-vacuous CI gate the library repo itself cannot host, because the facade never calls itself and extraction there yields zero.pot). The example also documents the dynamic-msgid caveat:dynamic_label/1callserli18n:gettext/1with a runtime (non-literal) key, so it is NOT extracted and never causes a false drift failure, while still translating at runtime. Because the example is developed in-tree (against the umbrella sources, not a Hex fetch) and rebar3 has no native{path, ...}resource, it surfaces both in-repo apps through rebar3's documented_checkouts/override (_checkouts/erli18n,_checkouts/rebar3_erli18n); those links and the example's_build/are git-ignored recreatable artifacts, while the baseline catalogs are tracked. - Executed proof of the cross-package plugin → lib load path. An
ERLI18N_DIAG_LOADPATH-gated diagnostic inrebar3_erli18n_commonlogs the loaded location oferli18n_poat provider-run time. Driven fromexamples/erli18n_demo/, theextract → merge → checkrun confirmscode:which(erli18n_po)resolves under the consumer's_build/default/checkouts/erli18n/ebin/erli18n_po.beam— the unpublished runtime library is reached through the consumer's checkout (not a Hex fetch) across the{deps, [erli18n]}boundary, with noundef erli18n_po:dump/1. So the runtimeerli18n_po:escape_string/1reuse is reachable cross-package, and the relocatedrebar3 erli18n checkgate can meaningfully pass/fail rather than undef-crash. The contingency private escaper/dumper was not vendored. No runtime/published module behavior changed in this step. - The translation-freshness gate now runs inside the consumer example.
bin/quality-gate.shrunsrebar3 erli18n checkfrom insideexamples/erli18n_demo/(via a newrun_step_in <dir> <name> -- <cmd...>helper that executes in a( cd <dir> && … )subshell, so the gate's own working directory is never mutated and a failure is accounted exactly like any other step). The check re-extracts the demo's realerli18n:gettextcall sites and FAILS the build on drift against the committed catalogs, in the same load context whereerli18n_pois on the plugin path through the demo's_checkouts/erli18n. Before the step,ensure_demo_checkoutsidempotently (re)creates bothexamples/erli18n_demo/_checkouts/erli18nand…/_checkouts/rebar3_erli18nso the git-ignored links are always present. This replaces the previous in-library invocation, which was vacuous (the facade never calls itself, so extraction in the library repo yields zero.potand the check protected nothing). A deliberate negative-drift integration test (providers_SUITE:check_drift_cycle_in_load_context) encodes the FAIL-on-drift → PASS-when-fresh cycle through the real provider entry points and asserts up front thatcode:which(erli18n_po)is reachable, so a cross-package load-path regression fails the test explicitly instead of masquerading as drift. No runtime/published module behavior changed in this step. - PO-metadata edge-case assertions in
po_meta_SUITE(the plugin's metadata serializer suite). Four serialize-side cases were added to pin contracts the existing golden tests did not assert: an explicit empty-binary context emitsmsgctxt ""while the absent-contextundefinedomits the line (the no-context invariant, on the write side); an obsolete PLURAL entry#~-prefixes every line of its multi-line block (msgid,msgid_plural, eachmsgstr[N]); an obsolete entry with a translator comment keeps the#comment un-prefixed while#~-prefixing the body (including#~ msgctxt); and a plural entry carrying both an#.extracted comment and#:references emits them in canonical GNU order before the plural block. Coverage ofrebar3_erli18n_po_metastays at 100%. These edge cases were mined from the discarded runtime preserve-mode CT work (see the design note below); no runtime/published module was edited.
Decided (design)
- The runtime
erli18n_po:parse/1preserve-mode WIP is abandoned. An earlier in-progress design added an opt-inerli18n_po:parse(Po, #{preserve => true})mode that retained the full GNU metadata channel (translator and extracted comments,#:references,#,flags incl.fuzzy,#|previous-msgid, and#~obsolete entries) on the runtime READ/parse side. That mode is deliberately NOT shipped:erli18n_po:parse/1stays lossy by design, collapsing all metadata and dropping fuzzy (PSD-001) and obsolete (PSD-007) entries, so the runtime API surface stays minimal. PO metadata is structurally owned by the plugin's WRITE/serialize side (rebar3_erli18n_po_meta), not the runtime parser. This matches the Gettext merge contract the plugin implements:#:references and comments are authoritative from the freshly extracted POT, and onlymsgstris preserved from the old PO, so the runtime read side has no need to round-trip the metadata channel. The discarded suite's edge-case assertions worth keeping (the PSD-001/PSD-007 fuzzy/obsolete and no-context cases) were ported to the plugin-sidepo_meta_SUITEinstead (see above). No runtime/published module was edited for this decision.
Removed
- The host-beam extraction workaround that previously let the in-repo
plugin satisfy the root project's xref as a project app (a vendored escript
that extracted the rebar3 host modules into a generated beam directory, plus
the matching root
rebar.configproject-app-dirs / extra-paths wiring). As a normal rebar3 plugin,rebar3_erli18nreceives the rebar3 host modules at plugin-load time; xref resolution for the host seam is now a scoped-ignore_xref/{xref_ignores}confined to the eight host{M, F, A}edges. No runtime module edits.
0.4.0 — 2026-06-21
Storage migration: the translation-catalog substrate moves from ETS to
persistent_term. The benchmark proved persistent_term reads roughly 55%
faster for this read-hot / load-once library because persistent_term:get/2
returns the term without copying it onto the caller's heap. The public API and
all lookup/fallback/idempotency semantics are unchanged; the only observable
differences are documented below. The minor bump follows the 0.x SemVer
policy above.
Changed
- Catalog storage migrated from ETS to
persistent_term(new moduleerli18n_pt_store). Each{Domain, Locale}catalog is one persistent term (key{erli18n_catalog, Domain, Locale}) holding a single map of its entries plus the header. Reads are copy-free and lock-free from the calling process. There is no lookup behavior change:lookup_singular/4,lookup_plural_form/5andlookup_header/2keep their exact specs, guards, miss semantics (undefined) and return shapes. reload/3,4andunload/2now trigger a node-widepersistent_termliteral-area garbage collection. Replacing or erasing the catalog map defers a cleanup in which every process still referencing the old map runs a major (fullsweep) GC and all processes are made runnable to scan their heaps. This cost is paid once per (re)load or unload and is negligible for erli18n's load-once-at-boot workload, but it is a real cost the previous ETS storage did not have. It is documented here and in theerli18n_server/erli18n_pt_storemodule docs, never hidden.memory_info/0— theets_bytesfield now reports the approximatepersistent_termstorage size in bytes. The field name is kept for backwards compatibility with the 0.3.0 return shape (the storage is no longer ETS).- A lookup against a stopped catalog now returns
undefinedinstead of crashing. Because the catalogs live in runtime-ownedpersistent_termrather than in a process-owned ETS table, a missing or unloaded catalog is a clean miss on the read path, not an access to a dead table.
Removed
erli18n_table_ownerand the entire ETS heir /'ETS-TRANSFER'/give_away/3handoff subsystem. That machinery existed only so ETS catalogs survived a worker crash (Finding #10).persistent_termis node-global and runtime-owned, so a worker crash destroys nothing: the supervisor collapses to a singleerli18n_serverchild underone_for_one(wasrest_for_onewith an owner-first ordering), and the secondary ETS catalog index and the associatederli18n.hrlmacros are gone with it.
0.3.0 — 2026-06-19
Phase 2: canonicalization-aware BCP-47 fallback chain + Accept-Language
negotiation (opt-in). This release is additive — a new module, four new
facade functions, one new application-env key defaulting to off, and one new
telemetry event under the existing opt-in flag. With the default configuration
every public function behaves exactly as in 0.2.0; the exact-match lookup hot
path is byte-for-byte unchanged and reads nothing extra. The minor bump follows
the 0.x SemVer policy above.
Added
erli18n_negotiate— a pure, total, dependency-free engine for locale canonicalization, fallback-chain construction, andAccept-Languagenegotiation. Holds no state (nogen_server, ETS, or process dictionary) and is property-tested in isolation.canonicalize/1— folds a BCP-47 / POSIX tag to the erli18n catalog-key shape (<<"pt-BR">>→<<"pt_BR">>): hyphen/underscore equivalence, RFC 5646 §2.1.1 positional casing (language lowercase, script Titlecase, region UPPERCASE), POSIX charset/modifier suffix stripping (pt_BR.UTF-8,ca_ES@valencia), and a closed legacy-language alias table (in→id,iw→he,ji→yi,jw→jv,mo→ro). Total and idempotent. Out of scope (documented non-goals): macrolanguage/script inference such aszh_Hans⇄zh_CN(needs the CLDR Add Likely Subtags algorithm) and grandfathered/irregular tags.fallback_chain/2— the ordered RFC 4647 Lookup candidate list (pt-BR+ defaulten→[<<"pt_BR">>, <<"pt">>, <<"en">>]), canonicalized, order-preserving-deduplicated, and bounded.parse_accept_language/1— parses an HTTPAccept-Languageheader (RFC 9110 §12.5.4) into[{Range, Q}]withQas an integer in milli-units (0..1000); absentq=1000, well-formedq=0dropped, sorted by descending quality with a stable header-order tiebreak. Total and fail-soft; output shape matches cowlib'scow_http_hd:parse_accept_language/1.negotiate/2,3andbest_match/3— RFC 4647 Lookup of a preference list against an available-locale set, returning the first supported match (preserving the available entry's original casing), a default, orerror.
- Facade additions on
erli18n—negotiate/2(always returns a usable locale, defaulting todefault_locale/0on no match),parse_accept_language/1,canonicalize_locale/1, andset_locale_fallback/1. None changes an existing arity. - Opt-in lookup fallback chain — the four lookup families
(
gettext/ngettext/pgettext/npgettext, and so the interpolatingf-family that delegates to them) consult the fallback chain only on an exact-match miss and only when enabled, so apt_BRrequest resolves a loadedptcatalog instead of returning the rawmsgid. - Config
erli18n.locale_fallback(env, defaultoff):off— exact match only (0.2.0 behavior; the hit path reads nothing extra).base_language— RFC 4647 Lookup chain (pt_BR→pt→default_locale).{explicit, Map}—Map :: #{locale() => [locale()]}override layer; an unlisted locale falls through tobase_language.
- Telemetry
[erli18n, locale, fallback]— emitted when a non-exact locale resolves a translation through the chain, with achain_depthmeasurement andrequested_locale/resolved_localemetadata. Opt-in under the existingemit_lookup_telemetryflag and kept entirely off the exact-hit path. event_locale_fallback/0onerli18n_telemetry.
Performance & safety
- Zero-overhead exact hit. All fallback work runs strictly in the post-miss
branch and only when enabled; an exact hit remains a single
ets:lookupwith no extra allocation or config read (verified by a dedicated CT case). On a miss with fallback on, cost is O(chain length) extra reads, short-circuiting on the first hit. - Total / fail-soft & anti-DoS. Parsing untrusted tags and headers never
raises and never interns atoms (no
binary_to_atom); bounded by per-tag (35 B), subtag (8), chain (8), header (4096 B), element (64), and range (32) caps. An invalidlocale_fallbackvalue degrades tooffrather than breaking a lookup.
Caveats
- Likely-subtags inference is not performed.
zh-CNcanonicalizes tozh_CN, notzh_Hans; a script-only catalog (zh_Hans) is not matched by a region-only request (zh_CN) or vice versa. Load catalogs under the keys your clients send, or supply an{explicit, Map}mapping.
0.2.0 — 2026-06-16
Phase 1: named %{var} interpolation. This release is additive — every
change is a new function, type, or module; the existing gettext / ngettext /
pgettext / npgettext families (and their d / dc variants) are
behaviorally unchanged. The minor bump follows the 0.x SemVer policy above.
Added
erli18n_interp— a pure, dependency-free substituter for named%{name}placeholders.format/2(lenient) is total and fail-soft: for any input and any bindings it returns a binary and never raises.format/3takes anopts()map whose single key,on_missing, selects the missing-binding policy (lenient|strict).- Named placeholders.
%{name}decouples wording from argument order — a translator can move or repeat%{name}and the binding still resolves by name (atom keys). Values may be a binary, an iolist/string, an integer, a float, or an atom, and are coerced to UTF-8 text. - Escaping. A literal percent is
%%; to emit a literal, un-substituted%{name}, write%%{name}(the%%collapses to%, leaving{name}untouched). lenientvsstrict. Lenient leaves an unbound%{name}in place literally; strict raises{erli18n_interp, {missing_binding, Name}}.- Anti-DoS caps. Output is bounded by
?MAX_OUTPUT_BYTES(65536): every append (literal chunk, coerced bound value, literal placeholder) is size-checked in O(1) and the result is truncated to fit before scanning stops. Placeholder expansion is bounded by?MAX_EXPANSIONS(1024); past that, placeholders are emitted literally. Truncation/clamp paths usebinary:copy/1so the returned binary does not pin a large parent binary.
- Named placeholders.
bindings/0type —#{atom() => term()}, exported fromerli18n_interp(alongsideon_missing/0andopts/0).- Interpolating
f-suffix façade family — 24 new functions onerli18n:gettextf,ngettextf,pgettextf,npgettextfand theird/dcdomain-explicit variants, each with a process-locale and an explicit-locale arity. Everyffunction resolves the translation exactly like its non-fsibling, then splices%{var}values from a trailingBindings :: map(). The façadeffamily is lenient (unbound placeholders stay literal; never raises); opt intostrictby callingerli18n_interp:format/3directly. - Plural count auto-bind. The
ngettextf/npgettextffamilies auto-bindcount => N, so%{count}is always available without passing it; a caller-suppliedcountwins.
Caveats
- Bidi / RTL. Interpolation does not auto-insert Unicode bidi isolation marks (U+2066–U+2069) around spliced values. Placing an RTL value into an LTR sentence (or the reverse) can reorder neighboring punctuation under the Unicode Bidirectional Algorithm. Isolate mixed-direction values yourself.
0.1.0 — 2026-06-14
Initial development release. The public API is functional but subject to backward-incompatible
changes on minor bumps per the 0.x SemVer policy.
Requires OTP 27 or newer. The public modules carry native -doc / -moduledoc
documentation attributes (EEP-59), which only compile on OTP 27+; OTP 25.3 and 26 reject
them at compile time with attribute doc after function definitions.
Added
- Core OTP application:
erli18n_app,erli18n_sup(intensity{5, 10}hardcoded per AMB-002). erli18n_server— genserver + ETS catalog store with anti-bottleneck pattern (hot path `lookup*is lock-free direct ETS from caller process; writes serialized throughprotected` table owner).erli18n_po— hand-written recursive-descent parser for GNU gettext.poformat. Honors PSDs 001-009:- PSD-001: fuzzy entries dropped by default; opt-in via
#{include_fuzzy => true}. - PSD-002: charset support restricted to UTF-8, Latin-1, US-ASCII (native to
unicode:characters_to_binary/3). - PSD-003: empty
msgstrpreserved; fallback-to-msgid handled at lookup. - PSD-004: header
Plural-Formsis runtime source of truth; CLDR consulted at load only for divergence warning. - PSD-005: BOM UTF-8 stripped silently.
- PSD-006: msgctxt stored as a separate ETS key field, matching how GNU gettext keys contextual entries (
msgctxt+EOT+msgid). - PSD-007: obsolete
#~entries skipped. - PSD-008: degenerate plural (
nplurals=1) accepted. - PSD-009:
npluralsmismatch rejected with structured error.
- PSD-001: fuzzy entries dropped by default; opt-in via
erli18n_plural— recursive-descent C-expression evaluator forPlural-Formsheader. CLDR data inlined for 49 locales. Bignum-clean.erli18n_server:ensure_loaded/3,4andreload/3,4— atomic catalog load (parse → compile plural → validate vs CLDR → insert), with idempotency fast-path (RISK-012 mitigation).erli18n(façade) — full GNU gettext C-macro API surface:gettextfamily (singular),ngettextfamily (plural),pgettextfamily (contextual),npgettextfamily (contextual + plural), withd/dcaliases. Per-process locale via process dictionary; application-wide defaults viaapplication:get_env/2.erli18n_telemetry— 7:telemetryevents as first-class observability concern (catalog load/reload/unload spans; lookup miss/fuzzy_skip opt-in; plural divergence warning; memory warning rate-limited).telemetrydeclared as optional dep viaoptional_applications(OTP 24+).- Test suite: 289 Common Test cases, green on OTP 27 and 28 — façade API, gen_server / catalog,
.poparser, plural evaluator, loader, and telemetry suites, plus PropEr properties (200 runs each) and fuzz scenarios (100–500 runs each). 6 of these are parity scenarios run against the real GNUgettext/ngettextCLI oracle; that suite skips cleanly whengettext-toolsor thept_BR.UTF-8/ru_RU.UTF-8locales are absent. - Coverage: 100% of behaviorally reachable lines. Dead defensive code removed (no silent fallbacks for invariant violations — crashes are explicit via
function_clause/case_clause/badmatch). - Apache 2.0 license.
- GitHub Actions CI (
.github/workflows/ci.yml) — three jobs on pinnedubuntu-24.04runners:lint(fast quality gate on OTP 28),test(Common Test + coverage across OTP 27 and 28, withgettextinstalled and thept_BR.UTF-8/ru_RU.UTF-8locales generated soerli18n_parity_SUITEexercises the oracle path),dialyzer(isolated job with PLT cache). CI runs automatically only onmain; every other branch runs on demand viaworkflow_dispatch. Concurrency cancellation per ref, least-privilegecontents: readtoken, rebar3 build cache keyed per OTP. - Local CI emulation via
actand a custom runner image (Dockerfile.act-runner): extendsghcr.io/catthehacker/ubuntu:full-24.04with ELP2026-02-27(SHA256-verified per SLSA v0.2). Reuses the workflow YAML unchanged — GitHub-hosted runners gracefully[SKIP]the ELP steps in real CI. Bootstrap is declarative incompose.yml(act-toolcachevolume init + image build).actionlint 1.7.12pinned viamise.tomlfor static workflow analysis. - Repo hygiene:
README.md(with usage / install / compatibility / dev sections),CONTRIBUTING.md,SECURITY.md,CODE_OF_CONDUCT.md(Contributor Covenant 3.0),.editorconfig.
Architecture decisions
The design rationale is captured inline in the source: PO-semantics decisions
(PSD-001…PSD-009), risk mitigations (RISK-*), and ambiguity resolutions
(AMB-*) are referenced from the relevant module -moduledoc / -doc attributes
and code comments. The internal planning corpus that originally tracked them is not
part of the published package.