Skip to content

Commit d23e57a

Browse files
committed
Default is not download locales at runtime
1 parent 4639d49 commit d23e57a

11 files changed

Lines changed: 282 additions & 128 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ The format is based on
1212

1313
* Runtime locale downloading is now **opt-in**. The new `:allow_runtime_locale_download` application config key (default `false`) gates whether the `PersistentTerm` provider may download locale ETF files from the CDN when a cache miss occurs. When disabled (the default), a missing locale returns `{:error, %Localize.LocaleNotFoundInCacheError{}}` with a message that tells the user how to fix it (either run `mix localize.download_locales` or enable the config key). The `Localize.Locale.Provider` behaviour gains an optional `allow_download?/0` callback so alternative providers can implement their own download-gating logic.
1414

15-
* `mix localize.download_locales` — new Mix task (shipped with the hex package) that downloads locale ETF files from the Localize CDN and writes them to the configured cache directory. Three modes: `mix localize.download_locales en fr de` for specific locales, `--preload` to download the configured `:preload_locales`, and `--all` for all 766 CLDR locales. Intended for Dockerfiles, CI pipelines, and release builds.
15+
* `mix localize.download_locales` — new Mix task (shipped with the hex package) that downloads locale ETF files from the Localize CDN and writes them to the configured cache directory. With no arguments, downloads the configured `:supported_locales`. Also accepts explicit locale names (`mix localize.download_locales en fr de`) or `--all` for all CLDR locales. Intended for Dockerfiles, CI pipelines, and release builds.
1616

1717
* `Localize.Locale.expand_locale_list/2` — extracted from `Localize.Application` as a public function so mix tasks and user code can expand wildcard locale lists (e.g. `"en-*"`) against the known CLDR locale set.
1818

@@ -104,6 +104,8 @@ The format is based on
104104

105105
* Configuration option `:data_dir` renamed to `:locale_cache_dir`. Defaults to `Path.join(:code.priv_dir(:localize), "localize/locales")`.
106106

107+
* **`:preload_locales` is deprecated and ignored.** Use `:supported_locales` to declare your locale set and `mix localize.download_locales` to pre-populate the cache at build time. Locale data is loaded lazily into `:persistent_term` on first access. A deprecation warning is logged at application startup if `:preload_locales` is still configured.
108+
107109
* `Localize.List.to_string/2` and `Localize.List.intersperse/2` option `:format` renamed to `:list_style`. The companion helpers `known_list_formats/0` and `list_formats_for/1` were renamed to `known_list_styles/0` and `list_styles_for/1` for consistency. Freeing `:format` from list-specific use means it now passes through to per-element formatters: `Localize.List.to_string([~D[2025-07-10], ~D[2025-08-15]], locale: :en, format: :long)` produces `"July 10, 2025 and August 15, 2025"` because `:format` reaches `Localize.Date.to_string/2` for each element while the list join uses the default `:standard` style. The `list_patterns_for/1` helper is unchanged — it returns the underlying CLDR pattern data, not the style names.
108110

109111
### Added exceptions

DEVELOPMENT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ mix localize.upload_locale # Generate one locale and upload to Clou
291291

292292
# ── User tasks (lib/ tree, shipped with the hex package) ────────
293293
mix localize.download_locales en fr de # Download specific locale ETFs from the CDN
294-
mix localize.download_locales --preload # Download the :preload_locales from config
294+
mix localize.download_locales # Download configured :supported_locales (default)
295295
mix localize.download_locales --all # Download all 766 CLDR locales
296296
```
297297

README.md

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,6 @@ Localize requires no compile-time configuration. All options are set in your app
117117
config :localize,
118118
default_locale: :fr,
119119
supported_locales: [:en, :fr, :de, :ja, :es, "zh-*"],
120-
preload_locales: [:en, :de, :ja, "fr-*"],
121120
locale_provider: MyApp.LocaleProvider,
122121
locale_cache_max_entries: 2_000,
123122
format_cache_max_entries: 5_000,
@@ -130,8 +129,8 @@ config :localize,
130129
| Option | Default | Description |
131130
|--------|---------|-------------|
132131
| `:default_locale` | Derived from `LOCALIZE_DEFAULT_LOCALE` env var, then `LANG` env var, then `:en`. | The application-wide default locale. Can also be set at runtime with `Localize.put_default_locale/1`. |
133-
| `:supported_locales` | `nil` | A list of locale identifiers that your application supports. Each entry is an atom matching a known CLDR locale (e.g., `:en`, `:"fr-CA"`) or a wildcard string (e.g., `"en-*"`) that expands to all matching CLDR locales. Invalid entries log a warning and are skipped. When set, `validate_locale/1` resolves locale identifiers against this list rather than all ~766 CLDR locales. Accessible at runtime via `Localize.supported_locales/0`. See below for how this interacts with `:preload_locales`. |
134-
| `:preload_locales` | `nil` | A list of locale identifiers to load at application startup. Accepts atoms and wildcard strings like `:supported_locales`. Locale data is fetched and cached in `:persistent_term` before any formatting calls. Invalid entries log a warning and are skipped. See below for how this interacts with `:supported_locales`. |
132+
| `:supported_locales` | `nil` | A list of locale identifiers that your application supports. Each entry is an atom matching a known CLDR locale (e.g., `:en`, `:"fr-CA"`), a wildcard string (e.g., `"en-*"`), a coverage-level keyword (`:modern`, `:moderate`, `:basic`), or a Gettext-style string (e.g., `"pt_BR"`, `"zh_Hans"`). POSIX-style underscores are normalised to hyphens and entries are resolved to their CLDR canonical form via likely-subtag resolution (e.g. `"pt_BR"``:pt`). Only exact matches (score 0) are accepted — entries that cannot be resolved log a warning with `domain: :localize` and are skipped. When set, `validate_locale/1` resolves locale identifiers against this list rather than all ~766 CLDR locales. Accessible at runtime via `Localize.supported_locales/0`. |
133+
| `:preload_locales` | **deprecated** | Deprecated and ignored. Use `:supported_locales` to declare your locale set and `mix localize.download_locales` to pre-populate the cache at build time. |
135134
| `:locale_provider` | `Localize.Locale.Provider.PersistentTerm` | Module that implements the `Localize.Locale.Provider` behaviour for loading and caching per-locale data. |
136135
| `:locale_cache_max_entries` | `1_000` | Maximum number of validated locales to hold in the ETS cache. A background sweeper runs every 10 seconds and evicts excess entries to prevent unbounded growth. |
137136
| `:format_cache_max_entries` | `2_000` | Maximum number of compiled format patterns (number and date/time) to hold in the ETS cache. A background sweeper runs every 10 seconds and evicts excess entries to prevent unbounded growth. |
@@ -141,20 +140,30 @@ config :localize,
141140
| `:cacertfile` | System default | Path to a custom CA certificate file for HTTPS connections (used when downloading locale data). |
142141
| `:https_proxy` | `nil` | HTTPS proxy URL. Also reads the `HTTPS_PROXY` environment variable. |
143142

144-
### Supported and preload locale interaction
143+
### Using Gettext locales
145144

146-
When `:supported_locales` is configured, the effective supported locale list is the **union** of `:supported_locales` and `:preload_locales`. This means any locale you preload is automatically considered supported — you don't need to list it in both places.
145+
If your application uses Gettext, you can derive `:supported_locales` from your Gettext backend in `config/runtime.exs` (where the module is already compiled and available):
147146

148147
```elixir
148+
# config/runtime.exs
149149
config :localize,
150-
supported_locales: [:en, :fr, :de],
151-
preload_locales: [:ja]
150+
supported_locales: Gettext.known_locales(MyApp.Gettext)
151+
```
152+
153+
POSIX-style locale names returned by Gettext (e.g. `"pt_BR"`, `"zh_Hans"`) are automatically normalized to BCP 47 and resolved to their CLDR canonical form (`:pt`, `:zh`). No manual mapping is needed.
154+
155+
### Pre-populating the locale cache
152156

153-
# Effective supported locales: [:en, :fr, :de, :ja]
154-
# Locale data for :ja is preloaded at startup
157+
Use `mix localize.download_locales` at build time to download locale data into the on-disk cache. By default it downloads the configured `:supported_locales`:
158+
159+
```bash
160+
# Dockerfile
161+
RUN mix localize.download_locales
155162
```
156163

157-
When `:supported_locales` is **not** configured (the default), `validate_locale/1` matches against all ~766 CLDR locales and `:preload_locales` simply controls which locale data is loaded eagerly.
164+
Specific locales can also be downloaded explicitly: `mix localize.download_locales en fr de`. Use `--all` for all CLDR locales. Locale data is loaded lazily into `:persistent_term` on first access from the cache.
165+
166+
When `:supported_locales` is **not** configured (the default), `validate_locale/1` matches against all ~766 CLDR locales.
158167

159168
## Environment variables
160169

guides/migration.md

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,25 +24,50 @@ Localize requires no compile-time configuration. All options are set in your app
2424

2525
### Recommended migration config
2626

27-
Most ex_cldr projects configure a fixed set of locales in the backend module. The Localize equivalent is `:supported_locales` (constrains validation) and `:preload_locales` (eagerly loads locale data at startup):
27+
Most ex_cldr projects configure a fixed set of locales in the backend module. The Localize equivalent is `:supported_locales` (constrains validation) plus `mix localize.download_locales` (pre-populates the cache at build time):
2828

2929
```elixir
3030
# config/config.exs
3131
config :localize,
3232
default_locale: :en,
33-
supported_locales: [:en, :fr, :de, :ja],
34-
preload_locales: [:en, :fr, :de, :ja]
33+
supported_locales: [:en, :fr, :de, :ja]
3534
```
3635

37-
In ex_cldr, these were declared inside `use Cldr, locales: [...]`. In Localize they are application environment keys, so no recompilation is needed when the list changes.
36+
```bash
37+
# At build time (Dockerfile, CI, or local)
38+
mix localize.download_locales
39+
```
40+
41+
In ex_cldr, locales were declared inside `use Cldr, locales: [...]` and embedded at compile time. In Localize, `:supported_locales` is an application environment key (no recompilation needed), and locale data is downloaded once at build time and loaded lazily into `:persistent_term` on first access.
42+
43+
### Using Gettext locales
44+
45+
If your application uses Gettext, you can derive `:supported_locales` from your Gettext backend. Since the Gettext module must be compiled first, use `config/runtime.exs`:
46+
47+
```elixir
48+
# config/runtime.exs
49+
config :localize,
50+
supported_locales: Gettext.known_locales(MyApp.Gettext)
51+
```
52+
53+
POSIX-style locale names returned by Gettext (e.g. `"pt_BR"`, `"zh_Hans"`) are automatically normalised to BCP 47 and resolved to their CLDR canonical form via likely-subtag resolution. For example, `"pt_BR"` resolves to `:pt` (CLDR treats bare `pt` as Brazilian Portuguese) and `"zh_Hans"` resolves to `:zh`. No manual mapping is needed.
54+
55+
Only exact matches (distance score 0 in the CLDR matching algorithm) are accepted for `:supported_locales` — this ensures that misspelled or unrecognised locale names are caught at startup rather than silently mapping to a distant locale. Entries that cannot be resolved log a warning with `domain: :localize` and are skipped.
56+
57+
Coverage-level keywords (`:modern`, `:moderate`, `:basic`) are also accepted and expand to all CLDR locales at or above that level:
58+
59+
```elixir
60+
config :localize,
61+
supported_locales: [:modern] # ~104 locales with modern CLDR coverage
62+
```
3863

3964
### Full options reference
4065

4166
| Option | Default | Description |
4267
|---|---|---|
4368
| `:default_locale` | Derived from `LOCALIZE_DEFAULT_LOCALE` env var → `LANG` env var → `:en` | Application-wide default locale. |
44-
| `:supported_locales` | `nil` (all 766 CLDR locales) | List of locale atoms and/or wildcard strings (e.g. `"en-*"`). When set, `validate_locale/1` resolves against this list instead of all CLDR locales. |
45-
| `:preload_locales` | `nil` | List of locales to load into `:persistent_term` at application startup. Anything in `:preload_locales` is automatically considered supported. |
69+
| `:supported_locales` | `nil` (all 766 CLDR locales) | List of locale atoms, wildcard strings (e.g. `"en-*"`), coverage-level keywords (`:modern`, `:moderate`, `:basic`), or Gettext-style strings (e.g. `"pt_BR"`). POSIX underscores are normalised and entries are resolved via likely-subtag resolution — only exact matches (score 0) are accepted. Invalid entries log a warning and are skipped. When set, `validate_locale/1` resolves against this list instead of all CLDR locales. |
70+
| `:preload_locales` | **deprecated** | Deprecated and ignored. Use `:supported_locales` and `mix localize.download_locales`. |
4671
| `:locale_cache_dir` | `Application.app_dir(:localize, "priv/localize/locales")` | Directory where downloaded locale ETF files are cached. |
4772
| `:allow_runtime_locale_download` | `false` | When `true`, locales not in the cache are downloaded from the CDN on first access. Default `false` — use `mix localize.download_locales` to pre-populate at build time. |
4873
| `:locale_provider` | `Localize.Locale.Provider.PersistentTerm` | Module implementing `Localize.Locale.Provider` for locale data loading. |
@@ -88,9 +113,9 @@ iex> Localize.with_locale(:ja, fn ->
88113
{:ok, "1,234"}
89114
```
90115

