Releases: eagle-head/erli18n
Release list
Release erli18n-v0.8.0
Added
-
Optional erlydtl template bridge (
erli18n_erlydtl). A new optional
module lets an erlydtl template translate
its{% trans %}/{% blocktrans %}tags through erli18n — full gettext
(contexts, CLDR plurals, hot-reloadablepersistent_termcatalogs, per-request
locale), with erlydtl keeping ownership of{{ var }}interpolation and
auto-escaping.erli18n_erlydtl:translation_fun/1returns a render-timetranslation_fun(a
fun/2) bound to a gettext domain; pass it in erlydtl'srender/2options. The
tag mapping — performed by the pure, exporteddecode/2— is{% trans %}→
gettext,context→pgettext, a counted{% blocktrans %}→ngettext,
andcontext+ count →npgettext. The locale comes from an explicit
binary/stringlocalerender option, or falls back to the per-process locale
(erli18n:which_locale/0) thaterli18n_cowboy/erli18n_ellialready set —
so it composes with the request middleware with no extra wiring.Unlike the Cowboy/Elli adapters, the bridge is an inverted integration: it
references zero erlydtl functions (erlydtl calls into the fun), so it is
not anoptional_applicationsentry and carries no
xref/dialyzer/eqwalizer suppressions — full static analysis stays live over
every line. The fun is total overterm(): a non-integercount(a missing
template variable) and a non-chardata runtime_(Var)value are normalized
rather than crashing the render. On a miss it echoes the msgid — matching
erlydtl's own source fallback for{% trans %}, and giving a count-appropriate
two-form fallback for a counted{% blocktrans %}(a deliberate improvement on
erlydtl's singular-only native fallback).erlydtlis a test-profile
dependency only; the published package still requires onlykernel+stdlib.A runnable example lives in
examples/erli18n_erlydtl_demo.
Release rebar3_erli18n-v0.2.0
A new rebar3 erli18n compile provider — opt-in compile-time
.po->BEAM catalog codegen — plus a compile-time key-existence check. The
minor bump under the 0.x policy is driven by the new provider, the new
{erli18n, [...]} rebar.config surface, and the raised runtime-library
requirement. The four existing providers (extract, merge, check,
report) and their flags are unchanged, and the whole compile surface is
opt-in: with no {compiled_catalogs, true} in rebar.config, the provider
is a loud-logged no-op that writes nothing.
Added
rebar3 erli18n compile— opt-in compile-time catalog codegen. For every
(Domain, Locale)catalog under the catalog root, the provider reads the
.po, parses its entries and compiles itsPlural-Formsrule ahead of
time, then emits a tiny generated carrier module
(erli18n_cc_<Domain>__<Locale>.erl) whosecatalog/0returns the
ALREADY-parsed entries plus the ALREADY-compiled plural rule baked into the
BEAM literal pool. A consumer registers them at boot through the runtime
library'serli18n:register_compiled_catalogs/1with no runtime.poparse
and no plural compile. The term-to-source emitter (rebar3_erli18n_codegen)
uses onlyerl_parse:abstract/2+erl_ppfrom stdlib — nomerl, no
erl_syntax, no parse transform, and nocompile:forms-to-BEAM step; the
generated source is byte-deterministic for a given catalog and compiled by the
normal app-compile step. The provider prunes orphaned carriers (a deleted
.poleaves no stale module behind) and writes a.gitignoreso the
generatedgen_diris a build artifact, not version-controlled.- vs-CLDR plural divergence is emitted once, here, at build time. A
catalog whosePlural-Formsheader diverges from CLDR is logged a single
time during codegen; the divergence is baked into the carrier header so the
runtime install is silent (boot is not noisy). A brokenPlural-Forms
rule aborts the build loudly rather than shipping a bad carrier.
- vs-CLDR plural divergence is emitted once, here, at build time. A
- Compile-time key-existence check (
rebar3_erli18n_keycheck). After
codegen, the provider compares every compile-time-literal facade call site (as
produced by the existingextractwalk) against the per-domain key universe
of the compiled catalogs and reports each call site whose{Context, Msgid}
has no matching compiled key. The check is scoped to the domains actually being
compiled (domain scoping), so it never flags a domain the project did not opt into.
Policy isoff | warn | strict(defaultwarn):warnlogs each diagnostic
and continues,strictfails the build. The CLI overrides —--strict,
--no-key-check, and--check(a dry run that validates without writing
carriers) — take precedence over the config in that order. This is the missing
half of what was previously documented as "compile-time key checking
intentionally out of scope": it is now an opt-in capability, not the
default. {erli18n, [...]}rebar.configsurface (read through the new
rebar3_erli18n_host:get_config/3seam). Keys:compiled_catalogs(master
gate, default off),key_check(off | warn | strict, defaultwarn),
compiled_domains(allor an explicit[atom()]),gen_dir(default
"src/erli18n_gen"),include_fuzzy(defaultfalse),
gen_eqwalizer_nowarn(defaulttrue),max_po_bytes(default 16 MiB), and
max_entries(default 500000). An unknownkey_checkatom or an
ill-typed value degrades to its documented default with a one-time log rather
than crashing the build.- Build-time size and entry caps mirror the runtime loader. The
compile
provider rejects an oversized.pobefore reading it (a file larger than
max_po_bytes, default 16 MiB, checked viafilelib:file_size/1) and a
catalog with more thanmax_entries(default 500000) after the parse, so a
compiled carrier can never carry more thanerli18n:ensure_loaded/3would
accept. Both defaults come from the runtime library
(erli18n_server:default_max_bytes/0anddefault_max_entries/0) as the
single source of truth; set either toinfinityto disable that cap. A
violation is a loud build error ({input_too_large, _, _}/
{too_many_entries, _, _}) that prevents carrier generation. - A carrier's baked
po_pathis a binary. The generated carrier header
storespo_pathas a binary, mirroring the runtime loader's path
normalization, rather than a character-code list. - README sections for the compile provider: the opt-in
rebar.config
snippet andprovider_hookswiring, the consumerstart/2registration
snippet, therebar3 erli18n compile --checkCI one-liner, a
runtime-vs-compiled decision guide, the generated-carrier eqWAlizer note, and
theinclude_fuzzyparity caveat.
Changed
- Raised the
erli18ndependency from~> 0.6to~> 0.7, in lockstep with
the co-releasederli18n0.7.0. The bump is required, not cosmetic: the
codegen constructs theerli18n_server:compiled_spec()/baked_header()
terms and targetserli18n:register_compiled_catalogs/1, all introduced in
erli18n0.7.0, so a generated carrier cannot register against an older
runtime line. The publish order is unchanged —erli18n0.7.0 must be live on
Hex before this plugin, since the publishedrequirementsresolve~> 0.7. - The host seam (
rebar3_erli18n_host) now wraps two additional rebar3 host
calls —rebar_state:get/3(therebar.configreader backing
get_config/3) andrebar_api:warn/2— bringing the scoped host-API surface
to ten{M, F, A}edges. The three suppression sites stay in lockstep: the
seam's-ignore_xref, its-dialyzer({no_unknown, [...]}), and the
{xref_ignores, [...]}block inrebar.config.
Release erli18n-v0.7.0
Opt-in compile-time .po->BEAM catalog registration. A consuming
app can now register catalogs that the rebar3_erli18n build-time codegen baked
into generated BEAM modules — ALREADY parsed, with the Plural-Forms rule
ALREADY compiled — so boot does no .po parse and no plural compile. This
release is additive under the 0.x SemVer policy: one new facade function, one
new erli18n_server API, two new public types, and one new internal module. The
runtime ensure_loaded/3,4 path remains the default; a project that uses
none of the compiled-catalog features sees zero change — no new application-env key, and the read hot
path (erli18n_pt_store:get_singular/4 / get_plural_form/5) is byte-for-byte
unchanged.
Added
erli18n:register_compiled_catalogs/1— the opt-in boot-time counterpart
ofensure_loaded/3for catalogs frozen into the release at build time. Given
the consuming application's atom, it discovers that app's generated carrier
modules (theerli18n_cc_*modules therebar3_erli18n compileprovider
emits), reads each ALREADY-parsed entry list plus its ALREADY-compiled plural
rule, and installs them all in a single serialized write through the same
erli18n_servermailbox every other catalog write uses. Returns
[{Domain, Locale, ensure_result()}]— a fresh catalog reports
{ok, NumEntries}, one already present reports{ok, already}. The honest
framing is no.poparse / no plural compile at startup — NOT zero-load:
registration still installs each catalog (the cost is the install, not the
parsing), so it composes with, rather than replaces, runtime loading.-
Coexistence + idempotency. Registration is additive and idempotent: it
composes withensure_loaded/3(a project may compile some catalogs and load
others at runtime), and a catalog already present — by a prior call or by
ensure_loaded/3— reports{ok, already}and is not overwritten. -
Placement contract (boot ordering). Call it once, in the consuming
app'sstart/2, before its supervision tree starts, so every catalog is
live before any worker can look one up:%% my_app_app.erl start(_Type, _Args) -> _ = erli18n:register_compiled_catalogs(my_app), my_app_sup:start_link().
-
It crashes with
error({erli18n_compiled_app_not_loaded, App})whenAppis
not loaded (a wrong atom or a wrong boot order — a programming error, surfaced
loudly), and emits a single?LOG_WARNINGand returns[]whenAppis
loaded but ships no compiled catalogs.
-
erli18n_server:register_compiled_many/1— the server-side install of a
list ofcompiled_spec()values. It stages each pre-built catalog (the
already-parsed entries + the baked header) and commits them through the
existing serializedcommit_manypath, reusing the same staging/idempotency
machinery asensure_loaded. The embedded'$header'keeps the catalog's
real baked divergence (solookup_header/2still reports it), but the
staged value the install path receives carriesdivergence => noneand
fuzzy_skipped => 0, so boot-time registration installs silently — the
vs-CLDR plural-divergence warning a diverging catalog would raise is emitted
once at build time by the codegen provider, not on every boot.erli18n_server:baked_header/0andcompiled_spec/0public types.
baked_header()is the build-time-computed header of a compiled catalog (its
pre-compiledplural, the rawplural_rawfallback, the sourcepo_path, the
pre-computed vs-CLDRdivergence,fuzzy_included, andnum_entries);
compiled_spec()is the{Domain, Locale, Entries, baked_header()}tuple a
generated carrier'scatalog/0returns. They are exported so the separate
rebar3_erli18ncodegen can construct the exact termregister_compiled_many/1
installs across the published{deps, [erli18n]}boundary.erli18n_compiled— the internal discovery + registration helper
register_compiled_catalogs/1delegates to. It finds an application's generated
carrier modules by their-erli18n_compiled_catalog([...])attribute, applies
each module'scatalog/0, and hands the resultingcompiled_spec()list to
erli18n_server:register_compiled_many/1.kernel+stdlibonly.erli18n_server:default_max_bytes/0anddefault_max_entries/0are now
exported. They return the resource-bound defaults (max_po_bytes, 16 MiB;
max_po_entries, 500,000), each read fromapplication:envand narrowed to a
non_neg_integer() | infinity. Exporting them makes the runtime the single
source of truth for the caps: therebar3_erli18nbuild-time codegen consults
them to reject an oversized.po(by file size, before reading it whole) or an
over-cap entry count (after parse) at BUILD time — the same bounds
ensure_loaded/3,4enforces on a runtime load — so a compiled catalog can
never carry more than a runtime load would accept. Set either env key to
infinityto disable that cap.
Notes
- No behavior change for runtime-loaded catalogs. Lookup, fallback,
idempotency, telemetry, and theensure_loaded/3,4/reload/3,4contracts are
all unchanged.ngettext(and family) still returns the plural form; no
existing arity changes meaning or return shape. The only way to reach the new
path is to callregister_compiled_catalogs/1explicitly.
Release rebar3_erli18n-v0.1.2
Documentation and packaging patch. No code or behavior change — the plugin's
CLI and its {erli18n, "~> 0.6"} requirement are unchanged.
Changed
- Refreshed the package documentation and Hex metadata. The module doc now
states the currenterli18n ~> 0.6line, the README is brought in step with
the umbrella's OTP 27/28/29 gate, and the.app.srclinks point at the
current source and docs.
Release erli18n-v0.6.1
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 inline
erli18n_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 GNUgettext/ 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.
Release rebar3_erli18n-v0.1.1
Changed
- Raised the
erli18ndependency from~> 0.5to~> 0.6, in lockstep with
the co-releasederli18n0.6.0. The plugin still calls only the long-stable
erli18n_po:parse/1/dump/1/escape_string/1API, so the bump pins the
exact library line this release is built and tested against rather than
requiring new API. The publish order is unchanged —erli18n0.6.0 must be
live on Hex before this plugin, since the publishedrequirementsresolve
~> 0.6.
Fixed
extractandmergeno longer crash with abadmatchwhen a catalog file
cannot be written. Both providers matchedfile:write_file/2(and
filelib:ensure_path/1/ensure_dir/1) against a bareok =, so any
filesystem failure — a read-onlypriv/gettext, an uncreatable parent,
enospc— aborted the wholeextract/mergerun on a{badmatch, {error, _}}
stacktrace.extract'swrite_pots/write_each_potandmerge'swrite_po/2
now short-circuit to{error, {write_failed, Path, Reason}}on the first
failure, whichdo/1surfaces as a normal{error, _}provider result. No CLI,
flag, or on-disk-layout change.rebar3_erli18n_common:format_error/1renders the new{write_failed, Path, Reason}reason aserli18n: cannot write <path>: <reason>, so a write
failure prints a clean human-readable message instead of the raw~p
catch-all.
Release erli18n-v0.6.0
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-Language
header > 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, inlogger
process 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 resolveavailable/defaultlazily too:erli18n:loaded_locales/0
is forced only once a source actually yields a value,erli18n:default_locale/0
only 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-time
logger: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, shared
gen_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 a
persistent_term:putwould otherwise trigger. -
erli18n_http— public framework-agnostic negotiation core. Exposes
negotiate_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 raw
Cookieheader, 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 exactly
negotiate_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.
Release rebar3_erli18n-v0.1.0
Initial release of the rebar3_erli18n catalog-tooling plugin as its own Hex
package. It depends on the runtime library erli18n ({deps, [{erli18n, "~> 0.5"}]}) and is published after erli18n 0.5.0. The package is built and
published from its own app directory (cd apps/rebar3_erli18n && rebar3 hex publish ...), which resolves erli18n from Hex into a per-app lock as a
level-0 {pkg,...} entry — the entry create_package carries into the
published requirements (verified locally: the per-app build produces tarball
requirements = {erli18n, "~> 0.5"}). That resolution can only happen once the
matching erli18n minor is live on Hex, which is why the publish order is
erli18n first, then the plugin.
Added
- Initial
rebar3_erli18nplugin package. Promoted from in-repo tooling to
a first-class, publish-ready rebar3 plugin app underapps/rebar3_erli18n/
in the erli18n umbrella. Ships four providers under theerli18nnamespace —
extract,merge,check, andreport— plus the host seam
(rebar3_erli18n_host), the abstract-form extractor, the Jaro fuzzy matcher,
the keyword spec, and the PO metadata serializer. - README documenting the opt-in
{plugins, [rebar3_erli18n]}install, the
Gettext-style merge contract (#:references and extracted comments
authoritative from the fresh.pot; onlymsgstrpreserved from the old
.po; new msgids fuzzy-matched against removed ones into#, fuzzyentries
with a#|previous-msgid hint; removed msgids demoted to#~obsolete),
the dynamic-msgid caveat (only compile-time literal msgids are extracted;
a runtime-computed id still translates but is not statically discoverable),
the consumer two-checkouts requirement for local dev, and the rejected
xref-alternatives note. - Apache-2.0 LICENSE.
- Executed proof of the plugin → lib load path. Added a
ERLI18N_DIAG_LOADPATH-gated diagnostic inrebar3_erli18n_commonthat logs
the loaded location oferli18n_poat provider-run time. Driven from
examples/erli18n_demo/,extract→merge --locale pt_BR→checkall
succeed andcode:which(erli18n_po)resolves under the consumer's
_build/<profile>/checkouts/erli18n/ebin/erli18n_po.beam— proving the
unpublished runtime library is reached through the consumer's checkout (not a
Hex fetch) across the{deps, [erli18n]}boundary, with no
undef erli18n_po:dump/1. Theproviders_SUITE
runtime_lib_reachable_at_provider_runandcommon_SUITE
runtime_lib_path_resolvescases assert the same edge in-node. See the README
"Proven cross-package load path" section. Because the path is proven, the
contingency private escaper/dumper was not vendored — the providers reuse
erli18n_po:escape_string/1directly.
Changed
- Declared a real dependency on the runtime library
({deps, [{erli18n, "~> 0.5"}]},{applications, [kernel, stdlib, erli18n]}), replacing the earlier false "build-only, kernel + stdlib, no
runtime erli18n dep" claim. The providers reuse the published PO API
(erli18n_po:parse/1,erli18n_po:dump/1,erli18n_po:escape_string/1)
across this package boundary, in the plugin → lib direction (the same as
rebar3_gpb_plugin→gpb). This dependency is also what binds an
unpublished consumer's_checkouts/erli18nonto the plugin's runtime path at
provider-run time. - Form walk is now O(nodes). The abstract-form walk called
lists:flatten
at every recursion level (O(extractions × ast-depth)); it now threads a single
accumulator and reverses once at the top. Behavior is identical. - Keyword spec is a compile-time constant.
rebar3_erli18n_keywords:spec/0
built the ~48-entry{Name, Arity} => slots()table withmaps:merge/2on
every call (andlookup/2calls it per look-up). It is now a single
literal map, so the compiler builds it once and every call returns the same
shared constant;lookup/2is a singlemaps:findover that constant. The
table contents are unchanged.
Fixed
merge'sprevious_of/1now renders in the generated docs. The
white-box-only export carried its rationale only in a plain%%comment,
which ex_doc does not read, so the function surfaced on the published doc
page as undocumented. Its explanation is now a real-docattribute (a
native EEP-48 Docs chunk), stating that it is a build-tool internal exported
solely for white-box testing and not part of any published (Hex) API
surface. No behavior change.checknow detects a domain whose call sites have all vanished. The
freshness check folded only over the freshly-extracted domains, so a domain
whose every call site was deleted dropped out of extraction entirely and its
now-orphaned committed.potwas never compared — drift was missed and
checkwrongly passed.checknow compares the union of the
freshly-extracted domains and the domains with a committed<Domain>.poton
disk; a domain present on disk but absent from fresh extraction is compared
against an empty catalog, so its stale.potcorrectly reports drift (it
should be regenerated to empty or removed). The dynamic-key guarantee is
unaffected — a legitimately dynamic key is never extracted, so it never
appears in a committed.potand never produces a phantom domain.- Extractor no longer crashes on a surrogate-code-point binary msgid. A
literal binary msgid whose integer segment is a UTF-16 surrogate
(16#D800..16#DFFF, e.g.erli18n:gettext(<<16#D800>>)) passed the
integer-segment guard but then failed to encode as<<Int/utf8>>, raising
badargand aborting the wholeextract/check/merge/reportrun on a
stacktrace. The integer-segment guard now excludes the surrogate range, so
such a segment is non-resolvable and the call site is skipped exactly like
any other non-compile-time-literal msgid (the documented dynamic-key-skip
contract), never crashing.
Removed
- The host-beam extraction workaround (a vendored generator escript that
extracted the rebar3 host modules into a generated beam directory, plus the
matching rootrebar.configproject-app-dirs / extra-paths wiring that
analyzed the plugin as a project app). The rebar3 host modules (providers,
rebar_state,rebar_api,rebar_app_info) are now resolved for xref by a
scoped-ignore_xref([...])in therebar3_erli18n_hostseam and a matching
{xref_ignores, [...]}inrebar.config, confined to the eight host
{M, F, A}edges — every other module stays under active
undefined_function_callschecking.
Tests
report's console output is now asserted, not just{ok, _}. The four
report_*provider cases previously asserted only thatdo/1returned
{ok, _}, never inspecting the printed table — so a format regression would
pass silently. They now capture the real per-(Domain, Locale)text the
command prints (by swapping the test process's group leader for a capturing
I/O server, exercisingdo/1->rebar3_erli18n_host:console/2, not a
private builder) and assert it byte-for-byte, including the(no catalog)
line, an explicit---domainreport, and a fully-translated plural counting
as1/1.- Adversarial
.pocoverage formerge/check/report. Beyond the lone
truncated-msgstrparse error, three committed fixtures under
providers_SUITE_data/now drive the documented fail-soft behavior: an
invalid-UTF-8 body (raw0xFF 0xFEunder acharset=UTF-8header) makes
mergeandreportreturn a structured{error, _}naming the file and the
charset_conversionreason — and makescheckreport drift in both the
default and--names-onlymodes — never a crash; a line-wrapped old
msgid ("Sign in " "to your account") is decoded to the same key as the
unwrapped fresh.potmsgid, so its translation carries over with no fuzzy
and no obsolete (pinning the wrapping-insensitive equality contract); and a
larger 60-entry old.poexercises theread_oldparse path at scale,
carrying the one surviving key and demoting the other 59 to#~obsolete.
Release erli18n-v0.5.0
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 separate
rebar3_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 single
msgstr[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 equals
Nplurals— 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. Its
README.md,CHANGELOG.md, andLICENSEwere relocated from the repo root
intoapps/erli18n/so the published tarball ships them, and the package's
ex_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_hex
computes the package file set strictly inside the app directory: with the
package files at the repo root, the0.4.0tarball shipped only
include/erli18n.hrl,rebar.config, andsrc/*.erl— no
README/CHANGELOG/LICENSE. No runtime module behavior changed.
Changed
- The test suite no longer makes a runtime
eqwalizer:dynamic_cast/1
call. 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 modules
erli18n_serveranderli18n_pt_store— instead of calling the
eqwalizer:dynamic_cast/1helper at run time. The previous runtime call
undef-crashed under Common Test because theeqwalizer_support
git_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 its
git_subdirdouble-nesting no longer causes{undef, dynamic_cast}.bin/quality-gate.sh --fullnow hard-requireselp. A new
require_elpstep records a real FAIL (counted in the gate total, forcing a
non-zero exit) whenelpis not found, instead of letting the eqwalizer and
elp lintsteps silently SKIP-to-green. In--fullthose two steps now run
strictly (a missingelpis a FAIL, not a SKIP); only the cheap--fast
lane 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 inapps/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 is
cd 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 in
apps/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 own
README/CHANGELOG/LICENSE(Apache-2.0) and is published as a separate
Hex package, after this library, against{erli18n, "~> 0.5"}. See
apps/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-literal
erli18n:gettext/ngettext/pgettextf/npgettext/dgettext/gettextf
call sites
across thedefault,errors, andaccountsdomains. Running
rebar3 erli18n extract → merge --locale pt_BR → checkagainst it produces
the committed baseline.pottemplates and the translatedpt_BR.po
catalogs underexamples/erli18n_demo/priv/gettext/, which the
rebar3 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 cavea...
Release v0.4.0
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 module
erli18n_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 behaviour 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_term
literal-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 approximate
persistent_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_term
rather 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.