Salta ai contenuti

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

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.MessageFormat is 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 title and detail to be localized through content negotiation, while type stays the problem’s stable identifier.

Messages.

  • Source format. Messages are ICU MessageFormat 1, edited in packages/i18n/catalogs/<language>.json as Format.JS catalogs:

    • a dotted identifier, such as problems.money.invalid_amount.title;
    • the text in defaultMessage;
    • in the English source, a description for translators: where the text appears, who reads it, and what each argument is.
  • Runtime files. pnpm i18n:catalogs writes 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, later dashboard, 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-XA and ar-XB join 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 (createTranslator for 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_translations and room_type_translations, in db:

    • workspace and composite foreign key, like every table;
    • a language tag;
    • a status: draft, machine_translated or reviewed;
    • 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.

  • title and detail follow the negotiated language, with English as the fallback.
  • Content-Language names the language actually produced, and negotiated responses send Vary: Accept-Language.
  • type, code, the status and every extension member are identical in every language, and clients never parse detail.
  • No technical message (stack, SQL, Channex error) reaches detail.
  • 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.
  • 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.