91-
### Supported and preload locale interaction
116+
### Pre-populating the locale cache
92117

93-
When `:supported_locales` is configured, the effective supported locale list is the **union** of `:supported_locales` and `:preload_locales`. Any locale you preload is automatically considered supported — you don't need to list it in both places.
118+
Run `mix localize.download_locales` at build time to download locale data for all configured `:supported_locales`. Locale data is then loaded lazily into `:persistent_term` on first access — no runtime downloads needed.
94119

95120
## Dependency changes
96121

lib/localize.ex

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1205,7 +1205,10 @@ defmodule Localize do
12051205
locale. When given a binary locale identifier, it is parsed into
12061206
a `t:Localize.LanguageTag.t/0`. When given an existing language tag
12071207
whose `:cldr_locale_id` is not yet populated, a best-match
1208-
resolution is attempted using `Localize.LanguageTag.best_match/2`.
1208+
resolution is attempted using `Localize.LanguageTag.best_match/3`.
1209+
1210+
POSIX-style locale names (e.g. `"pt_BR"`, `"zh_Hans"`) are
1211+
accepted — underscores are normalized to hyphens before parsing.
12091212
12101213
## Locale resolution
12111214
@@ -1215,16 +1218,31 @@ defmodule Localize do
12151218
12161219
* If `config :localize, supported_locales: [...]` is
12171220
configured, the candidate list is the resolved supported
1218-
locales (the union of `:supported_locales` and
1219-
`:preload_locales`). This restricts matching to only the
1220-
locales your application explicitly supports.
1221+
locales. This restricts matching to only the locales your
1222+
application explicitly supports.
12211223
12221224
* If `:supported_locales` is not configured, the candidate
12231225
list is all CLDR locale IDs.
12241226
12251227
Validated locale results are cached in an ETS table so
12261228
repeated calls with the same identifier are fast (~1µs).
12271229
1230+
> #### Always returns a result {: .warning}
1231+
>
1232+
> This function uses the CLDR locale matching algorithm, which
1233+
> is designed to **always return a result** when the candidate
1234+
> list is non-empty — even if the match is very distant. For
1235+
> example, `validate_locale("xyzzy")` will succeed and return
1236+
> some CLDR locale (typically the first candidate), not an error.
1237+
> This is the correct CLDR behaviour for user-facing locale
1238+
> negotiation (a distant match is better than no match), but it
1239+
> means the returned locale may not be what the caller expected.
1240+
>
1241+
> For strict validation (e.g. resolving configuration values),
1242+
> use `Localize.LanguageTag.best_match/3` with a threshold of
1243+
> `0` to accept only exact matches after likely-subtag
1244+
> resolution.
1245+
12281246
### Arguments
12291247
12301248
* `locale` is a locale identifier binary, an atom, or a
@@ -1248,6 +1266,10 @@ defmodule Localize do
12481266
iex> tag.cldr_locale_id
12491267
:en
12501268
1269+
iex> {:ok, tag} = Localize.validate_locale("pt_BR")
1270+
iex> tag.cldr_locale_id
1271+
:pt
1272+
12511273
"""
12521274
@spec validate_locale(Localize.LanguageTag.t() | String.t() | atom()) ::
12531275
{:ok, Localize.LanguageTag.t()} | {:error, Exception.t()}

lib/localize/application.ex

Lines changed: 19 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -42,50 +42,35 @@ defmodule Localize.Application do
4242
end
4343
end
4444

45-
# ── Supported and preload locales ─────────────────────────────
45+
# ── Supported locales ───────────────────────────────────────────
4646

4747
defp resolve_supported_locales do
48-
supported = expand_config_locales(:supported_locales)
49-
preload = expand_config_locales(:preload_locales)
48+
maybe_warn_deprecated_preload()
5049

51-
# Store the union of supported and preload locales, but only
52-
# if :supported_locales was explicitly configured.
5350
case Application.get_env(:localize, :supported_locales) do
5451
nil ->
5552
:ok
5653

57-
_configured ->
58-
merged =
59-
(supported ++ preload)
60-
|> Enum.uniq()
61-
62-
:persistent_term.put({:localize, :supported_locales}, merged)
54+
locales when is_list(locales) ->
55+
expanded = Localize.Locale.expand_locale_list(locales, :supported_locales)
56+
:persistent_term.put({:localize, :supported_locales}, expanded)
6357
end
64-
65-
# Preload locale data for the preload list. Failures are
66-
# logged as warnings rather than crashing the supervisor —
67-
# the locale will be loaded on first access (or the user
68-
# will get a clear error if downloads are disabled and the
69-
# locale is not in the cache).
70-
Enum.each(preload, fn locale ->
71-
case Localize.Locale.Loader.load_and_store(locale) do
72-
:ok ->
73-
:ok
74-
75-
{:error, exception} ->
76-
Logger.warning(
77-
"Failed to preload locale #{inspect(locale)}: #{Exception.message(exception)}",
78-
domain: [:localize]
79-
)
80-
end
81-
end)
8258
end
8359

84-
defp expand_config_locales(config_key) do
85-
case Application.get_env(:localize, config_key) do
86-
nil -> []
87-
[] -> []
88-
locales when is_list(locales) -> Localize.Locale.expand_locale_list(locales, config_key)
60+
defp maybe_warn_deprecated_preload do
61+
case Application.get_env(:localize, :preload_locales) do
62+
nil ->
63+
:ok
64+
65+
_configured ->
66+
Logger.warning(
67+
"The :preload_locales configuration key is deprecated and ignored. " <>
68+
"Use :supported_locales to declare your locale set, and " <>
69+
"`mix localize.download_locales` to pre-populate the cache " <>
70+
"at build time. Locale data is loaded lazily into " <>
71+
":persistent_term on first access.",
72+
domain: [:localize]
73+
)
8974
end
9075
end
9176
end

0 commit comments

Comments
 (0)