diff --git a/lib/localize/accept_language.ex b/lib/localize/accept_language.ex index 95da58c..8a7099a 100644 --- a/lib/localize/accept_language.ex +++ b/lib/localize/accept_language.ex @@ -2,7 +2,7 @@ defmodule Localize.AcceptLanguage do @moduledoc """ Parses HTTP `Accept-Language` headers and finds the best matching locale. - The `Accept-Language` header is parsed per [RFC 2616](https://www.rfc-editor.org/rfc/rfc2616#section-14.4) into quality-tagged language tags which are then matched against available locales using `Localize.validate_locale/1`. The primary entry point is `best_match/1` which returns the highest-quality successfully validated locale. + The `Accept-Language` header is parsed per [RFC 2616](https://www.rfc-editor.org/rfc/rfc2616#section-14.4) into quality-tagged language tags which are then matched against the supported locales. The primary entry point is `best_match/1` which returns the highest-quality tag that matches a supported locale. """ @@ -75,8 +75,10 @@ defmodule Localize.AcceptLanguage do @doc """ Returns the best matching locale for the given `Accept-Language` header. - Parses the header, validates each language tag, and returns the - highest-quality successfully validated locale. + Parses the header and returns the highest-quality language tag that + matches a supported locale. A tag that only resolves to a supported + locale by fallback, such as `ja` when no Japanese locale is supported, + is skipped in favour of the next tag in the header. ### Arguments @@ -101,15 +103,7 @@ defmodule Localize.AcceptLanguage do result = header |> tokenize() - |> Enum.find_value(fn {_quality, tag} -> - case Localize.validate_locale(tag) do - {:ok, %Localize.LanguageTag{cldr_locale_id: id} = locale} when not is_nil(id) -> - locale - - _other -> - nil - end - end) + |> Enum.find_value(fn {_quality, tag} -> supported_match(tag) end) case result do %Localize.LanguageTag{} = locale -> @@ -120,6 +114,30 @@ defmodule Localize.AcceptLanguage do end end + # `Localize.validate_locale/1` never rejects a valid tag: with no close + # supported locale it falls back to the first one (`ja` resolves to + # `de-CH` data when only `de-CH` and `en-CH` are supported). A distance + # of 80 means unrelated languages, so a match within 79 keeps every + # CLDR match (`en-US` to `en-CH`, `gsw` to `de-CH`) and skips the + # fallback. When CLDR matches across languages, the supported locale + # is returned, so its language is the one being served. + @max_match_distance 79 + + defp supported_match(tag) do + with {:ok, requested} <- Localize.validate_locale(tag), + {:ok, locale_id, _distance} <- + Localize.LanguageTag.best_match( + requested, + [requested.cldr_locale_id], + @max_match_distance + ), + {:ok, served} <- Localize.validate_locale(locale_id) do + if served.language == requested.language, do: requested, else: served + else + _no_match -> nil + end + end + # Split on the `;q=` weight parameter. A well-formed tag has at most one # weight, but real-world Accept-Language headers sometimes contain # duplicates (e.g. `"ja;q=0.9;q=0.9"`). Per RFC 9110 ยง5.3 duplicates are diff --git a/test/accept_language_test.exs b/test/accept_language_test.exs index 6eaa01f..c265712 100644 --- a/test/accept_language_test.exs +++ b/test/accept_language_test.exs @@ -262,19 +262,34 @@ defmodule Localize.AcceptLanguageTest do AcceptLanguage.best_match("fr-CH,fr;q=0.9,en;q=0.8,de;q=0.7") end - test "handles Romanian with English variants" do + test "skips unsupported Romanian and matches en-us" do {:ok, locale} = AcceptLanguage.best_match("ro-RO,ro;q=0.8,en-us;q=0.6,en-gb;q=0.4,en;q=0.2") - assert locale.language == :ro + assert %{language: :en, territory: :US, cldr_locale_id: :en} = locale end - test "handles Polish with German and English fallback" do + test "skips unsupported Polish and matches de-DE" do {:ok, locale} = AcceptLanguage.best_match("pl,de-DE;q=0.9,de;q=0.8,en;q=0.7") - assert locale.language == :pl + assert %{language: :de, territory: :DE, cldr_locale_id: :de} = locale end - test "handles es-419 Latin American Spanish" do + test "skips unsupported es-419 and es and matches en" do {:ok, locale} = AcceptLanguage.best_match("es-419,es;q=0.9,en;q=0.8") - assert locale.language == :es + assert %{language: :en, cldr_locale_id: :en} = locale + end + + test "skips unsupported es-ES and matches fr-CH to fr" do + {:ok, locale} = AcceptLanguage.best_match("es-ES,fr-CH;q=0.9,en;q=0.8") + assert %{language: :fr, territory: :CH, cldr_locale_id: :fr} = locale + end + + test "returns the supported locale when CLDR matches gsw-CH to de" do + {:ok, locale} = AcceptLanguage.best_match("gsw-CH,fr;q=0.9") + assert %{language: :de, cldr_locale_id: :de} = locale + end + + test "returns an error when pt-BR and ko match no supported locale" do + assert {:error, %Localize.UnknownLocaleError{}} = + AcceptLanguage.best_match("pt-BR,ko;q=0.9") end test "handles header with low self-rating" do @@ -291,7 +306,7 @@ defmodule Localize.AcceptLanguageTest do header = "es-ES,es;q=0.9,en-US;q=0.8,en;q=0.7,es-MX;q=0.6" {:ok, locale} = AcceptLanguage.best_match(header) - assert locale.language == :es + assert %{language: :en, territory: :US, cldr_locale_id: :en} = locale end test "handles empty string" do