|
| 1 | +--- |
| 2 | +name: localize |
| 3 | +description: Write localized Elixir applications with the Localize library (hex package "localize") — formatting numbers, currencies, dates, times, durations, units of measure, lists, collation/sorting, and MessageFormat 2 messages. Use this skill whenever an Elixir app renders numbers, money, dates, times, quantities, or sorted text for people to read — even if the app targets a single locale — and for any task mentioning localize, ex_cldr migration, CLDR, locale validation, language tags, plural rules, or translatable message strings. Also use it when reviewing Elixir code that formats values by hand (interpolation, Calendar.strftime, :io_lib.format, Enum.sort on user-visible strings) since Localize is usually the better replacement. |
| 4 | +license: Apache-2.0 |
| 5 | +--- |
| 6 | + |
| 7 | +# Writing localized Elixir applications with Localize |
| 8 | + |
| 9 | +Localize (hex: `localize`) formats numbers, currencies, dates/times, units, lists and messages using Unicode CLDR data, and sorts text with the Unicode Collation Algorithm. It is the successor to the `ex_cldr` family: one package, no compile-time backend modules, data loaded at runtime. |
| 10 | + |
| 11 | +## Why use it even for a single-locale app |
| 12 | + |
| 13 | +Hand-formatting is where localization bugs are born, and most of them bite in English too: |
| 14 | + |
| 15 | +* `"#{count} files"` is wrong for `count == 1`. `Localize.Message.format/3` or a cardinal plural rule gets "1 file / 2 files" right for free. |
| 16 | +* `"$#{amount}"` breaks on 1000000 (no grouping), negative amounts, and non-USD currencies. `Localize.Number.to_string(amount, format: :currency)` renders "$1,234.56" correctly today and "1.234,56 €" the day the app grows a second locale. |
| 17 | +* `Calendar.strftime/3` hardcodes one ordering and one language of month names. `Localize.Date.to_string/2` gives the locale's convention. |
| 18 | +* `Enum.sort/1` on user-visible strings puts "Zebra" before "apple" and mishandles accents. `Localize.Collation.sort/2` sorts the way people expect. |
| 19 | + |
| 20 | +So when writing or reviewing Elixir that renders values for humans, reach for the Localize call rather than string interpolation, `strftime`, or raw `Enum.sort` — the single-locale output is equally correct and the app is localization-ready without a rewrite. |
| 21 | + |
| 22 | +## Core conventions (apply everywhere) |
| 23 | + |
| 24 | +* **Results are tagged tuples**: `{:ok, formatted}` or `{:error, %SomeError{}}` where the error is an exception struct with a readable `Exception.message/1`. Every formatter has a `!` variant that returns the bare value or raises. |
| 25 | +* **Every formatting function takes a `:locale` option** defaulting to the process locale `Localize.get_locale()` (itself defaulting to the app config, then `:en`). Set it per-process with `Localize.put_locale/1`, per-call with `locale: "fr"`, or per-block with `Localize.with_locale/2`. |
| 26 | +* **Validate untrusted locale input** with `Localize.validate_locale/1` — it parses, canonicalizes (aliases like `iw` → `he`), resolves likely subtags, applies `-u-` extensions, and caches. Pass the resulting `LanguageTag` around rather than re-validating strings. |
| 27 | +* **Zero config is valid config.** With no configuration, `:en` works out of the box; other locales download on first use (integrity-verified). Configure `supported_locales` and `default_locale` when the app knows its audience. |
| 28 | + |
| 29 | +```elixir |
| 30 | +# config/config.exs — typical production setup |
| 31 | +config :localize, |
| 32 | + default_locale: :en, |
| 33 | + supported_locales: [:en, :de, :fr, :ja], |
| 34 | + otp_app: :my_app |
| 35 | +``` |
| 36 | + |
| 37 | +## Quick reference by subsystem |
| 38 | + |
| 39 | +Each row shows the everyday call; the reference file has the full option set, more examples, and the edge cases. Read the reference file when the task goes beyond the row. |
| 40 | + |
| 41 | +### Numbers, currencies, plurals — [references/numbers.md](references/numbers.md) |
| 42 | + |
| 43 | +```elixir |
| 44 | +Localize.Number.to_string(1234.5) # {:ok, "1,234.5"} |
| 45 | +Localize.Number.to_string(1234.5, format: :currency) # {:ok, "$1,234.50"} (currency from locale) |
| 46 | +Localize.Number.to_string(1234.5, format: :currency, currency: :EUR, locale: :de) |
| 47 | + # {:ok, "1.234,50 €"} (no-break space before €) |
| 48 | +Localize.Number.to_string(0.456, format: :percent) # {:ok, "46%"} |
| 49 | +Localize.Number.to_string(1_234_000, format: :decimal_short) # {:ok, "1.2M"} |
| 50 | +Localize.Number.to_string(42, format: :ordinal) # {:ok, "42nd"} |
| 51 | +Localize.Number.PluralRule.plural_type(1, locale: :en) # :one (drive "file" vs "files") |
| 52 | +Localize.Number.parse("1,234.56") # parse localized input back |
| 53 | +``` |
| 54 | + |
| 55 | +Read the reference for: rounding and digit options, significant digits via patterns, spellout/RBNF, ranges ("3–5"), scanning numbers out of text, `Decimal` inputs, custom format patterns. |
| 56 | + |
| 57 | +### Dates, times, intervals, durations — [references/dates-times.md](references/dates-times.md) |
| 58 | + |
| 59 | +```elixir |
| 60 | +Localize.Date.to_string(~D[2026-07-06]) # {:ok, "Jul 6, 2026"} |
| 61 | +Localize.Date.to_string(~D[2026-07-06], format: :full) # {:ok, "Monday, July 6, 2026"} |
| 62 | +Localize.DateTime.to_string(dt, format: :yMMMdHm) # skeleton — locale picks the layout |
| 63 | +Localize.Interval.to_string(~D[2026-07-06], ~D[2026-07-09]) # {:ok, "Jul 6 – 9, 2026"} (thin spaces around the dash) |
| 64 | +Localize.DateTime.Relative.to_string(-3600) # {:ok, "1 hour ago"} |
| 65 | +Localize.Duration.to_string(duration) # {:ok, "11 months and 30 days"} |
| 66 | +``` |
| 67 | + |
| 68 | +Prefer style atoms (`:short`/`:medium`/`:long`/`:full`) or skeletons (`:yMd`, `:yMMMEd`, `:Hm`) over literal pattern strings — skeletons let each locale arrange fields its own way. Read the reference for: pattern symbols, time zones, week/quarter fields, non-Gregorian calendars, `to_time_string` clock-style durations. |
| 69 | + |
| 70 | +### Units of measure — [references/units.md](references/units.md) |
| 71 | + |
| 72 | +```elixir |
| 73 | +unit = Localize.Unit.new!(3.5, "kilometer") |
| 74 | +Localize.Unit.to_string(unit) # {:ok, "3.5 kilometers"} |
| 75 | +Localize.Unit.to_string(unit, format: :short) # {:ok, "3.5 km"} |
| 76 | +Localize.Unit.convert(unit, "mile") # {:ok, mile-unit} |
| 77 | +Localize.Unit.to_string(Localize.Unit.new!(5, "curr-usd-per-100-kilometer")) |
| 78 | + # {:ok, "$5.00 per 100 kilometers"} |
| 79 | +``` |
| 80 | + |
| 81 | +Compound identifiers compose (`kilowatt-hour`, `meter-per-second`, `foot-and-inch`); usage-based preferences (`usage: "person-height"`) pick regional units automatically. Read the reference for: conversion, mixed units, arithmetic, custom units, grammatical case/gender in inflected languages. |
| 82 | + |
| 83 | +### Lists — inline, no reference needed |
| 84 | + |
| 85 | +```elixir |
| 86 | +Localize.List.to_string(["a", "b", "c"]) # {:ok, "a, b, and c"} |
| 87 | +Localize.List.to_string(["a", "b"], list_style: :or) # {:ok, "a or b"} |
| 88 | +``` |
| 89 | + |
| 90 | +Never `Enum.join(items, ", ")` user-facing lists — conjunction rules differ by locale and by list length. |
| 91 | + |
| 92 | +### Collation (sorting and comparing text) — [references/collation.md](references/collation.md) |
| 93 | + |
| 94 | +```elixir |
| 95 | +Localize.Collation.sort(["banana", "Apple", "cherry"]) # ["Apple", "banana", "cherry"] |
| 96 | +Localize.Collation.compare("résumé", "resume", strength: :primary) # :eq — accent-insensitive |
| 97 | +Localize.Collation.sort(["file10", "file2"], numeric: true) # ["file2", "file10"] |
| 98 | +Localize.Collation.sort_key("text") # store for DB-side ordering |
| 99 | +``` |
| 100 | + |
| 101 | +Read the reference for: strength levels (accent/case-insensitive matching), `ignore_punctuation`, script reordering, locale collation types (`zh-u-co-pinyin`), search-style matching. |
| 102 | + |
| 103 | +### MessageFormat 2 (translatable message strings) — [references/message-format.md](references/message-format.md) |
| 104 | + |
| 105 | +```elixir |
| 106 | +Localize.Message.format("{{Hello {$name}!}}", %{"name" => "World"}) |
| 107 | +# {:ok, "Hello World!"} |
| 108 | + |
| 109 | +Localize.Message.format( |
| 110 | + ".input {$count :number}\n.match $count\n1 {{You have one file}}\n* {{You have {$count} files}}", |
| 111 | + %{"count" => 3} |
| 112 | +) |
| 113 | +# {:ok, "You have 3 files"} |
| 114 | +``` |
| 115 | + |
| 116 | +MF2 is the right shape for any user-visible sentence containing a value: the plural/gender/select logic lives in the (translatable) message, not in Elixir `if`s. Read the reference for: built-in functions (`:number`, `:datetime`, `:currency`, `:unit`), markup, custom functions, Gettext integration, and when plain Gettext is the better fit. |
| 117 | + |
| 118 | +### Locale validation, language tags, configuration — [references/locales-and-config.md](references/locales-and-config.md) |
| 119 | + |
| 120 | +```elixir |
| 121 | +{:ok, locale} = Localize.validate_locale("pt-br") # canonical "pt-BR", ready to pass around |
| 122 | +Localize.put_locale(locale) # set for this process |
| 123 | +Localize.Territory.display_name(:NZ, locale: :fr) # {:ok, "Nouvelle-Zélande"} |
| 124 | +Localize.Currency.currency_from_locale(locale) # territory-appropriate default currency |
| 125 | +``` |
| 126 | + |
| 127 | +`-u-` extension keys on a locale (e.g. `"en-u-ca-buddhist-nu-thai-hc-h23"`) automatically drive calendar, digits, hour cycle, collation and more through every formatter. Read the reference for: the `LanguageTag` struct, supported/known vocabularies, display names, runtime data download and supervision, per-locale defaults derived from territory data. |
| 128 | + |
| 129 | +## Patterns for application code |
| 130 | + |
| 131 | +**Set the locale once per request, then format without ceremony.** In Phoenix, a plug resolves the user's locale (session, `Accept-Language`, URL) through `validate_locale/1` and calls `Localize.put_locale/1`; every downstream `to_string` call then does the right thing with no `:locale` plumbing. |
| 132 | + |
| 133 | +**Prefer the non-bang variants at boundaries, bang variants internally.** User-supplied locales, currencies, or units can be invalid — handle `{:error, e}` (its `Exception.message/1` is presentable). For app-internal constants (`Localize.Unit.new!(1, "meter")`), the bang variant is cleaner and a raise means a programming error. |
| 134 | + |
| 135 | +**Keep values as values until the last moment.** Store money as `Decimal` + currency code, dates as `Date`/`DateTime`, quantities as `Localize.Unit` — format at the presentation edge. Never parse formatted output back except with `Localize.Number.parse/2`. |
| 136 | + |
| 137 | +**Plural-sensitive text belongs in messages, not conditionals.** Replace `if count == 1, do: "file", else: "files"` with an MF2 message or `PluralRule.pluralize/3` — English has 2 plural categories but Arabic has 6, and the conditional version silently caps the app at English. |
| 138 | + |
| 139 | +## Common mistakes to catch in review |
| 140 | + |
| 141 | +* String interpolation of numbers, money, dates, or lists into user-visible text. |
| 142 | +* `Calendar.strftime/3` or `Timex.format` for user-facing dates (fine for logs and machine formats). |
| 143 | +* `Enum.sort/1` / `Enum.sort_by(&String.downcase/1)` on human-visible lists. |
| 144 | +* Hardcoded `"$"`, `"%"`, thousands separators, or month/day names. |
| 145 | +* `String.to_atom(user_locale)` — use `validate_locale/1`, which is safe on untrusted input. |
| 146 | +* Concatenating sentence fragments around a value ("You have " <> n <> " new") — word order varies; use MF2. |
| 147 | +* Treating `validate_locale` output as a string — it returns a `%Localize.LanguageTag{}`; pass the struct. |
0 commit comments