Skip to content

Releases: eagle-head/erli18n

Release erli18n-v0.8.0

Choose a tag to compare

@github-actions github-actions released this 03 Jul 13:45

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-reloadable persistent_term catalogs, per-request
    locale), with erlydtl keeping ownership of {{ var }} interpolation and
    auto-escaping.

    erli18n_erlydtl:translation_fun/1 returns a render-time translation_fun (a
    fun/2) bound to a gettext domain; pass it in erlydtl's render/2 options. The
    tag mapping — performed by the pure, exported decode/2 — is {% trans %}
    gettext, contextpgettext, a counted {% blocktrans %}ngettext,
    and context + count → npgettext. The locale comes from an explicit
    binary/string locale render option, or falls back to the per-process locale
    (erli18n:which_locale/0) that erli18n_cowboy / erli18n_elli already 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 an optional_applications entry and carries no
    xref/dialyzer/eqwalizer suppressions — full static analysis stays live over
    every line. The fun is total over term(): a non-integer count (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). erlydtl is a test-profile
    dependency only; the published package still requires only kernel + stdlib.

    A runnable example lives in examples/erli18n_erlydtl_demo.

Release rebar3_erli18n-v0.2.0

Choose a tag to compare

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 its Plural-Forms rule ahead of
    time
    , then emits a tiny generated carrier module
    (erli18n_cc_<Domain>__<Locale>.erl) whose catalog/0 returns 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's erli18n:register_compiled_catalogs/1 with no runtime .po parse
    and no plural compile
    . The term-to-source emitter (rebar3_erli18n_codegen)
    uses only erl_parse:abstract/2 + erl_pp from stdlib — no merl, no
    erl_syntax, no parse transform, and no compile: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
    .po leaves no stale module behind) and writes a .gitignore so the
    generated gen_dir is a build artifact, not version-controlled.
    • vs-CLDR plural divergence is emitted once, here, at build time. A
      catalog whose Plural-Forms header 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 broken Plural-Forms
      rule aborts the build loudly rather than shipping a bad carrier.
  • Compile-time key-existence check (rebar3_erli18n_keycheck). After
    codegen, the provider compares every compile-time-literal facade call site (as
    produced by the existing extract walk) 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 is off | warn | strict (default warn): warn logs each diagnostic
    and continues, strict fails 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.config surface (read through the new
    rebar3_erli18n_host:get_config/3 seam). Keys: compiled_catalogs (master
    gate, default off), key_check (off | warn | strict, default warn),
    compiled_domains (all or an explicit [atom()]), gen_dir (default
    "src/erli18n_gen"), include_fuzzy (default false),
    gen_eqwalizer_nowarn (default true), max_po_bytes (default 16 MiB), and
    max_entries (default 500000). An unknown key_check atom 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 .po before reading it (a file larger than
    max_po_bytes, default 16 MiB, checked via filelib:file_size/1) and a
    catalog with more than max_entries (default 500000) after the parse, so a
    compiled carrier can never carry more than erli18n:ensure_loaded/3 would
    accept. Both defaults come from the runtime library
    (erli18n_server:default_max_bytes/0 and default_max_entries/0) as the
    single source of truth; set either to infinity to 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_path is a binary. The generated carrier header
    stores po_path as 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 and provider_hooks wiring, the consumer start/2 registration
    snippet, the rebar3 erli18n compile --check CI one-liner, a
    runtime-vs-compiled decision guide, the generated-carrier eqWAlizer note, and
    the include_fuzzy parity caveat.

Changed

  • Raised the erli18n dependency from ~> 0.6 to ~> 0.7, in lockstep with
    the co-released erli18n 0.7.0. The bump is required, not cosmetic: the
    codegen constructs the erli18n_server:compiled_spec() / baked_header()
    terms and targets erli18n:register_compiled_catalogs/1, all introduced in
    erli18n 0.7.0, so a generated carrier cannot register against an older
    runtime line. The publish order is unchanged — erli18n 0.7.0 must be live on
    Hex before this plugin, since the published requirements resolve ~> 0.7.
  • The host seam (rebar3_erli18n_host) now wraps two additional rebar3 host
    calls
    rebar_state:get/3 (the rebar.config reader backing
    get_config/3) and rebar_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 in rebar.config.

Release erli18n-v0.7.0

Choose a tag to compare

