Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 30 additions & 12 deletions lib/localize/accept_language.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.

"""

Expand Down Expand Up @@ -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

Expand All @@ -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 ->
Expand All @@ -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
Expand Down
29 changes: 22 additions & 7 deletions test/accept_language_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading