Skip to content

Commit d68334e

Browse files
committed
Use CLDR exemplar cities for timezone display names
1 parent d284905 commit d68334e

4 files changed

Lines changed: 195 additions & 61 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
1616

1717
### Fixed
1818

19+
* Timezone display names use the exemplar city CLDR ships rather than one derived from the IANA identifier. The lookup matched `%{city: _}` where the zone data keys `:exemplar_city`, so it never fired: 48 zones in `en` and 190 in `ja` were named from the identifier instead, losing renames (`America/Godthab` is `"Nuuk"`), accents (`"Córdoba"`), and in non-Latin locales the script entirely.
20+
21+
* Timezone display names resolve three-part identifiers such as `America/Indiana/Knox`, whose leaf CLDR keys by string one level deeper. They were split into two parts, so the lookup sought a city named `"Indiana/Knox"` and fell back to `"Knox"` rather than CLDR's `"Knox, Indiana"`.
22+
1923
* `Localize.Number.Parser.scan/2` finds a grouped number written with an ordinary keyboard space in a locale that formats with U+202F. Under `fr`, `scan("1 234,5")` was `[1, " ", 234.5]` and is now `[1234.5]`.
2024

2125
* `Localize.Number.parse/2` treats every character in `[:Zs:]` as a grouping space, per TR35's loose matching for lenient parsing. It previously accepted only U+0020 and the locale's own separator, so a `fr` number carrying U+00A0 or U+2009 — as copied out of formatted output — failed to parse.
@@ -24,6 +28,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
2428

2529
* `Localize.Number.parse/2` applies CLDR's `parseLenients` character folds, which were generated into the locale data but never read. The minus, plus, comma and full-stop families now fold to their canonical form, so the 18 locales writing `minusSign` as U+2212 — `fa`, `fi`, `sv` and `no` among them — parse their own negative numbers back. Per TR35 the fold applies to the locale's own separators as well as the input, which is what lets `de-CH` accept both spellings of its apostrophe group separator.
2630

31+
### Added
32+
33+
* `Localize.DateTime.Timezone.exemplar_city/3` returns the localized exemplar city for an IANA timezone identifier. `derive: false` returns an error rather than deriving a name from the identifier, distinguishing a name CLDR vouches for from one this library invented.
34+
2735
## [1.1.1] — August 16th, 2026
2836

2937
### Fixed

lib/localize/datetime/timezone.ex

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,139 @@ defmodule Localize.DateTime.Timezone do
773773
end
774774
end
775775

776+
@doc """
777+
Returns the exemplar city for an IANA timezone identifier.
778+
779+
CLDR names a representative city for most timezones — the city a
780+
reader would recognise the zone by — localized, and sometimes
781+
differing from the city in the identifier: `"America/Godthab"` is
782+
`"Nuuk"`, which is what the place is now called.
783+
784+
### Arguments
785+
786+
* `iana_id` is an IANA timezone identifier such as
787+
`"America/Los_Angeles"` or `"America/Indiana/Knox"`.
788+
789+
* `locale` is a locale identifier or a `t:Localize.LanguageTag.t/0`.
790+
The default is `Localize.get_locale/0`.
791+
792+
* `options` is a keyword list of options.
793+
794+
### Options
795+
796+
* `:derive` determines what happens when CLDR names no exemplar city
797+
for the zone. `true`, the default, derives one from the identifier,
798+
so `"Pacific/Wallis"` yields `"Wallis"`. `false` returns an error
799+
instead, which distinguishes a name CLDR vouches for from one this
800+
library invented.
801+
802+
### Returns
803+
804+
* `{:ok, city}`, or
805+
806+
* `{:error, exception}` if the locale is unknown, or if the zone has
807+
no exemplar city and `derive: false` was given.
808+
809+
### Examples
810+
811+
iex> Localize.DateTime.Timezone.exemplar_city("America/Los_Angeles", :en)
812+
{:ok, "Los Angeles"}
813+
814+
iex> Localize.DateTime.Timezone.exemplar_city("America/Godthab", :en)
815+
{:ok, "Nuuk"}
816+
817+
iex> Localize.DateTime.Timezone.exemplar_city("America/Indiana/Knox", :en)
818+
{:ok, "Knox, Indiana"}
819+
820+
iex> Localize.DateTime.Timezone.exemplar_city("Atlantic/Azores", :de)
821+
{:ok, "Azoren"}
822+
823+
iex> {:error, exception} =
824+
...> Localize.DateTime.Timezone.exemplar_city("Neverwhere/Nowhere", :en, derive: false)
825+
iex> exception.__struct__
826+
Localize.UnknownTimezoneError
827+
828+
"""
829+
@spec exemplar_city(String.t(), Localize.locale(), Keyword.t()) ::
830+
{:ok, String.t()} | {:error, Exception.t()}
831+
def exemplar_city(iana_id, locale \\ Localize.get_locale(), options \\ [])
832+
833+
def exemplar_city(iana_id, locale, options) when is_binary(iana_id) do
834+
with {:ok, language_tag} <- Localize.validate_locale(locale) do
835+
zone =
836+
case Localize.Locale.get(language_tag, [:dates, :time_zone_names]) do
837+
{:ok, tz_data} -> Map.get(tz_data, :zone, %{})
838+
{:error, _reason} -> %{}
839+
end
840+
841+
case find_exemplar_city(iana_id, zone) do
842+
nil -> derived_exemplar_city(iana_id, options)
843+
city -> {:ok, city}
844+
end
845+
end
846+
end
847+
848+
defp derived_exemplar_city(iana_id, options) do
849+
with true <- Keyword.get(options, :derive, true),
850+
city when is_binary(city) <- derive_city_from_id(iana_id) do
851+
{:ok, city}
852+
else
853+
_no_city -> {:error, Localize.UnknownTimezoneError.exception(timezone: iana_id)}
854+
end
855+
end
856+
857+
# The zone data is structured as
858+
# %{america: %{los_angeles: %{type: :zone, exemplar_city: "Los Angeles"}}}.
859+
# A three-part identifier — "America/Indiana/Knox" — nests one level deeper,
860+
# and CLDR keys that leaf by string rather than by atom.
861+
defp find_exemplar_city(iana_id, zone) do
862+
case String.split(iana_id, "/") do
863+
[region, city] ->
864+
exemplar_city_name(zone, [zone_key(region), zone_key(city)])
865+
866+
[region, group, city] ->
867+
exemplar_city_name(zone, [zone_key(region), zone_key(group), leaf_key(city)])
868+
869+
_other ->
870+
nil
871+
end
872+
end
873+
874+
defp exemplar_city_name(zone, keys) do
875+
if Enum.all?(keys, & &1) do
876+
case get_in(zone, keys) do
877+
# A zone CLDR gives no exemplar city for carries only its long and
878+
# short names.
879+
%{exemplar_city: city_name} -> city_name
880+
_other -> nil
881+
end
882+
end
883+
end
884+
885+
# Gate atomisation on existing-atom membership. The zone data has
886+
# pre-atomised keys for legitimate IANA components; an attacker-controlled
887+
# `-u-tz-` extension value with an unknown region or city must not be allowed
888+
# to grow the atom table.
889+
defp zone_key(component) do
890+
component
891+
|> leaf_key()
892+
|> Localize.Utils.Helpers.existing_atom()
893+
end
894+
895+
defp leaf_key(component) do
896+
component
897+
|> String.downcase()
898+
|> String.replace(" ", "_")
899+
end
900+
901+
# "America/Los_Angeles" -> "Los Angeles", "America/Argentina/Salta" -> "Salta"
902+
defp derive_city_from_id(iana_id) do
903+
case String.split(iana_id, "/") do
904+
[_single_component] -> nil
905+
parts -> parts |> List.last() |> String.replace("_", " ")
906+
end
907+
end
908+
776909
defp pad(integer, n) when is_integer(integer) do
777910
str = Integer.to_string(integer)
778911
padding = n - String.length(str)

lib/localize/locale/locale_display/u.ex

Lines changed: 17 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ defmodule Localize.Locale.LocaleDisplay.U do
44
import Localize.Locale.LocaleDisplay,
55
only: [get_display_preference: 2, join_field_values: 2, replace_nested_brackets: 2]
66

7+
alias Localize.DateTime.Timezone
8+
79
# Mapping from BCP47 U extension struct field atoms to the
810
# CLDR key names used in locale_display_names[:keys] and [:types].
911
# Fields not in this map use the field atom directly.
@@ -321,21 +323,12 @@ defmodule Localize.Locale.LocaleDisplay.U do
321323
# 4. Format using the locale's regionFormat pattern
322324
# (e.g., "{0} Time").
323325
defp get_timezone_display_name(iana_id, locale_id) when is_binary(iana_id) do
324-
alias Localize.DateTime.Timezone
325-
326326
case Localize.Locale.get(locale_id, [:dates, :time_zone_names]) do
327327
{:ok, tz_data} ->
328328
region_format = get_in(tz_data, [:region_format, :generic])
329329
territory = Map.get(Timezone.territories_by_timezone(), iana_id)
330330

331-
location =
332-
if territory && Timezone.timezone_count_for_territory(territory) == {:ok, 1} do
333-
# If the territory has a single timezone, use the country name
334-
get_territory_name(territory, locale_id)
335-
else
336-
# Otherwise use the exemplar city
337-
find_exemplar_city(iana_id, tz_data) || derive_city_from_id(iana_id)
338-
end
331+
location = timezone_location(iana_id, territory, locale_id)
339332

340333
if location && region_format do
341334
Localize.Substitution.substitute(location, region_format)
@@ -349,6 +342,20 @@ defmodule Localize.Locale.LocaleDisplay.U do
349342
end
350343
end
351344

345+
# Steps 2 and 3 of the non-location format algorithm: a territory with only
346+
# one timezone is named by the country, and anything else by the zone's
347+
# exemplar city.
348+
defp timezone_location(iana_id, territory, locale_id) do
349+
if territory && Timezone.timezone_count_for_territory(territory) == {:ok, 1} do
350+
get_territory_name(territory, locale_id)
351+
else
352+
case Timezone.exemplar_city(iana_id, locale_id) do
353+
{:ok, city} -> city
354+
{:error, _reason} -> nil
355+
end
356+
end
357+
end
358+
352359
# Look up the display name for a territory.
353360
defp get_territory_name(territory, locale_id) do
354361
case Localize.Territory.display_name(territory, locale: locale_id) do
@@ -357,57 +364,6 @@ defmodule Localize.Locale.LocaleDisplay.U do
357364
end
358365
end
359366

360-
# Look up an explicit exemplar city in the zone data.
361-
# The zone data is structured as %{america: %{los_angeles: %{city: "Los Angeles"}, ...}}
362-
defp find_exemplar_city(iana_id, tz_data) do
363-
zone = Map.get(tz_data, :zone, %{})
364-
365-
case String.split(iana_id, "/", parts: 2) do
366-
[region, city] ->
367-
# Gate atomisation on existing-atom membership. The zone data
368-
# has pre-atomised keys for legitimate IANA components; an
369-
# attacker-controlled `-u-tz-` extension value with unknown
370-
# region or city must not be allowed to grow the atom table.
371-
region_key = region |> String.downcase() |> Localize.Utils.Helpers.existing_atom()
372-
373-
city_key =
374-
city
375-
|> String.downcase()
376-
|> String.replace(" ", "_")
377-
|> Localize.Utils.Helpers.existing_atom()
378-
379-
exemplar_city_name(zone, region_key, city_key)
380-
381-
_ ->
382-
nil
383-
end
384-
end
385-
386-
defp exemplar_city_name(zone, region_key, city_key) do
387-
if region_key && city_key do
388-
case get_in(zone, [region_key, city_key]) do
389-
%{city: city_name} -> city_name
390-
_ -> nil
391-
end
392-
end
393-
end
394-
395-
# Derive city display name from IANA timezone ID.
396-
# "America/Los_Angeles" → "Los Angeles"
397-
# "Europe/London" → "London"
398-
# "America/Argentina/Buenos_Aires" → "Buenos Aires"
399-
defp derive_city_from_id(iana_id) do
400-
case String.split(iana_id, "/") do
401-
[_] ->
402-
nil
403-
404-
parts ->
405-
parts
406-
|> List.last()
407-
|> String.replace("_", " ")
408-
end
409-
end
410-
411367
# Validity values for deprecated spellings are tagged
412368
# `{:deprecated, preferred}`; display always uses the preferred
413369
# form.

test/localize/locale/locale_display_u_test.exs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,43 @@ defmodule Localize.Locale.LocaleDisplayUTest do
4747
end
4848
end
4949

50+
# Step 3 of CLDR's non-location format algorithm uses the zone's exemplar
51+
# city. The zone data keys that `:exemplar_city`, but the lookup matched
52+
# `%{city: _}`, so it never fired and every zone fell through to a city name
53+
# derived from the IANA id instead — 48 zones in `en` and 190 in `ja`.
54+
describe "display_name/2 -u-tz- exemplar cities" do
55+
test "uses CLDR's exemplar city rather than one derived from the IANA id" do
56+
# CLDR renamed this city; the IANA id still says Godthab.
57+
assert {:ok, "English (Time Zone: Nuuk Time)"} =
58+
LocaleDisplay.display_name("en-u-tz-glgoh")
59+
60+
assert {:ok, "Deutsch (Zeitzone: Nuuk [Ortszeit])"} =
61+
LocaleDisplay.display_name("de-u-tz-glgoh", locale: :de)
62+
end
63+
64+
test "keeps the accents that deriving from the id would drop" do
65+
assert {:ok, "Deutsch (Zeitzone: Azoren [Ortszeit])"} =
66+
LocaleDisplay.display_name("de-u-tz-ptpdl", locale: :de)
67+
end
68+
69+
test "descends into a three-part zone id, whose leaf CLDR keys by string" do
70+
# "America/Indiana/Knox" — splitting into two parts looked for a city
71+
# named "Indiana/Knox", so the qualifier CLDR adds was lost.
72+
assert {:ok, "English (Time Zone: Knox, Indiana Time)"} =
73+
LocaleDisplay.display_name("en-u-tz-usknx")
74+
75+
assert {:ok, "Deutsch (Zeitzone: R\u00edo Gallegos [Ortszeit])"} =
76+
LocaleDisplay.display_name("de-u-tz-arrgl", locale: :de)
77+
end
78+
79+
test "an unknown zone still falls back to a name derived from the id" do
80+
# A zone CLDR has no exemplar city for, and an unknown one, must both
81+
# degrade rather than raise — and must not grow the atom table.
82+
assert {:ok, "English (Time Zone: UTC Time)"} =
83+
LocaleDisplay.display_name("en-u-tz-utc")
84+
end
85+
end
86+
5087
describe "display_name/2 -u- key ordering and composition" do
5188
test "multiple keys are displayed sorted by BCP47 key, not input order" do
5289
# Input order is ca, nu, hc but display order is ca, hc, nu.

0 commit comments

Comments
 (0)