Skip to content

build(deps): replace ex_cldr with localize and localize_web - #5630

Merged
JakobLichterfeld merged 12 commits into
mainfrom
migrate-ex-cldr-to-localize
Aug 23, 2026
Merged

build(deps): replace ex_cldr with localize and localize_web#5630
JakobLichterfeld merged 12 commits into
mainfrom
migrate-ex-cldr-to-localize

Conversation

@swiffer

@swiffer swiffer commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Why

ex_cldr officially recommends migrating to localize (migration guide):

  • Bug-fix support for ex_cldr_* only until 31 December 2027, then no planned support
  • No CLDR data updates beyond CLDR 48.2
  • No planned enhancements (PRs only case-by-case)

TeslaMate already paid for the old architecture. ex_cldr embeds locale data at compile time, so every prod build (and often every mix compile) tried to pull locales from GitHub. That caused recurring headaches:

The application-code change is small. The build plumbing is the real win.

What TeslaMate actually used

We do not use CLDR for numbers, dates, units, lists, currencies, or routes. Dates go through Timex. Translations go through Gettext. Address language is a separate OSM Nominatim setting and is untouched.

ex_cldr + ex_cldr_plugs were only used for:

  • Accept-Language negotiation against the 19 Gettext UI locales (ca da de en es fi fr hu it ja ko nb nl sv th tr uk zh_Hans zh_Hant)
  • Setting Gettext on the request / LiveView process
  • The Settings “Web App” language dropdown (Cldr.known_locale_names/0)
  • Persisting the choice in the session

So we need both successor packages:

Package Role
localize 1.2.0 Core locale validation, process locale, Gettext mapping
localize_web 1.1.0 PutLocale, PutSession, put_locale_from_session/2 (replaces ex_cldr_plugs)

We do not take calendrical, localize_sql, localized routes, HTML helpers, MF2 interpolation, or the ICU NIF. Runtime locale download stays off.

What changed

  • Swap deps: drop ex_cldr / ex_cldr_plugs / cldr_utils, add localize + localize_web
  • Delete TeslaMateWeb.Cldr and the custom PutSession plug (no compile-time backend)
  • Router: Localize.Plug.PutLocale with from: [:query, :session, :accept_language] — query must stay first so the settings UI (?locale=de) beats a stored session. Do not use localize_web’s default order.
  • LiveViews restore the locale via Localize.Plug.put_locale_from_session/2 and still assign the Gettext id ("en", "zh_Hans"), not a BCP 47 tag
  • Settings dropdown is Gettext.known_locales/1 (no more rejecting parent :zh / hyphen rewriting)
  • supported_locales is a static CLDR ID list including :"zh-Hans" and :"zh-Hant". Expanding Gettext names like "zh_Hans" would collapse both Chinese variants to :zh
  • html lang now uses Gettext.get_locale/1 (the old :locale session key was never written)
  • Strip compile-time download plumbing: SKIP_LOCALE_DOWNLOAD / LOCALES, Nix elixir-cldr/cldr fetch, devenv LOCALES, CI priv/cldr/locales cache

Behaviour preserved

  1. First visit: best Gettext locale from Accept-Language, else en
  2. Later visits: session wins, unless ?locale= is present
  3. Settings “Web App” list is still the 19 Gettext locales
  4. LiveViews keep translating after live navigation
  5. Addresses language (OSM) unchanged
  6. Existing browser sessions lose the stored UI language once (session key is now "localize_locale") and fall back to Accept-Language

Tests

New test/teslamate_web/locale_test.exs covers default en, Accept-Language de-DE, query beating the header, unknown ?locale=xx, distinct zh_Hans / zh_Hant, and session persistence. Existing settings UI-language redirect test is kept.

Follow-up

mixFodDeps in nix/flake-modules/package.nix is stale. Run nix run .#update-nix-hashes before merging (not done here; Nix was not available).

Out of scope

Replacing Timex, localizing routes, Localize.HTML helpers, MF2 Gettext interpolation, OSM address-language list.

@netlify

netlify Bot commented Aug 18, 2026

Copy link
Copy Markdown

Deploy Preview for teslamate ready!

Name Link
🔨 Latest commit aec388f
🔍 Latest deploy log https://app.netlify.com/projects/teslamate/deploys/6a8aa9ea8ba41e0008c2c900
😎 Deploy Preview https://deploy-preview-5630--teslamate.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@JakobLichterfeld JakobLichterfeld added elixir Pull requests that update Elixir code enhancement New feature or request dependencies Pull requests that update a dependency file area:teslamate Related to TeslaMate core nix releated to nix flake labels Aug 18, 2026
@JakobLichterfeld JakobLichterfeld added this to the v4.2.0 milestone Aug 18, 2026

@JakobLichterfeld JakobLichterfeld left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Matthias — this is a really well-prepared migration, and getting rid of the compile-time locale download, the Nix cldr pin and the CI locale cache is exactly the win the PR description promises. I ran nix run .#update-nix-hashes and pushed the new mixFodDeps hash to the branch, so that follow-up is done.

I did a deep review with 8 parallel Agents (including reading the localize/localize_web sources and verifying the locale resolution empirically with mix run). One real regression and a few smaller things:

Blocker

Valid-but-unsupported languages resolve to Catalan instead of falling back to English. Localize.validate_locale/1 restricts to supported_locales via LanguageTag.best_match/3 with the default distance threshold, which always returns a result — the first supported locale as a last resort. Verified with the app config loaded:

  • Accept-Language: ru-RU,ru;q=0.9,en;q=0.8 → locale :ca, Gettext "ca" (the en;q=0.8 never wins, because the ru-RU tag already "validates" to :ca)
  • ?locale=pt, ?locale=pl:ca as well
  • Localize.Plug.PutSession then persists "ca", so the user is stuck in Catalan across requests

On main, Cldr.Plug.AcceptLanguage declined the no-match (hence the old no_match_log_level: :debug) and we fell through to English. The new test only covers ?locale=xx, which fails parsing (invalid ISO subtag) — a different code path — so the suite stays green. Could you add tests with a real unsupported language (ru, pt) and restore the English fallback (strict matching / reordering alone only fixes the last-resort case)?

Should fix

  • Locale negotiation is now recomputed on every request and LiveView mount, uncached. The old chain read a precomputed session value (Process.put-cheap); now gettext_locale_id/2 runs a full 19-candidate LanguageTag.best_match (parse + canonicalize + likely-subtags per candidate) in the plug on every request, plus twice more per LiveView page load (dead render + connected mount). Measured ~70 µs per call on Apple Silicon — on the Raspberry Pi 3 B+ boxes a large share of our installs run on, that's realistically ~2–3 ms per negotiation, so ~6–10 ms per page load warm, and a cold Accept-Language tag (~2.5 ms even on the Mac) can hit 50–100 ms there. The fix is cheap and restores the old shape: store the resolved Gettext locale in the session (like the deleted PutSession did) and make on_mount a plain Gettext.put_locale/2 again.
  • <html lang> is no longer valid BCP-47 for Chinese: Gettext.get_locale/1 returns the POSIX name, so we render lang="zh_Hans". Screen readers, :lang() CSS and translate detection won't match it — and locale_test.exs currently asserts the invalid value, locking it in. The plug already has the proper tag: Localize.Plug.PutLocale.get_locale(@conn) renders as "zh-Hans".
  • Two locale lists that can drift: the settings dropdown now derives from Gettext.known_locales/1 while the plug gate is the hand-copied list in config.exs. If Weblate adds priv/gettext/pl and nobody edits the config, "Polish" shows up in the dropdown and selecting it lands on… Catalan (same mechanism as above), with no compile-time or test signal. A test asserting the two sets match would close this cheaply. (Your comment about zh_Hans collapsing to :zh is accurate, I checked — so the static list itself is fine, it just needs the guard.)

Nits

  • router.ex: default: :en duplicates config :localize, default_locale: :en and opts back into eager Plug.init-time validation that the library's :__localize_default__ sentinel deliberately avoids; param: "locale" and as: :string are the library defaults. All three lines can go.
  • init_assigns.ex: the {:error, _} branch hardcodes "en" (Gettext already falls back to its configured default if you just do nothing) and never sets Localize.put_locale/1, so the two locale registries disagree on that path. It is reachable once per pre-upgrade session on a LiveView reconnect — harmless, but worth simplifying.
  • car_live/index.html.heex still passes "locale" => @locale into the nested live_render — dead since the controller-side removal, nothing reads it anymore.
  • .gitignore still has /priv/cldr/; nothing writes that directory now (and upgraded checkouts keep a stale multi-MB priv/cldr/locales).
  • CHANGELOG.md: the entry is missing your - @swiffer credit, and the one-time UI-language reset (session key change) deserves a short note block under [unreleased] like we did for 4.1.x, so we don't collect "my language reset itself" issues.

None of this diminishes the migration itself — the approach and the build-plumbing cleanup are exactly right. 👍


🤖 Review drafted with Claude Code (Fable 5 high) — sponsored by Claude for Open Source

@JakobLichterfeld
JakobLichterfeld force-pushed the migrate-ex-cldr-to-localize branch from b66c870 to 23a902a Compare August 22, 2026 07:39
@JakobLichterfeld

Copy link
Copy Markdown
Member

I'll implement the fixes

JakobLichterfeld added a commit that referenced this pull request Aug 22, 2026
…ocale (#5630)

Localize's default matching never fails for a valid language tag and best-matches unrelated languages onto the first supported locale, so Accept-Language: ru came back Catalan and was persisted in the session.
Replace the built-in PutLocale sources with strict ones (distance 79 keeps CLDR's related-language fallbacks, rejects the unrelated bucket) that fall through per Accept-Language entry to the configured default.

Store the resolved Gettext locale under the pre-migration "gettext_locale" session key: existing sessions keep their language across the upgrade, the LiveView on_mount hook is a plain Gettext.put_locale/2 again, and the per-request 19-candidate tag matching drops to a map lookup on the warm path.

Also: emit BCP 47 <html lang> (zh-Hans, not zh_Hans), drop the redundant PutLocale options and Localize.Plug.PutSession, remove the dead "locale" live_render session key and the stale /priv/cldr/
gitignore entry, and add tests for unsupported locales, secondary Accept-Language preferences, legacy sessions, and config/Gettext sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@JakobLichterfeld

Copy link
Copy Markdown
Member
  • and the one-time UI-language reset (session key change) deserves a short note block under [unreleased] like we did for 4.1.x, so we don't collect "my language reset itself" issues.

Resolved differently: the implementation now stores the Gettext locale under the pre-migration "gettext_locale" session key, so existing sessions keep their language across the upgrade (covered by a regression test). No language reset happens anymore, hence no changelog note needed.

@JakobLichterfeld JakobLichterfeld left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up on my review above: I pushed 959d92c addressing the findings — strict source functions for the negotiation (restores the English fallback, with tests for ru, pt and the ru,de;q=0.9 fallthrough), session-cached Gettext locale so on_mount is a plain put_locale/2 again, proper BCP-47 <html lang> (zh-Hans/zh-Hant covered), the config↔priv/gettext sync test, and the nits. The session key stays "gettext_locale", so pre-migration sessions keep their language and no changelog note is needed.

I then re-ran the deep review in a new session (8 parallel agents, findings verified by executing the compiled plug) on the fixed branch. Two edge cases in my own fix, worth closing before merge:

  1. Non-binary ?locale param crashes with a 500. strict_match/1 only has nil and is_binary clauses, so GET /?locale[]=de (Plug decodes it as ["de"]) raises FunctionClauseError on every :browser route — trivially reachable by scanners. The old Cldr.validate_locale/2 returned {:error, _} and fell through. Fix: defp strict_match(_other), do: nil.
  2. ?locale=und renders the UI in Catalan. best_match/3 short-circuits und to the first supported locale at score 80 — above the strict threshold of 79 — but the {:ok, cldr_id, _score} clause discards the score and accepts it, then persists gettext_locale=ca. The one gap in the strict matching; needs a score check (or rejecting und up front) plus a test.

One design point to settle deliberately: the release ships no CLDR locale data except en/und, allow_runtime_locale_download is false, and otp_app: :teslamate points Localize's cache dir at a path nothing in the Docker/Nix build populates. Negotiation needs no per-locale data, so everything works — but the first Localize.Number/date formatting call anyone adds will raise LocaleNotFoundInCacheError in production, for non-English users only. If formatting stays a non-goal, let's document that and re-add a .gitignore entry for /priv/localize/ (a mix localize.download_locales run currently leaves committable blobs in priv/).

Smaller things, fine as follow-ups:

  • put_session/3 runs unconditionally, so every response re-signs and re-sends the session cookie; compare-then-write (like Localize's own PutSession) avoids that.
  • on_mount's catch-all silently falls back to English on the connected mount when the session is missing (reverse proxies stripping cookies on the websocket upgrade — we've had support threads on that), and a pre-migration session carrying "gettext_locale" => nil (the old PutSession wrote gettext_locale_name verbatim) matches the pattern and Gettext.put_locale/2 raises on nil. A when is_binary(locale) guard covers both.
  • @strict_distance derives from Localize.LanguageTag.default_distance() - 1, a @doc false internal that tracks CLDR data — a dep bump can silently move the matching window. I'd pin the literal and add an Accept-Language: nn test asserting nb (verified working today), so the moduledoc's promise is enforced.
  • session_key/0 and gettext_locales/0 have no callers — init_assigns.ex and locale_test.exs hardcode the literal / re-derive the mapping. Either wire them through so the sync test exercises the plug's actual maps, or drop them.

Also verified clean: zh-CNzh-Hans and zh-Hant-TWzh-Hant via Accept-Language, no dangling Cldr/SKIP_LOCALE_DOWNLOAD references, Nix/CI/docs consistent.


🤖 Review drafted with Claude Code (Fable 5 high) — sponsored by Claude for Open Source

@JakobLichterfeld JakobLichterfeld left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the six follow-up commits (same setup: parallel agents + empirical verification against the compiled plug, full locale/settings suite green against Postgres). All review findings are addressed: non-binary ?locale params fall through instead of 500ing, und is rejected by the score guard, legacy nil session locales no longer crash mounts, nnnb is pinned by a test, the session key and locale map are wired to their consumers, and the formatting non-goal is documented with the cache dir ignored.

What's left is minor:

  • The persist_locale compare-then-write doesn't save anything yet: fetch_settings right after it does an unconditional put_session(:settings, …), so the session is always dirty and every response still re-signs the cookie. The same one-line compare-then-write there unlocks the win (and stops re-serializing the whole settings struct into the cookie per request).
  • The pinned @strict_distance 79 guards against localize raising its default distance, but inverts if an upgrade ever lowers it to ≤ 79 (the dep's strict-error path is guarded by distance < default, and the fallback echoes the threshold as the score, so the guard passes). Our ru test would go red in CI, so it's caught — but a compile-time @strict_distance < default_distance() assertion fails at the file that owns the invariant. The comment also overstates: CLDR scores some genuinely related pairs (sgslt) at exactly 80, so rejecting the 80 bucket is a trade-off, not a free lunch.
  • on_mount accepts any binary session locale unvalidated, unlike the dead-render path — a stale value from an old release (e.g. a removed locale) renders English while the settings dropdown matches no option. A membership check against gettext_locales() makes both paths agree.
  • The nil-guard test should use session_key() (renaming the key would silently make it test the wrong branch) and also assert the positive case.

🤖 Review drafted with Claude Code (Fable 5 high) — sponsored by Claude for Open Source

@JakobLichterfeld JakobLichterfeld left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the follow-up commits (same setup: parallel review agents + empirical verification against the compiled plug, full web-layer suite green against Postgres — 119 tests, 0 failures). All findings from my review are addressed: non-binary ?locale params fall through instead of 500ing, und is rejected by the score guard, legacy nil session locales no longer crash LiveView mounts, nnnb is pinned by a test, the session key and locale map are wired to their consumers, and the formatting non-goal is documented with the locale cache dir git-ignored.

The re-review surfaced a few smaller things, closed in 1edf81e:

  • The persist_locale compare-then-write saved nothing yet: fetch_settings right after it did an unconditional put_session(:settings, …), keeping the session permanently dirty — every response still re-signed and re-sent the cookie. fetch_settings now does the same compare-then-write, which also stops re-serializing the settings struct into the cookie on every request.
  • Pinning @strict_distance 79 guards against localize raising its default matching distance, but would invert if an upgrade ever lowered it to ≤ 79 (the dep's strict-error path is guarded by distance < default, and the fallback echoes the threshold as the score). A compile-time assertion now fails the build at the file that owns the invariant. The comment also acknowledges that CLDR scores a few genuine relatives (sgslt) at exactly 80, so rejecting that bucket is a deliberate trade-off.
  • on_mount accepted any binary session locale unvalidated, unlike the dead-render path — a stale value from an old release (e.g. a removed locale) would render English while the settings dropdown matched no option. The guard is now a membership check against the plug's locale map, so both paths agree; covered by new positive and stale-locale tests.
  • The nil-guard test hardcoded the session key and only asserted the fallback; it now goes through session_key() and pins the positive branch too.

From my side this is ready to merge after smoke test.


🤖 Review drafted with Claude Code (Fable 5 high) — sponsored by Claude for Open Source

@swiffer

swiffer commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks a lot for taking this over, Jakob — work kept me fully occupied and I didn't get back to the review in time.

Really appreciate the follow-ups: the strict matching so we don't drop people into Catalan, keeping the old gettext_locale session key so existing installs don't reset, and closing out all the edge cases. Also for running update-nix-hashes and actually verifying the plug.

Looks good from my side; happy to leave the rest with you for the smoke test / merge.

@JakobLichterfeld

Copy link
Copy Markdown
Member

You're very welcome. I'll test these images and then do the release.

swiffer and others added 12 commits August 23, 2026 10:05
Migrate off ex_cldr before support ends in 2027 and drop compile-time locale downloads.
…ocale (#5630)

Localize's default matching never fails for a valid language tag and best-matches unrelated languages onto the first supported locale, so Accept-Language: ru came back Catalan and was persisted in the session.
Replace the built-in PutLocale sources with strict ones (distance 79 keeps CLDR's related-language fallbacks, rejects the unrelated bucket) that fall through per Accept-Language entry to the configured default.

Store the resolved Gettext locale under the pre-migration "gettext_locale" session key: existing sessions keep their language across the upgrade, the LiveView on_mount hook is a plain Gettext.put_locale/2 again, and the per-request 19-candidate tag matching drops to a map lookup on the warm path.

Also: emit BCP 47 <html lang> (zh-Hans, not zh_Hans), drop the redundant PutLocale options and Localize.Plug.PutSession, remove the dead "locale" live_render session key and the stale /priv/cldr/
gitignore entry, and add tests for unsupported locales, secondary Accept-Language preferences, legacy sessions, and config/Gettext sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
strict_match/1 only had nil and is_binary clauses, so a request like GET /?locale[]=de — which Plug decodes to a list — raised a FunctionClauseError (HTTP 500) on every browser route. Ignore any non-binary value and fall through to the next locale source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LanguageTag.best_match/3 short-circuits the desired locale "und" past the distance threshold and returns the first supported locale at the default distance (80), so ?locale=und rendered the UI in Catalan and persisted it in the session. Accept a match only when its score is within the strict distance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pre-localize PutSession plug wrote the CLDR tag's gettext_locale_name into the session verbatim, which could be nil.
Such a session matched the on_mount pattern and Gettext.put_locale/2 raises on nil, crashing every LiveView mount for that browser until
the cookie is replaced. Only accept binary locale values; anything else falls back to the Gettext default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@strict_distance derived from Localize.LanguageTag.default_distance/0, a @doc false internal tracking CLDR data, so a dependency bump could silently move the matching window. Pin the literal (79 — one below the unrelated-language bucket) and assert both sides of the boundary: Accept-Language nn falls back to nb, ru falls through to the default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… consumers

session_key/0 and gettext_locales/0 had no callers: init_assigns hardcoded the session key and the sync test re-derived the mapping from config, so neither exercised the plug's actual values. Wire both through. Also write the session only when the locale changed, instead of re-signing the cookie on every response.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ache dir

The release ships no per-locale CLDR data (negotiation needs none) and runtime download is off, so any future Localize formatting call would raise LocaleNotFoundInCacheError for non-English locales. State that next to the config, and ignore /priv/localize/ so a manual `mix localize.download_locales` cannot leave committable blobs in priv.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- write the settings session only on change: the unconditional put_session in fetch_settings kept the session dirty on every response, re-signing the cookie and defeating the locale plug's compare-then-write
- assert at compile time that @strict_distance stays below Localize's default matching distance, where strictness would silently invert, and state the sgs/lt trade-off of the 80-bucket cut in the comment
- validate session locales on LiveView mounts against the supported set, keeping the mount path consistent with the dead render and covering legacy values (nil, removed locales)
- tests: use session_key() instead of the literal, assert the positive mount path and the stale-locale fallback

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@JakobLichterfeld
JakobLichterfeld force-pushed the migrate-ex-cldr-to-localize branch from 1edf81e to aec388f Compare August 23, 2026 08:06
@JakobLichterfeld

JakobLichterfeld commented Aug 23, 2026

Copy link
Copy Markdown
Member

I allowed manual ghcr build (#5646), same as I already did with DevOps (#5593).

Now testing with image: ghcr.io/teslamate-org/teslamate:migrate-ex-cldr-to-localize

@JakobLichterfeld
JakobLichterfeld merged commit 50e2e5b into main Aug 23, 2026
31 checks passed
@JakobLichterfeld
JakobLichterfeld deleted the migrate-ex-cldr-to-localize branch August 23, 2026 08:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:teslamate Related to TeslaMate core dependencies Pull requests that update a dependency file elixir Pull requests that update Elixir code enhancement New feature or request nix releated to nix flake

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants