Skip to content

Commit 959d92c

Browse files
fix(locale): strict UI-language negotiation, session-cached Gettext locale (#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>
1 parent 6d3ef7b commit 959d92c

8 files changed

Lines changed: 204 additions & 21 deletions

File tree

.gitignore

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,6 @@ npm-debug.log
3434
# Ignore digested assets cache.
3535
/priv/static/cache_manifest.json
3636

37-
# CDLR data repository
38-
/priv/cldr/
39-
4037
# Files matching config/*.secret.exs pattern contain sensitive
4138
# data and you should not commit them into version control.
4239
#

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Breaking for automations on discovered entities: the Health sensor is inverted (
2727
- test: stop the app in test_helper instead of relying on --no-start (#5615 - @swiffer)
2828
- build(deps): update flake.lock (#5613)
2929
- build(deps): update flake.lock (#5645)
30-
- build(deps): replace `ex_cldr` / `ex_cldr_plugs` with `localize` and `localize_web` (ex_cldr support ends 2027-12-31). Drops compile-time locale download and the Nix `cldr` pin. (#5630 - @swiffer)
30+
- build(deps): replace `ex_cldr` / `ex_cldr_plugs` with `localize` and `localize_web` (ex_cldr support ends 2027-12-31). Drops compile-time locale download and the Nix `cldr` pin (#5630 - @swiffer, @JakobLichterfeld)
3131

3232
#### Dashboards
3333

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<%= for summary <- @summaries do %>
22
{live_render(@socket, TeslaMateWeb.CarLive.Summary,
33
id: "car_#{summary.car.id}",
4-
session: %{"summary" => summary, "settings" => @settings, "locale" => @locale}
4+
session: %{"summary" => summary, "settings" => @settings}
55
)}
66
<% end %>

lib/teslamate_web/live/init_assigns.ex

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,9 @@ defmodule TeslaMateWeb.InitAssigns do
66
import Phoenix.Component
77

88
def on_mount(:locale, _params, session, socket) do
9-
case Localize.Plug.put_locale_from_session(session, gettext: TeslaMateWeb.Gettext) do
10-
{:ok, _locale} ->
11-
:ok
12-
13-
{:error, _reason} ->
14-
Gettext.put_locale(TeslaMateWeb.Gettext, "en")
9+
case session do
10+
%{"gettext_locale" => locale} -> Gettext.put_locale(TeslaMateWeb.Gettext, locale)
11+
_other -> :ok
1512
end
1613

1714
{:cont, assign(socket, :locale, Gettext.get_locale(TeslaMateWeb.Gettext))}

lib/teslamate_web/plugs/locale.ex

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
defmodule TeslaMateWeb.Plugs.Locale do
2+
@moduledoc """
3+
Strict locale sources for `Localize.Plug.PutLocale`, plus the plug that
4+
applies the negotiated locale to Gettext and the session.
5+
6+
`Localize.validate_locale/1` matches at the default CLDR distance (80),
7+
which never fails for a syntactically valid tag: an unrelated language
8+
best-matches onto the *first* supported locale as a last resort, so
9+
`Accept-Language: ru` would come back Catalan instead of falling
10+
through to the default. The sources here match strictly — distance 79
11+
keeps CLDR's genuine related-language fallbacks (such as `nn` -> `nb`)
12+
but rejects the unrelated-language bucket — and return `nil` on no
13+
match, so the plug moves on to the next source and ultimately the
14+
configured default. Accept-Language entries are tried per tag in
15+
quality order, so an unsupported primary language still falls through
16+
to a supported secondary preference.
17+
18+
The session stores the Gettext locale name under `"gettext_locale"`,
19+
the same contract the pre-Localize `PutSession` plug used: sessions
20+
written before the migration stay valid, and the LiveView `on_mount`
21+
hook remains a plain `Gettext.put_locale/2` without re-running the
22+
negotiation.
23+
"""
24+
25+
import Plug.Conn
26+
27+
@behaviour Plug
28+
29+
@session_key "gettext_locale"
30+
@strict_distance Localize.LanguageTag.default_distance() - 1
31+
32+
# The supported CLDR ids map 1:1 onto the Gettext locale names
33+
# (`priv/gettext/*`); `locale_test.exs` asserts the two sets stay
34+
# in sync.
35+
@gettext_locales Map.new(
36+
Application.compile_env!(:localize, :supported_locales),
37+
&{&1, &1 |> Atom.to_string() |> String.replace("-", "_")}
38+
)
39+
@cldr_locales Map.new(@gettext_locales, fn {cldr, gettext} -> {gettext, cldr} end)
40+
41+
@doc false
42+
def session_key, do: @session_key
43+
44+
@doc false
45+
def gettext_locales, do: Map.values(@gettext_locales)
46+
47+
## Locale sources for Localize.Plug.PutLocale
48+
49+
@doc false
50+
def from_query(%Plug.Conn{query_params: %Plug.Conn.Unfetched{}} = conn, options) do
51+
from_query(fetch_query_params(conn), options)
52+
end
53+
54+
def from_query(conn, _options) do
55+
strict_match(conn.query_params["locale"])
56+
end
57+
58+
@doc false
59+
def from_session(conn, _options) do
60+
strict_match(get_session(conn, @session_key))
61+
end
62+
63+
@doc false
64+
def from_accept_language(conn, _options) do
65+
case get_req_header(conn, "accept-language") do
66+
[header | _] ->
67+
header
68+
|> Localize.AcceptLanguage.tokenize()
69+
|> Enum.find_value(fn {_quality, tag} -> strict_match(tag) end)
70+
71+
[] ->
72+
nil
73+
end
74+
end
75+
76+
# Exact Gettext locale names (session values, `?locale=` from the
77+
# settings UI) resolve through the compile-time map without any tag
78+
# matching; everything else pays one strict `best_match` against the
79+
# supported list.
80+
defp strict_match(nil), do: nil
81+
82+
defp strict_match(locale) when is_binary(locale) do
83+
case Map.fetch(@cldr_locales, locale) do
84+
{:ok, cldr_id} ->
85+
validated(cldr_id)
86+
87+
:error ->
88+
case Localize.LanguageTag.best_match(
89+
locale,
90+
Localize.supported_locales(),
91+
@strict_distance
92+
) do
93+
{:ok, cldr_id, _score} -> validated(cldr_id)
94+
{:error, _reason} -> nil
95+
end
96+
end
97+
end
98+
99+
defp validated(cldr_id) do
100+
case Localize.validate_locale(cldr_id) do
101+
{:ok, _language_tag} = ok -> ok
102+
{:error, _reason} -> nil
103+
end
104+
end
105+
106+
## Plug: apply the locale Localize.Plug.PutLocale resolved
107+
108+
@impl Plug
109+
def init(options), do: options
110+
111+
@impl Plug
112+
def call(conn, _options) do
113+
case Localize.Plug.PutLocale.get_locale(conn) do
114+
%Localize.LanguageTag{cldr_locale_id: cldr_id} when is_map_key(@gettext_locales, cldr_id) ->
115+
locale = Map.fetch!(@gettext_locales, cldr_id)
116+
Gettext.put_locale(TeslaMateWeb.Gettext, locale)
117+
118+
# :html_lang, not :locale — LiveView merges its assigns (where
119+
# :locale is the Gettext name, e.g. "zh_Hans") into the conn
120+
# assigns on the dead render and would shadow this one.
121+
conn
122+
|> put_session(@session_key, locale)
123+
|> assign(:html_lang, Atom.to_string(cldr_id))
124+
125+
_other ->
126+
conn
127+
end
128+
end
129+
end

lib/teslamate_web/router.ex

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,18 @@ defmodule TeslaMateWeb.Router do
88
plug :fetch_session
99
plug :fetch_live_flash
1010

11-
# Query first so the settings UI language switcher (?locale=) beats session.
11+
# Query first so the settings UI language switcher (?locale=) beats the
12+
# stored session. The strict sources reject locales that are not close
13+
# to a supported one, so the chain falls through to the configured
14+
# default instead of best-matching an unrelated language.
1215
plug Localize.Plug.PutLocale,
13-
from: [:query, :session, :accept_language],
14-
param: "locale",
15-
gettext: TeslaMateWeb.Gettext,
16-
default: :en
16+
from: [
17+
{TeslaMateWeb.Plugs.Locale, :from_query},
18+
{TeslaMateWeb.Plugs.Locale, :from_session},
19+
{TeslaMateWeb.Plugs.Locale, :from_accept_language}
20+
]
1721

18-
plug Localize.Plug.PutSession, as: :string
22+
plug TeslaMateWeb.Plugs.Locale
1923

2024
plug :put_root_layout, {TeslaMateWeb.LayoutView, :root}
2125
plug :protect_from_forgery

lib/teslamate_web/templates/layout/root.html.heex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<!DOCTYPE html>
22
<html
3-
lang={Gettext.get_locale(TeslaMateWeb.Gettext)}
3+
lang={@conn.assigns[:html_lang]}
44
data-theme-mode={@conn.assigns.settings.theme_mode}
55
>
66
<head>

test/teslamate_web/locale_test.exs

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,45 @@ defmodule TeslaMateWeb.LocaleTest do
4747
assert html =~ "Settings"
4848
end
4949

50-
test "preserves Chinese script variants", %{conn: conn} do
50+
test "valid but unsupported query locale falls back to the default", %{conn: conn} do
51+
conn = get(conn, "/settings?locale=pt")
52+
html = html_response(conn, 200)
53+
54+
assert html_lang(html) == ["en"]
55+
assert html =~ "Settings"
56+
end
57+
58+
test "unsupported Accept-Language falls back to the default", %{conn: conn} do
59+
conn =
60+
conn
61+
|> put_req_header("accept-language", "ru-RU,ru;q=0.9")
62+
|> get("/settings")
63+
64+
html = html_response(conn, 200)
65+
66+
assert html_lang(html) == ["en"]
67+
assert html =~ "Settings"
68+
end
69+
70+
test "unsupported primary language falls through to a supported secondary", %{conn: conn} do
71+
conn =
72+
conn
73+
|> put_req_header("accept-language", "ru,de;q=0.9")
74+
|> get("/settings")
75+
76+
html = html_response(conn, 200)
77+
78+
assert html_lang(html) == ["de"]
79+
assert html =~ "Einstellungen"
80+
end
81+
82+
test "preserves Chinese script variants with BCP 47 lang tags", %{conn: conn} do
5183
html = conn |> get("/settings?locale=zh_Hans") |> html_response(200)
52-
assert html_lang(html) == ["zh_Hans"]
84+
assert html_lang(html) == ["zh-Hans"]
5385
assert html =~ "设置"
5486

5587
html = build_conn() |> get("/settings?locale=zh_Hant") |> html_response(200)
56-
assert html_lang(html) == ["zh_Hant"]
88+
assert html_lang(html) == ["zh-Hant"]
5789
assert html =~ "設定"
5890
end
5991

@@ -67,4 +99,28 @@ defmodule TeslaMateWeb.LocaleTest do
6799
assert html_lang(html) == ["de"]
68100
assert html =~ "Einstellungen"
69101
end
102+
103+
test "sessions from before the localize migration keep their locale", %{conn: conn} do
104+
conn =
105+
conn
106+
|> Plug.Test.init_test_session(%{"gettext_locale" => "de"})
107+
|> get("/settings")
108+
109+
html = html_response(conn, 200)
110+
111+
assert html_lang(html) == ["de"]
112+
assert html =~ "Einstellungen"
113+
end
114+
115+
test "supported_locales config stays in sync with the Gettext locales" do
116+
configured =
117+
:localize
118+
|> Application.fetch_env!(:supported_locales)
119+
|> Enum.map(&(&1 |> Atom.to_string() |> String.replace("-", "_")))
120+
|> Enum.sort()
121+
122+
gettext = TeslaMateWeb.Gettext |> Gettext.known_locales() |> Enum.sort()
123+
124+
assert configured == gettext
125+
end
70126
end

0 commit comments

Comments
 (0)