@guestcraft/i18n
Questi contenuti non sono ancora disponibili nella tua lingua.
Localization for GuestCraft (ADR 0012, ADR 0013). It turns the canonical values of the API into text for people, reads the numbers people type, and holds the message catalogs. It works on the API’s wire formats only, so the web and mobile apps, the server, the SDK and any integrator use the same rules. It is MIT and imports nothing from the AGPL core.
Every function takes its locale explicitly: nothing reads the language, the region or the time zone of the machine. Like Intl, the functions throw a RangeError for a malformed locale.
A request gives two settings. The catalog language chooses the texts; the formatting locale chooses how numbers, dates and measures are written. The staff interface is English only, so it loads the source catalog and chooses only the formatting locale. Pass the request’s Accept-Language header to this portable helper:
import { englishFormattingLocale, formatArea, formatMoney, formatStay, parseAcceptLanguage, SOURCE_LANGUAGE,} from "@guestcraft/i18n";import { loadMessages } from "@guestcraft/i18n/catalogs";
export async function presentStay(acceptLanguage: string | null) { const requested = parseAcceptLanguage(acceptLanguage); const locale = englishFormattingLocale(requested); const messages = await loadMessages(SOURCE_LANGUAGE); return { locale, messages, price: formatMoney("1234.50", "GBP", locale, { display: "code" }), dates: formatStay("2026-10-26", "2026-10-28", locale), area: formatArea("25.5", "square-meter", locale), };}The staff formatting locale
Section titled “The staff formatting locale”This is the one statement of the rule; ADR 0012 records the decision and the browser measurements. englishFormattingLocale keeps English text and selects en-<reader region> from Accept-Language. It reads the preferences in weight order, header order on a tie, and the first one that gives an accepted region wins:
- a preference with a region gives that region:
de-CHgivesen-CH; - a generic preference gives the region of a later preference in the same language, which refines it, or else the region its language maximizes to (
itgivesIT); - generic
engives no region, because the text is English anyway; - with no region at all, the tag is
en-US.
An exclusion (q=0) refuses a region when it covers the preference, the preference in that region, or the English tag. Generic en refuses only en-US. When every region is refused, or ICU does not resolve the region for both numbers and dates (en-AQ), the tag is en-001 (WORLD_ENGLISH): English names, day-first dates, comma grouping and a decimal point, never an implicit US English.
| Header | Tag |
|---|---|
it-IT,it;q=0.9 |
en-IT |
de,en-US;q=0.7,en;q=0.3 |
en-DE |
it,en-US;q=0.9,en;q=0.8 |
en-IT |
de,de-AT;q=0.9 |
en-AT |
en,en-GB;q=0.9 |
en-GB |
en,it-IT;q=0.9 |
en-IT |
en |
en-US |
it-IT,it;q=0,en-GB;q=0.5 |
en-GB |
it-IT,en;q=0 |
en-IT |
en-IT;q=0,it-IT |
en-001 |
A property’s currency is independent of this reader setting. An explicit personal formatting setting remains planned.
A browser can lack ICU data that the server has. formattingSample(locale) gives a short date and a number as this runtime writes them; the server sends it with the tag, and formatsLike(locale, sample) says whether the browser resolves the tag’s region and writes the sample the same way. The staff interface’s LocaleProvider runs that check after mount and switches to en-001 when it fails.
formattingLocale stays public MIT API on purpose: it chooses a regional locale within a negotiated language, which multilingual integrators and any future catalog need, although the English staff interface does not call it.
Public API
Section titled “Public API”The private, unpublished MIT workspace has three export paths:
| Entry point | Purpose |
|---|---|
@guestcraft/i18n |
Locale negotiation, formatting/parsing, the language constants and the option types. |
@guestcraft/i18n/catalogs |
loadMessages, the typed Messages shape, catalog validation/build helpers and pseudo-localization. |
@guestcraft/i18n/messages/*.json |
Generated nested runtime catalogs; en.json is the only shipped language. |
The root exports group the formatting API as follows:
| Area | Functions | Rules |
|---|---|---|
| Languages | negotiate, englishFormattingLocale, formattingLocale, formattingSample, formatsLike, WORLD_ENGLISH, parseAcceptLanguage, canonicalLanguage, textDirection, SOURCE_LANGUAGE, CATALOG_LANGUAGES, GUEST_LANGUAGES |
Matching uses CLDR’s language data, so de-CH finds de, and zh-TW finds zh-Hant, or zh-Hans before another language when only that is available. A language excluded with q=0 is never chosen. The English staff selector takes the reader’s region across languages in weight order, with the world-English fallback. Generic formattingLocale keeps a preference’s region (en-IN) within the chosen language and script. External tags are made canonical: an OTA’s EN or pt_BR becomes en or pt-BR; English, an unknown code, and a country code such as CH that names another language become nothing. |
| Money | formatMoney |
The amount is the API’s decimal string, shown with the decimals people use for its currency (CLDR) unless more are significant: it is never rounded. By default the separators are CLDR’s currency ones, which some locales set apart from their numbers (de-AT: € 1.234,50; fr-CH: 1'234.50 €): guest documents keep them. separators: "number" writes the locale’s number separators instead (en-IT: €1.234,50 rather than €1,234.50), so the text is what parseLocaleDecimal reads; the staff interface passes it through formatStaffMoney of @guestcraft/ui, which MoneyInput also uses. Currency placement and signs remain CLDR-driven. The currency never comes from the locale. The locale’s symbol is the default (CLDR writes other currencies unambiguously, such as US$); inputs and views that mix currencies pass display: "code". |
| Dates | formatDate, formatStay, formatWeekday, formatMonth, formatDateTime, formatTime |
Calendar dates have no time zone. An instant always needs one, given explicitly. |
| Measures | formatArea, formatLength, formatDistance, convertArea, convertLength, preferredAreaUnit, preferredFurnitureUnit |
The unit follows CLDR’s preference for the usage (floor area, furniture, distance) and the reader’s region, or -u-ms-metric, -u-ms-ussystem and -u-ms-uksystem. Conversions are exact fractions, rounded half to even as in the core; what people see rounds half away from zero, as Intl does. Area names follow CLDR’s parent locales: “square metres” in en-AU, “Quadratfuss” in de-CH. |
| Numbers | formatNumber, parseLocaleDecimal |
A count is written in the locale’s digits and grouping: “٣” in ar-EG. A number typed in a locale becomes a plain decimal string, by the locale’s conventions: “1 234,5” is 1234.5 in French. Input that does not follow them, such as “12,5,6” in French, gives nothing; so does a point before exactly three digits where the point neither groups nor separates decimals, as “12.500” in French. |
| Names and lists | countryName, languageName, currencyName, formatList |
From the runtime’s CLDR data, in the reader’s language. |
| Runtimes | missingIntlFeatures |
Lists the Intl features the runtime lacks or gets wrong; empty on Node.js and current browsers. |
Catalogs
Section titled “Catalogs”Messages are ICU MessageFormat, edited in catalogs/<language>.json as Format.JS catalogs, the format Weblate edits (ADR 0013). Each message has a dotted identifier (problems.money.invalid_amount.title), its text and, in the English source, a description for translators:
{ "common.nights": { "defaultMessage": "{count, plural, one {# night} other {# nights}}", "description": "The nights of a stay, as in a booking summary. count is a whole number, 1 or more." }}pnpm i18n:catalogs writes the nested files that next-intl reads in the web app, and use-intl in the mobile app and on the server: messages/<language>.json. @guestcraft/i18n/catalogs loads one language at a time and provides the tools, which the tests run on every catalog:
checkDescriptors: plain identifiers, a text for every message, and a description for every message of the source.lintCatalogs: each translation has the identifiers, arguments, tags and select options of the source, valid syntax, and every plural category its language needs.pseudoLocalize: makesen-XA(accented letters and 40% longer text) andar-XB(right to left) for screenshot tests.buildMessages: the nested form, which a test compares with the generated files.
A new language needs:
- its catalog;
- its entry in
CATALOG_LANGUAGES(orGUEST_LANGUAGESfor guest pages) and in the loaders; - its CLDR area patterns, regenerated with
pnpm i18n:cldr; - for the staff interface, negotiation in the web app’s request configuration, which loads only
SOURCE_LANGUAGEtoday, and a formatting locale chosen withformattingLocale:englishFormattingLocaleand theLocaleProviderfallback build English tags only.
A missing loader does not compile; a missing catalog or missing patterns fail the tests.
Runtime support
Section titled “Runtime support”Hermes can lack Intl.Locale, PluralRules, DisplayNames and ListFormat, and parts of NumberFormat and DateTimeFormat. A host with those gaps must load the @formatjs polyfills before this package: getCanonicalLocales, Locale, PluralRules, NumberFormat, DateTimeFormat, DisplayNames and ListFormat, in that order, with the data of the published languages only. A mobile release must verify that missingIntlFeatures() returns nothing on real iOS and Android builds; this package alone does not prove device support. Without the NumberFormat polyfill, Hermes reads decimal strings as binary floats, so amounts above 2^53 would lose digits: the check probes it.
ECMA-402 has no area units, so the patterns of square metres and square feet in src/generated/area-units.ts are generated from Unicode CLDR by pnpm i18n:cldr. They cover the catalog and guest languages and the regional variants that differ from their parent; CLDR’s parent locales, generated with them, find the rest. Tests check that the patterns come from the pinned CLDR version and cover every language, and that the unit preferences in src/units.ts are those of CLDR.
That file is under the Unicode License v3, not MIT: its header says so, and the license text is in LICENSES/Unicode-3.0.txt. A published package must ship both.
Tests and generated data
Section titled “Tests and generated data”pnpm --filter @guestcraft/i18n testpnpm --filter @guestcraft/i18n typecheckpnpm i18n:catalogspnpm i18n:cldrpnpm --filter @guestcraft/scripts test:integration test/integration/readme-examples.integration.test.tsUnit and property tests cover locale negotiation/exclusions, exact decimal money and unit conversions, localized input parsing, explicit-zone dates, catalog validation, pseudo-locales, Intl capabilities and generated-file drift. Tests compare generated runtime catalogs and CLDR data with their sources. The README example check compiles the actual TypeScript fence. No database, translation service or customer credentials are needed.
This package exposes no HTTP endpoints. The API consumes its problem texts and the clients consume its wire-format helpers; see the generated API reference for those contracts. Formatting support for a locale does not imply a shipped translation, and guest language data remains independent of the English-only staff interface.