Context
Every product release ships seven languages: Arabic, English, French, German, Spanish, Italian and Dutch (RV-55a makes this a release blocker).@stackra/i18n knew four base languages and four overlays, formatted times with whatever clock
the engine guessed, and had no week start. The framework UI packages shipped English and Arabic or nothing, with
English strings left in component code. Nothing failed when a translation missed a key. The US and Dutch markets need
imperial display over metric storage and their own postal formats.
Decision
- Locales.
Localeholds the seven base languagesar,en,fr,de,es,it,nland the overlaysar-SA,ar-AE,ar-EG,en-USandes-US. Which locales a Market offers, and its binding legal language, staymarket-pack:<code>.languages[]data. - Formats as case data with an override. Each
Localecase carries its CLDR default first day of the week (ISO: Monday for the European languages,ar-AEanden; Sunday foren-US,es-USandar-SA; Saturday forarandar-EG) and its clock (HourCycle: 24-hour for fr, de, es, it and nl; 12-hour otherwise).I18nConfig.formatsoverrides either per locale, andlocaleFormat(locale, override)merges them. The provider puts the result in context and builds every Intl tag with-u-hc-<cycle>-nu-<digits>. Decimal and grouping separators come from Intl (comma decimal in fr, de, es, it and nl); no separator table is written. - Imperial display over metric storage. Storage stays metric
Quantity.DisplayUnit(grams to gallons) andformatQuantity(quantity, unit, options)convert exactly and format with Intl’s unit names; the unit a Market or a user sees is pack data or a user setting.formatLengthstays for lengths. - Postal formats are pack data. The Italian CAP (
^\d{5}$) and the Dutch postcode (^([1-9]\d{3})(?!SA|SD|SS)([A-Z]{2})$, stored1234 AB) areidentity.address_formatvalues in theitandnlpacks.IdentifierFormatgainscanonical, a$ntemplate that writes the stored form from the pattern’s groups (a group that did not match drops with the text before it). No country is named in code. - Analysers. Spanish, Italian and Dutch use the Postgres built-in
spanish,italiananddutchconfigurations, already inTEXT_SEARCH_CONFIGS; asearch_vector_<locale>column picks its configuration from its language subtag, soes-USusesspanish. - Reindex plan for the new languages. A model that adds a language adds its
search_vector_<locale>column witht.searchable({ locales })in a new migration (expand), builds its GIN index concurrently in the next migration, then runssearch.in(Model).reindex()as a job in its own context. Until the reindex finishes, rows without the new vector are found through the other lanes; nothing is dropped. - Framework catalogues.
ui,ui-pro(ui_pro),panel,consentandnotificationsshipassets/lang/<language>/<namespace>.jsonin all seven languages. Theuikeys are the ones its components name in their shortcut comments; moving each component from its English constant touseTranslationfollows in theuipackage’s own change. Every non-English catalogue is reviewed by a native speaker before it ships (0192). - Completeness gate.
tools/scripts/catalogue-completeness.mjs, run insidepnpm validate, fails when a base language misses a namespace or a key ofen, when any catalogue holds a keyenlacks, when a member has noen, and when one of the five framework members lacks one of the seven languages. Overlays may hold fewer keys. - Amount in words. Stays a pack option for
arandenonly; no speller is added for the new languages. Voice languages stay pack data.
Consequences
- A member may list
es,itandnlinI18nConfig.locales; the fallback rules are unchanged. - A 12-hour or Sunday-first market on a European language is a configuration change, not code.
- A translator who drops a key fails
pnpm validatewith the file and the key. - Every translated message names the same ICU placeholders as its
ensource: the argument names, including the ones nested inplural,selectandselectordinaloptions, compared as a set. Quoted text and the plural#are not arguments. A translator who drops, adds or renames a{name}, or writes a message that does not parse as ICU, failspnpm validatewith the file and the key.
Enforcement
automated —tools/scripts/catalogue-completeness.mjs in pnpm validate; the package tests fix the locales, formats
and postal formats (packages/foundation/i18n/src/core/**/__tests__/, packages/foundation/validation/src/core/functions/__tests__/).
Note 2026-10-27: catalogue loaders (STA-1309)
Decision 7’s catalogues reach the runtime through gen, not by hand:- Gen writes the loaders.
#stackra/i18nexportscatalogues, one lazy JSON loader per namespace and locale file (the member’s ownassets/lang, and each package’s through its./assets/*export, which every@stackra/*package that shipsassets/langexports), andloadMessages(locale). That callsloadMessages(catalogues, locale)of@stackra/i18n, which loads the fallback chain (ar-SA,ar,en) and merges it farthest first, so the nearest locale wins key by key. - Only runtime dependencies bring lang. A package listed in
devDependenciesnever reaches the runtime, so its catalogues are neither merged nor counted fori18n_namespace_clash. - A member may not extend a package’s namespace. A member that ships a namespace a package already ships is refused
(
i18n_namespace_clashstays); it uses its own namespace.
emitI18n and checkI18n in packages/developer/gen, loadMessages in
packages/foundation/i18n (tests written, run on ci-box).
Supersedes and sources
Supersedes: none. Amends 0080 and 0221. Sources:notes/handover/review-round-3/owner-answers.md (RV-54, RV-55,
RV-55a, RV-57, R3-SO-06); notes/handover/review-round-4/languages-scope.md (R4-3, R4-L-07);
notes/handover/review-round-4/whole-plan.md; US-118a, US-117, US-83; STA-788.