0012. Keep languages, formats, currencies, units and countries independent, and localize at the edges
Questi contenuti non sono ancora disponibili nella tua lingua.
- Status: accepted
- Date: 2026-10-26
Context
Section titled “Context”GuestCraft serves staff and guests who speak different languages, properties in different countries, and amounts in several currencies. The research report i18n-research.md verified the facts and the libraries; the ones that shape the code are these.
- Currencies. A booking that Channex delivers carries its own
currency, and a rate plan has its own, so an amount is meaningless without its currency (ADR 0014). - Content and guest language. Channex accepts content in one language only. The guest’s
languagein a booking revision is optional and has no documented format (ADR 0013). - Decimals. ISO 4217 and Unicode CLDR disagree on the decimals of some currencies: HUF has two in ISO 4217 and none in CLDR, so
Intl.NumberFormatshows 12345.50 HUF as “12 346 Ft”. - Units.
- ECMA-402 sanctions 45 units, among them acre and hectare, but neither square metres nor square feet:
Intl.NumberFormatrejectssquare-meterwith a RangeError (Node 24, ICU 78, CLDR 48). - CLDR has the localized patterns, such as “{0} m²”, “{0} pi²” in French and “{0} stopy kw.” in Polish. It also has the preferences by usage: floor area in square feet in the United States, the United Kingdom, Canada and Myanmar, in square metres elsewhere.
- The TC39 proposal for smart unit preferences is at stage 1.
- ECMA-402 sanctions 45 units, among them acre and hectare, but neither square metres nor square feet:
- Runtimes.
- Node 24 and current browsers implement the Intl Locale Info API, standard in ECMA-402 since its 13th edition (2026).
- Hermes, the engine of the mobile app, registers only
Collator,DateTimeFormat,NumberFormatandgetCanonicalLocalesin the version that Expo SDK 58 uses. There is noIntl.Locale,PluralRules,ListFormatorDisplayNames.
- The core. It reads no ambient state: Biome rejects
Intlinpackages/core(structure review 2, N02).@guestcraft/i18nis MIT and may not import the AGPL core, not even a type (ADR 0004).
The analysis (chapter 6) already states the principle: language, currency, time zone and country are independent, and none is deduced from the others.
Decision
Section titled “Decision”Six independent settings. None is ever derived from another; a default may be suggested, never imposed.
| Setting | Values | Belongs to |
|---|---|---|
| Language of text | a canonical BCP 47 tag (LanguageTag) |
a user, a guest, a text, a template |
| Formatting conventions | a language and a region (en-GB), with the person’s hour cycle, first day of the week and measurement system |
a user, or a document’s recipient |
| Time zone | IANA (ADR 0007) | a property, for stays, business dates and deadlines; a person only to show them instants |
| Currency | ISO 4217 (CurrencyCode) |
every amount; a property has a base currency (ADR 0014) |
| Country | ISO 3166-1 alpha-2 (CountryCode) |
a property, an address, a document, a nationality; it chooses the country pack |
| Units | the reader’s preference by usage: floor area, furniture, distance | a user, or derived from the reader’s region |
- A person’s settings are a
LocalePreferencesvalue in the core. The adapters build the locale string for Intl from it, such asen-GB-u-hc-h23-ms-metric; extensions are never stored as part of a language. - A property has a country, a time zone, a base currency, the languages offered to guests and the language of its content, each chosen explicitly.
- An organization has the languages it enables and a reporting currency.
Canonical inside, localized at the edges.
- Storage, domain and API use only canonical values:
- codes (
IT,EUR,pt-BR); - amounts as integer minor units in the core and as decimal strings with the ISO decimals in the API;
- ISO 8601 dates;
- measures as integers of square centimetres and millimetres in the core, and as a decimal string with an explicit unit in the API.
- codes (
- Only what people read is localized: the UI, emails, PDFs, SMS, and the
titleanddetailof problem details (ADR 0013). The API never returns a formatted value instead of a canonical one.
Where the code lives.
core/shared, pure and withoutIntl:CountryCode: the 249 codes of ISO 3166-1, plus XK;LanguageTag, which refuses codes that name no language (und) and deprecated ones (iw), withlookup, which chooses among available translations as RFC 4647 does but never drops a script (zh-Hantnever becomeszh);MoneyandCurrencyCode;ExchangeRate,Conversionandconvert, androundToIncrementfor cash (ADR 0014);AreaandLength;LocalePreferences.
packages/i18n(MIT), on the public wire formats only, so that the SDK, the apps and any integrator can use it without the AGPL core:- languages:
- negotiation with CLDR’s language matching from a pinned matcher, which
Accept-Languageexclusions (q=0) can veto. It chooses the catalog language. As CLDR’s distances say, a close language in another script comes before another language:zh-TWgetszh-Hanswhen there is nozh-Hant; formattingLocale, which keeps the region of the same preferences for numbers, dates and measures:en-GBfor a reader of theencatalog. The most wanted regional preference wins, and a generic one such asennever hides it;- canonicalization of tags that come from outside, such as
ENorpt_BR. It refuses names such asEnglish, codes that CLDR does not know, and a country code such asCHthat would name another language; - text direction;
- negotiation with CLDR’s language matching from a pinned matcher, which
- formatting:
- money with the currency’s usual decimals unless the amount has more significant ones, so an amount is never rounded for display. The default is the locale’s symbol, since CLDR writes other currencies unambiguously (
US$,CA$); inputs and views that mix currencies show the ISO code; - dates and stays from Temporal values, instants always with an explicit time zone;
- areas, furniture sizes and distances in the reader’s unit, with CLDR’s patterns, parent locales and thresholds, rounded half away from zero as Intl rounds;
- lists, and names of countries, languages and currencies;
- money with the currency’s usual decimals unless the amount has more significant ones, so an amount is never rounded for display. The default is the locale’s symbol, since CLDR writes other currencies unambiguously (
- input: strict reading of the numbers people type, by the locale’s conventions and never by a guess. Where the point neither groups nor separates decimals, as in French, a point before exactly three digits is refused, because it could group thousands;
- catalogs: the message catalogs and their tools (ADR 0013);
- runtimes:
missingIntlFeatures(), which says what a runtime lacks.
- languages:
- Country packs (
countries/<id>): rules, documents, reports, legal texts and the presentation of prices per jurisdiction, behind core ports (analysis, chapter 6). A pack never formats a number or a date: CLDR does. - Exchange rates, content translations and problem details: ADR 0014 and ADR 0013.
Measures.
- Square centimetres and millimetres keep every value people type, as the core’s property test shows: up to two decimals in feet or square feet, one in inches, four in square metres. A value with more decimals than its unit reads back is refused (
too_precise), as an amount with too many decimals is. - Conversions in the core, and
convertAreaandconvertLengthini18n, round half to even. What people see rounds half away from zero. - The displayed precision is a choice of the view.
- Booking.com’s content API takes room sizes in
sqmorsqft. Channex has no room size field, so room areas serve GuestCraft’s own pages until a channel adapter maps them.
Runtimes.
@guestcraft/i18nuses the runtime’s Intl. On Hermes the mobile app loads the@formatjspolyfills first, for:getCanonicalLocales,Locale,PluralRules,NumberFormat,DateTimeFormat,DisplayNamesandListFormat;- in that order, and only for the published languages.
- A release build may not ship until
missingIntlFeatures()returns nothing on real iOS and Android builds. Node and browsers load no polyfill. - Without the polyfill, Hermes reads the decimal strings given to
NumberFormatas binary floats, so amounts above 2^53 would lose digits; the capability check probes exactly this. - The catalog loaders import JSON with import attributes. The first Expo spike checks that Metro supports them before more code depends on them.
Data, and their licenses.
- CLDR data are generated into small, versioned files, with Unicode’s notice (Unicode-3.0):
- the generator keeps each language and the regional variants that differ from their parent, such as
en-001andde-CH; - CLDR’s parent locales find the rest:
en-AUreadsen-001; - a test checks that the data come from the pinned CLDR version and cover every catalog and guest language.
- the generator keeps each language and the regional variants that differ from their parent, such as
- Everything else comes from the runtime’s CLDR, whose version varies by runtime. The nightly matrix on several Node.js versions is the signal of drift.
- When addresses arrive:
- Google’s libaddressinput metadata (CC-BY-4.0) are taken as dated snapshots, with attribution;
- Debian’s iso-codes (LGPL-2.1) never go into an MIT package;
@shopify/address, now deprecated, is not used.
- The tables of accepted codes are for new operations. A record keeps the code and the decimals it was made with, so that a withdrawn currency never makes old documents unreadable.
- Rules implemented both in the core and in
i18n, such as unit conversions, run the same cases from@guestcraft/testing/cases.
Search. Collations and full-text search are decided with the search features. Within these limits:
- ICU collations come from an allowlist, never a name built from a request;
- nondeterministic collations cannot be used with
LIKEon PostgreSQL 17; unaccent, and a text search configuration per language, exist on Neon and Supabase.
Rules for every pull request.
- Messages.
- No sentence is built by concatenation; plurals and selections are ICU arguments.
- Interpolated user data is bidi-isolated.
- Layouts use logical CSS properties and no fixed widths for text.
- Money.
- An amount is never shown without its currency, and its currency never comes from the language or the region.
- No amount is rounded for display.
- No currency is converted implicitly.
- Settings. No setting is deduced from another, and no language from a name, a nationality, a phone prefix, a card or a currency.
- Sources. Source files never contain invisible or bidirectional control characters: they write escapes, and a test checks every file.
Consequences
Section titled “Consequences”-
The core stays testable without
Intl, and its results do not depend on a runtime’s CLDR version. -
@guestcraft/i18ndepends on the runtime’s Intl, which the capability check makes explicit. -
Adding a language means:
- its catalog;
- its loader and its
CATALOG_LANGUAGESentry, or itsGUEST_LANGUAGESentry for guest pages; - its CLDR area patterns, regenerated with
pnpm i18n:cldr.
A missing loader does not compile, and a test fails if the catalog or the patterns are missing.
-
The API gives integrators no formatted value. That is deliberate: they use
@guestcraft/i18nor their own formatting.
Alternatives considered
Section titled “Alternatives considered”- Format in the core with a locale argument. Rejected: the results would depend on each runtime’s CLDR data, and the core would import
Intl. - Derive currency or units from the language. Rejected: a German speaker in Zürich pays in CHF, and a guest from the UK may prefer square metres.
- Lookup instead of best fit to choose a UI language. Rejected for the interface: lookup turns
zh-TWintozhand missespt-PTforpt-BR. The matcher’s data are pinned, so the choice is reproducible. Content selection in the core uses lookup, which never drops a script. - ICU4X for units. Rejected for now: its unit converter is experimental, and two area units do not justify a WASM dependency.
- One MIT package for decimal arithmetic and unit factors, imported by the core and by
i18n. Rejected for now. It would be the core’s only workspace dependency, for about 80 lines that the shared conformance cases keep in step: every unit that both implement has a case, which a test checks. - Area units in the catalogs, translated by hand. Rejected: CLDR already has them, correct in every language.
Amendment: 2026-10-29 — Published UI language
Section titled “Amendment: 2026-10-29 — Published UI language”Only English is published as a staff UI catalog. CATALOG_LANGUAGES contains en alone;
the generic negotiation and catalog machinery remains available for future languages. A
future staff language also needs negotiation in the web app’s request configuration and a
formatting locale chosen with formattingLocale
(i18n README).
Guest language data and GUEST_LANGUAGES, regional formats, currencies and units remain
independent of the published UI catalog. Existing it UI cookies resolve to English.
Regional formatting implementation (2026-10-29)
Section titled “Regional formatting implementation (2026-10-29)”Choosing the tag. The staff request uses englishFormattingLocale, which builds
en-<region> from the reader’s Accept-Language in weight order: it-IT becomes en-IT,
and de,en-US;q=0.7 becomes en-DE, because the reader’s region matters and the text is
English anyway. When every region is refused, or the server’s ICU does not resolve the
region for both numbers and dates, the tag is en-001, world English: never an implicit US
English. The rule, its exclusions and its examples are stated once, in the
i18n README. The generic
multilingual formattingLocale remains public API.
The HTML language, next-intl/use-intl and React Aria all receive the server’s tag. Legacy UI-language cookies are ignored; reload and appearance saves retain the header’s regional signal. A person’s explicit formatting setting takes precedence in the design but remains planned: no selector or persistence is implemented.
The browser’s data. A browser can lack data that the server’s ICU has. Guessing them
from the user agent was rejected: a user agent does not say which ICU data the browser has.
Instead, the root layout sends the tag with a sample: a short date and a number, as the
server writes them. LocaleProvider renders the server’s tag first, so hydration matches.
Right after mount, before the browser paints, it checks the browser’s own data:
- its number and date formatters resolve the tag’s region, not a parent such as
en; - they write the sample the same way.
If either check fails, the provider switches to en-001 and sets it on <html lang>. Desk
screens format dates, numbers and money only after their data load, so they render with the
switched tag. Server-rendered HTML carries no locale-formatted values: page titles and
headings are plain catalog text, and catalog counts in server components stay below the
thousands, where every English region writes the same digits.
What this guarantees, and what was measured on 2026-10-29:
- Node 24.22.1 / ICU 78.3, Chromium 156 and Firefox 157 format all 124 English regions that Node resolves identically, so they keep every tag.
- Playwright’s WebKit 27.2 build bundles no ICU: on Ubuntu 24.04 it uses the system ICU 74.2.
That WebKit resolves 17 of those regions to
en, includingen-IT,en-ESanden-FR. It writesen-CH’s number with a typographic apostrophe anden-ZA’s with other separators. These 19 regions switch toen-001on the client.en-GB,en-AUanden-INpass the sample and keep their tag, although their full dates differ in punctuation. The front-desk browser suite checksen-ITanden-GBin that WebKit. - Safari on macOS, iOS and iPadOS uses Apple’s ICU, which differs by OS version and was
not measured. Because the check reads the browser’s own data, Safari keeps
en-GB,en-ITand every other region wherever its data have them, and falls back where they do not.
The check compares one short date and one number. A browser that agrees on them but writes another style differently, such as a full date’s punctuation, keeps the tag; nothing that it formats was rendered by the server, so hydration still matches.
Money in the staff interface uses the locale’s ordinary number separators, for both
display and entry, while retaining CLDR currency placement, names, signs and exact
significant decimals.
CLDR’s number specification
permits distinct currency separators. The CLDR 48 en_IT data
overrides ordinary decimal/group symbols; the observed inherited currency symbols still
produce €1,234.50 natively for these English European locales. The staff interface
therefore writes money with formatStaffMoney of @guestcraft/ui, which MoneyInput also
uses: it passes separators: "number" to formatMoney, which substitutes ordinary numeric
separators in currency format parts. Biome refuses a direct formatMoney import in the web
app. Without that option formatMoney keeps CLDR’s
currency separators, which differ deliberately in some locales (de-AT: € 1.234,50,
whose numbers read 1 234,5; fr-CH; pt-CV), and which guest documents keep. en-IT shows
€1.234,50, and its code-labelled input shows and accepts 1.234,50 as 1234.50.
en-US shows €1,234.50 and accepts 1,234.50. The currency never comes from the region.
For KWD, 1.234 explicitly means 1234.000 in en-IT and 1.234 in en-US; no
alternate convention is guessed. Mixed separators, and ambiguous alternate points such
as 1.234 in French, are refused with a field-linked error. ISO currency precision
continues to govern input, independently of CLDR display precision.