@github-actions github-actions released this 01 Jul 23:18

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
    of ensure_loaded/3 for catalogs frozen into the release at build time. Given
    the consuming application's atom, it discovers that app's generated carrier
    modules (the erli18n_cc_* modules the rebar3_erli18n compile provider
    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_server mailbox 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 .po parse / 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 with ensure_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's start/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}) when App is
      not loaded (a wrong atom or a wrong boot order — a programming error, surfaced
      loudly), and emits a single ?LOG_WARNING and returns [] when App is
      loaded but ships no compiled catalogs.

  • erli18n_server:register_compiled_many/1 — the server-side install of a
    list of compiled_spec() values. It stages each pre-built catalog (the
    already-parsed entries + the baked header) and commits them through the
    existing serialized commit_many path, reusing the same staging/idempotency
    machinery as ensure_loaded. The embedded '$header' keeps the catalog's
    real baked divergence (so lookup_header/2 still reports it), but the
    staged value the install path receives carries divergence => none and
    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/0 and compiled_spec/0 public types.
    baked_header() is the build-time-computed header of a compiled catalog (its
    pre-compiled plural, the raw plural_raw fallback, the source po_path, the
    pre-computed vs-CLDR divergence, fuzzy_included, and num_entries);
    compiled_spec() is the {Domain, Locale, Entries, baked_header()} tuple a
    generated carrier's catalog/0 returns. They are exported so the separate
    rebar3_erli18n codegen can construct the exact term register_compiled_many/1
    installs across the published {deps, [erli18n]} boundary.
  • erli18n_compiled — the internal discovery + registration helper
    register_compiled_catalogs/1 delegates to. It finds an application's generated
    carrier modules by their -erli18n_compiled_catalog([...]) attribute, applies
    each module's catalog/0, and hands the resulting compiled_spec() list to
    erli18n_server:register_compiled_many/1. kernel + stdlib only.
  • erli18n_server:default_max_bytes/0 and default_max_entries/0 are now
    exported.
    They return the resource-bound defaults (max_po_bytes, 16 MiB;
    max_po_entries, 500,000), each read from application:env and narrowed to a
    non_neg_integer() | infinity. Exporting them makes the runtime the single
    source of truth for the caps: the rebar3_erli18n build-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,4 enforces on a runtime load — so a compiled catalog can
    never carry more than a runtime load would accept. Set either env key to
    infinity to disable that cap.

Notes

  • No behavior change for runtime-loaded catalogs. Lookup, fallback,
    idempotency, telemetry, and the ensure_loaded/3,4 / reload/3,4 contracts 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 call register_compiled_catalogs/1 explicitly.

Release rebar3_erli18n-v0.1.2

Choose a tag to compare

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 current erli18n ~> 0.6 line, the README is brought in step with
    the umbrella's OTP 27/28/29 gate, and the .app.src links point at the
    current source and docs.

Release erli18n-v0.6.1

Choose a tag to compare

@github-actions github-actions released this 29 Jun 00:43

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/0 table from it. The seed is the single source of
    truth and the diffable target for a live GNU gettext parity 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.

Release rebar3_erli18n-v0.1.1

Choose a tag to compare

Changed

  • Raised the erli18n dependency from ~> 0.5 to ~> 0.6, in lockstep with
    the co-released erli18n 0.6.0. The plugin still calls only the long-stable
    erli18n_po:parse/1 / dump/1 / escape_string/1 API, 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 — erli18n 0.6.0 must be
    live on Hex before this plugin, since the published requirements resolve
    ~> 0.6.

Fixed

  • extract and merge no longer crash with a badmatch when a catalog file
    cannot be written.
    Both providers matched file:write_file/2 (and
    filelib:ensure_path/1 / ensure_dir/1) against a bare ok =, so any
    filesystem failure — a read-only priv/gettext, an uncreatable parent,
    enospc — aborted the whole extract/merge run on a {badmatch, {error, _}}
    stacktrace. extract's write_pots/write_each_pot and merge's write_po/2
    now short-circuit to {error, {write_failed, Path, Reason}} on the first
    failure, which do/1 surfaces as a normal {error, _} provider result. No CLI,
    flag, or on-disk-layout change.
  • rebar3_erli18n_common:format_error/1 renders the new {write_failed, Path, Reason} reason as erli18n: 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

Choose a tag to compare

@github-actions github-actions released this 26 Jun 03:09

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 — a cowboy_middleware that negotiates the request locale
      and calls erli18n:setlocale/1 before the handler runs.
    • erli18n_elli — the Elli elli_middleware counterpart (preprocess/2).

    Both delegate to the existing erli18n_negotiate engine 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 Cowboy Env (erli18n_locale) and, by default, in logger
    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 resolve available / default lazily 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-supplied available / default is
    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 ?locale and 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 the run/2 boundary:
    a malformed default (non-binary) or available (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.

    cowboy and elli are optional in the same way as telemetry: they are
    declared in optional_applications and are not runtime dependencies of the
    published package, which still builds and runs on kernel + stdlib alone.
    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 default available set the new adapters use). It is backed by a dedicated
    loaded-locale index kept as its own keyed persistent_term and 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 a usort rather
    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:put would 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 a default fall-through),
    negotiate_locale_lazy/4 (the lazy, short-circuiting engine the adapters drive,
    taking an on-demand extraction callback and available / default thunks),
    cookie_value/2 (total, fail-soft single-cookie extraction from a raw
    Cookie header, bounded against abuse), and query_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 (no setlocale,
    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 most MAX_COOKIE_PAIRS
    ;-segments and dropping the tail unscanned, O(cap) rather than O(header
    length)); and a locale="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 with available_index/1 and
    negotiate many preference lists against it with negotiate_with_index/2,
    instead of rebuilding the index per call. negotiate/2 is exactly
    negotiate_with_index(Preferred, available_index(Available)); its semantics are
    unchanged.
  • The ?MAX_RANGES anti-DoS cap on to_locale_list/2 is 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-public negotiate/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_interp truncation 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-offset binary: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 total truncate_utf8/2 (with codepoint_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/1 is now total over any binary(). A byte that is
    not part of a valid UTF-8 sequence (e.g. a lone 0xFF) matched no clause and
    raised function_clause, crashing dump/1 on 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

Choose a tag to compare

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_erli18n plugin package. Promoted from in-repo tooling to
    a first-class, publish-ready rebar3 plugin app under apps/rebar3_erli18n/
    in the erli18n umbrella. Ships four providers under the erli18n namespace —
    extract, merge, check, and report — 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; only msgstr preserved from the old
    .po; new msgids fuzzy-matched against removed ones into #, fuzzy entries
    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 in rebar3_erli18n_common that logs
    the loaded location of erli18n_po at provider-run time. Driven from
    examples/erli18n_demo/, extractmerge --locale pt_BRcheck all
    succeed and code: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. The providers_SUITE
    runtime_lib_reachable_at_provider_run and common_SUITE
    runtime_lib_path_resolves cases 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/1 directly.

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_plugingpb). This dependency is also what binds an
    unpublished consumer's _checkouts/erli18n onto 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 with maps:merge/2 on
    every call (and lookup/2 calls 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/2 is a single maps:find over that constant. The
    table contents are unchanged.

Fixed

  • merge's previous_of/1 now 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 -doc attribute (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.
  • check now 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 .pot was never compared — drift was missed and
    check wrongly passed. check now compares the union of the
    freshly-extracted domains and the domains with a committed <Domain>.pot on
    disk; a domain present on disk but absent from fresh extraction is compared
    against an empty catalog, so its stale .pot correctly 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 .pot and 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
    badarg and aborting the whole extract/check/merge/report run 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 root rebar.config project-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 the rebar3_erli18n_host seam and a matching
    {xref_ignores, [...]} in rebar.config, confined to the eight host
    {M, F, A} edges — every other module stays under active
    undefined_function_calls checking.

Tests

  • report's console output is now asserted, not just {ok, _}. The four
    report_* provider cases previously asserted only that do/1 returned
    {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, exercising do/1 -> rebar3_erli18n_host:console/2, not a
    private builder) and assert it byte-for-byte, including the (no catalog)
    line, an explicit---domain report, and a fully-translated plural counting
    as 1/1.
  • Adversarial .po coverage for merge/check/report. Beyond the lone
    truncated-msgstr parse error, three committed fixtures under
    providers_SUITE_data/ now drive the documented fail-soft behavior: an
    invalid-UTF-8 body (raw 0xFF 0xFE under a charset=UTF-8 header) makes
    merge and report return a structured {error, _} naming the file and the
    charset_conversion reason — and makes check report drift in both the
    default and --names-only modes — never a crash; a line-wrapped old
    msgid ("Sign in " "to your account") is decoded to the same key as the
    unwrapped fresh .pot msgid, so its translation carries over with no fuzzy
    and no obsolete (pinning the wrapping-insensitive equality contract); and a
    larger 60-entry old .po exercises the read_old parse path at scale,
    carrying the one surviving key and demoting the other 59 to #~ obsolete.

Release erli18n-v0.5.0

Choose a tag to compare

@github-actions github-actions released this 24 Jun 02:01

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/1 is now exported as public API — a
    runtime/published-module change to erli18n_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 escaping dump/1 already
    used internally. It is promoted to public API so the separate
    rebar3_erli18n plugin can serialize the PO metadata it owns (the #|
    previous-msgid lines) byte-identically to dump/1 across 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,2 plural-index validation no longer allocates a list
    sized by the untrusted nplurals= header (anti-DoS).
    The PSD-009 cross-check
    in validate_plural_indices/3 previously built lists:seq(0, Nplurals - 1),
    where Nplurals comes straight from the .po Plural-Forms header. The
    loader only caps that value's DIGIT COUNT (7 digits, up to 9,999,999), so a
    ~158-byte adversarial .po declaring nplurals=9999999 plus 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,2 continuation-line accumulation is now genuinely
    O(total).
    A msgid/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

  • erli18n is now a self-contained Hex package inside the umbrella. Its
    README.md, CHANGELOG.md, and LICENSE were relocated from the repo root
    into apps/erli18n/ so the published tarball ships them, and the package's
    ex_doc / {hex, [{doc, #{provider => ex_doc}}]} configuration moved from
    the root rebar.config into apps/erli18n/rebar.config. The root keeps only
    umbrella-wide and shared-community files. Required because rebar3_hex
    computes the package file set strictly inside the app directory: with the
    package files at the repo root, the 0.4.0 tarball shipped only
    include/erli18n.hrl, rebar.config, and src/*.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_server and erli18n_pt_store — instead of calling the
    eqwalizer:dynamic_cast/1 helper at run time. The previous runtime call
    undef-crashed under Common Test because the eqwalizer_support
    git_subdir checkout 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_support is RETAINED as the eqwalizer toolchain dependency
    (not dropped).
    It is the required git_subdir dep every eqwalizer project
    declares per the official getting-started instructions; it anchors the
    OTP/stdlib type overlays elp eqwalize-all needs. Removing it was tried and
    rejected: without it, elp eqwalize-all cannot narrow stdlib results and
    reports incompatible_types against term() across every src module
    (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_subdir double-nesting no longer causes {undef, dynamic_cast}.
  • bin/quality-gate.sh --full now hard-requires elp. A new
    require_elp step records a real FAIL (counted in the gate total, forcing a
    non-zero exit) when elp is not found, instead of letting the eqwalizer and
    elp lint steps silently SKIP-to-green. In --full those two steps now run
    strictly (a missing elp is 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 without elp can no longer pass the strict gate.
  • Repository converted to a rebar3 umbrella. The runtime library now
    lives in apps/erli18n/ (its src/, test/, and erli18n.app.src moved
    verbatim) instead of the repo root. This is a layout-only change with no
    runtime module edits
    : the published erli18n package'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 --app from 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 root rebar.config carries only
    umbrella-wide tooling (dev/test plugins, the test profile, and the
    dialyzer/xref/hank/erlfmt policy).
  • Documentation swept to the two-package umbrella reality. README.md,
    CONTRIBUTING.md, the plugin's apps/rebar3_erli18n/README.md, and
    .github/workflows/release.yml now describe the shipped layout consistently:
    the umbrella project tree (apps/erli18n/, apps/rebar3_erli18n/,
    examples/erli18n_demo/); the Erlang-native rebar3 erli18n extractor 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 --full gate's hard elp requirement (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),
    erli18n first. 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_plugin pattern). 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 under examples/, NOT apps/, so the umbrella
    does not auto-discover it) that consumes BOTH umbrella packages exactly as a
    real downstream app would: its rebar.config declares
    {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 the default, errors, and accounts domains. Running
    rebar3 erli18n extract → merge --locale pt_BR → check against it produces
    the committed baseline .pot templates and the translated pt_BR .po
    catalogs under examples/erli18n_demo/priv/gettext/, which the
    rebar3 erli18n check gate 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...
Read more

Release v0.4.0

Choose a tag to compare

@github-actions github-actions released this 21 Jun 18:41

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/5 and lookup_header/2 keep their exact specs, guards,
    miss semantics (undefined) and return shapes.
  • reload/3,4 and unload/2 now trigger a node-wide persistent_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 the erli18n_server /
    erli18n_pt_store module docs, never hidden.
  • memory_info/0 — the ets_bytes field now reports the approximate
    persistent_term storage 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 undefined instead of
    crashing.
    Because the catalogs live in runtime-owned persistent_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_owner and the entire ETS heir / 'ETS-TRANSFER' /
    give_away/3 handoff subsystem. That machinery existed only so ETS catalogs
    survived a worker crash (Finding #10). persistent_term is node-global and
    runtime-owned, so a worker crash destroys nothing: the supervisor collapses to
    a single erli18n_server child under one_for_one (was rest_for_one with
    an owner-first ordering), and the secondary ETS catalog index and the
    associated erli18n.hrl macros are gone with it.