0013. Edit messages as described catalogs, translate them in Weblate, and keep content translations per object
Questi contenuti non sono ancora disponibili nella tua lingua.
- Status: accepted
- Date: 2026-10-26
Context
Section titled “Context”ADR 0012 puts the message catalogs in @guestcraft/i18n. This ADR decides how they are written, translated and checked, and how GuestCraft handles the other texts people read: content written by users, such as property descriptions, and the language of each guest. The facts come from the research report i18n-research.md.
- Formats. ICU MessageFormat 1 is what next-intl, use-intl and intl-messageformat format. Unicode MessageFormat 2 is a stable specification since CLDR 47, but
Intl.MessageFormatis at TC39 stage 1 and the translation tools do not yet round-trip it. - Weblate. It checks ICU syntax and placeholders, and supports Format.JS JSON files with a description per message. The nested JSON of next-intl has no place for descriptions, and without them translators have nothing to go by.
- Channex content. Channex accepts property and room descriptions in one language only, as plain strings.
- Guest language. A booking revision carries
customer.language, which is optional and may be null. Booking.com sends a guest’s preferred language only when a feature is enabled, and uses its own language codes. Neither tells the language of every guest of a room. - Problem details. RFC 9457 allows
titleanddetailto be localized through content negotiation, whiletypestays the problem’s stable identifier.
Decision
Section titled “Decision”Messages.
-
Source format. Messages are ICU MessageFormat 1, edited in
packages/i18n/catalogs/<language>.jsonas Format.JS catalogs:- a dotted identifier, such as
problems.money.invalid_amount.title; - the text in
defaultMessage; - in the English source, a
descriptionfor translators: where the text appears, who reads it, and what each argument is.
- a dotted identifier, such as
-
Runtime files.
pnpm i18n:catalogswrites the nested files that next-intl and use-intl load,packages/i18n/messages/<language>.json. A test fails when they are stale, and identifiers that are not lower-case segments (such as__proto__) are refused. -
Namespaces. The first segment of an identifier is a namespace (
common,problems, laterdashboard,housekeeping,guest,communications). When the catalogs grow, each surface will load only the namespaces it needs. -
The linter (
lintCatalogs) checks every translation against the source:- the same identifiers, arguments, argument types, tags and select options;
- valid syntax;
other, and every plural category that a count up to 10,000 can select in the language, and no category it never selects.
The pseudo-locales
en-XAandar-XBjoin German and a real Arabic test catalog in the screenshot tests. -
Runtime. next-intl in the web app; use-intl in the mobile app and on the server (
createTranslatorfor emails, documents and problem details). The language is part of each request or delivery. There is never a process-wide current language. -
MessageFormat 2. It comes when the libraries and Weblate support it, as a versioned conversion of the catalogs.
Translation workflow.
- Weblate is a separate service. Translations reach the repository as pull requests, reviewed by people and by CI, and ship with each release. No installation depends on a translation service at runtime.
- The catalogs are MIT, like
packages/i18n. Texts copied from projects under other licenses are not accepted. - Legal texts are not UI messages. Country packs keep them versioned, by jurisdiction, purpose and language, with their provenance. An acceptance records the exact version and language.
Which language, for whom.
- Staff interface: an explicit choice, then the account’s preference, then the device or
Accept-Language, then the default of the surface. - A guest’s messages: the language chosen for the booking, then the guest’s confirmed preference, then the OTA’s signal (validated and mapped per channel), then the language chosen on the guest page, then the property’s default. The preference is stored with its source and time. A later null from an OTA never erases a confirmed choice.
- Never inferred: a language is never deduced from a name, a nationality, a phone prefix, a card or a currency.
Content written by users (descriptions, policies, message templates).
-
Storage. One translation table per kind of object, such as
property_translationsandroom_type_translations, indb:- workspace and composite foreign key, like every table;
- a language tag;
- a status:
draft,machine_translatedorreviewed; - the revision of the source text it translates, so that a stale translation shows.
There is no universal key-value translation table and no JSONB of languages: both weaken foreign keys, authorization and review.
-
Reading. The requested language, then its parents, then the object’s original language. The answer says which language was used.
-
Channex receives one representation, in the property’s content language, which the adapter declares. GuestCraft keeps every translation for its own pages and messages.
-
Machine translation is an optional adapter, with the deployer’s key and consent. Its results are drafts, and it is never used for legal texts.
API problems.
titleanddetailfollow the negotiated language, with English as the fallback.Content-Languagenames the language actually produced, and negotiated responses sendVary: Accept-Language.type,code, the status and every extension member are identical in every language, and clients never parsedetail.- No technical message (stack, SQL, Channex error) reaches
detail.
Consequences
Section titled “Consequences”- Every message has its context in the repository, where Weblate shows it to translators and reviewers read it.
- Adding a message means editing the English catalog and running
pnpm i18n:catalogs. CI says when the generated files or a translation are out of step. - The content tables, the preference of guests and the problem presenter are built with their layers (
db,api, guests). This ADR fixes their shape now. - The generated runtime files are committed, so that apps import them without a build step.
Alternatives considered
Section titled “Alternatives considered”- next-intl’s nested JSON as the edited format. Rejected: no descriptions, so no context for translators.
- Gettext PO with Lingui, or i18next with its own syntax. Both are good, but they mean a second set of conventions next to next-intl, which the analysis chose for the web app and use-intl for the rest.
- JSONB columns keyed by language for content. Rejected: review status, revision and access per language become partial updates of a document that row-level security sees as one value.
- Inferring the guest’s language from the country of their address or phone. Rejected: wrong for travellers and residents abroad, and a form of profiling.
Amendment: 2026-10-29 — English-only application
Section titled “Amendment: 2026-10-29 — English-only application”The staff application and API problem details ship only the English catalog. The Italian
catalog and UI language selector are removed. A stored Italian UI-language cookie is ignored,
so an existing session reads English without a data migration. New UI-language writes are not
offered. The catalog format, loader, linter and generated-message workflow remain generic:
adding a future language still means adding <language>.json, listing it in
CATALOG_LANGUAGES, adding its loader and regenerating messages and CLDR patterns. For the
staff interface it also means negotiating the language in the web app’s request
configuration, which loads only the English catalog today, and choosing its formatting
locale with formattingLocale (i18n README).
Guest language tags from reservations and OTAs remain data, independent of the staff UI. Locale-aware money, date and unit formatting remains independent of the catalog language and follows the reader’s regional preferences (ADR 0012; the rule is stated in the i18n README). The earlier staff language-selection and translated API-problem decisions above describe the original design; this amendment defines what the application ships now.