Skip to content

i18n adapter contract

i18n adapter contract

ACT treats internationalization as a first-class concern. The i18n adapter is a secondary adapter: it does not discover content, it contributes per-locale partial nodes that the multi-source merge step deep-merges with a primary content adapter’s output. This document pins the cross-adapter i18n contract — locale identifiers, per-locale variants, fallback resolution, and the bridge between popular i18n libraries (next-intl, react-intl, i18next) and the ACT locale tree.

Status

This is a first-party reference adapter distributed as @act-spec/adapter-i18n. The contract is normative. The adapter is Strict-only: it has no Core or Standard tier — i18n composition itself is a Strict-tier feature in the conformance bands defined in ../wire-format/conformance.md.

Why i18n is normative, not bolted on

ACT’s manifest carries a locales block. Every node carries metadata.locale. Cross-locale references are first-class via metadata.translations and related[].relation: "translation_of". The wire format gives every translation entry a stable (locale, id) identity so merge-time deduplication is sound.

The i18n adapter exists because almost every real ACT-emitting site already has a runtime i18n stack — next-intl, react-intl, i18next, vue-i18n. That stack already knows which locales are supported, which message catalogs are complete, and which keys are untranslated. The adapter bridges that knowledge into the ACT wire format without forcing operators to maintain two parallel locale inventories.

Locale identifiers

Locale tags are BCP-47, restricted to the subset pinned by ../wire-format/manifest.md:

  • Primary subtag: 2- or 3-letter language code (lowercase).
  • Optional region subtag: 2-letter country code (uppercase) joined by a hyphen (e.g., en-US, pt-BR, zh-CN).
  • No script subtags, no extensions, no private-use subtags in v0.2.

The adapter normalizes incoming locale strings:

  • Underscore separators (en_US) are auto-corrected to hyphens (en-US).
  • Case is auto-corrected to canonical form (en-usen-US).
  • Tags outside the BCP-47 subset (e.g., en-Latn-US-x-private) are unrecoverable; the build fails non-zero with an error citing the offending tag.

The default locale is configured explicitly; no inference from operating-system or browser preferences. Accept-Language headers are NEVER trusted as input to the build.

Catalog ingestion

The adapter recognizes three message-catalog formats, selected by the library config value:

  • next-intl — per-locale JSON file at <messagesDir>/<locale>.json. Keys are dotted namespaces (home.hero.headline); values are strings or nested objects. Nested objects are flattened to dotted keys before binding.
  • react-intl — per-locale JSON file at <messagesDir>/<locale>.json in either:
    • Flat (FormatJS extracted-messages): { "<id>": { "defaultMessage": "<text>", "description": "<text>" } }. The translation key is the outer key; the translated text is in defaultMessage regardless of locale.
    • Nested: { "<id>": "<text>" } (simple key-to-string map).
  • i18next — per-locale per-namespace JSON file at <messagesDir>/<locale>/<namespace>.json. Flattened to dotted keys with the namespace as the root segment.

The adapter MUST treat unreadable locale files (file missing, permission denied) as recoverable: emit a build warning and skip emission for that locale. The adapter MUST treat parse failures (invalid JSON) as unrecoverable.

Variant model

The i18n adapter emits partial nodes keyed by IDs that the primary adapter (configured as bindToAdapter) is expected to emit. A partial node carries:

{
"id": "<bound-id>",
"_actPartial": true,
"metadata": {
"locale": "<L>",
"translation_status": "complete | partial | fallback | missing",
"fallback_from": "<source-locale>",
"translations": [
{ "locale": "<L'>", "id": "<id-for-L'>" }
],
"source": { "adapter": "act-i18n", "source_id": "<L>:<key>" }
}
}
  • metadata.locale is set in Pattern 1 mode (locale-prefixed IDs) and omitted in Pattern 2 mode (per-locale manifests carry the locale at the manifest level instead).
  • translation_status is computed at the node level, not the per-key level, in v0.2: a node is complete when every catalog key its primary contributor depends on is translated; partial when some are missing; fallback when the locale falls back to another via the chain; missing when no translation exists at all.
  • translations is the dense form per ../wire-format/node.md: every other locale that has the same canonical content, listed as { locale, id }. Per-entry conflicts collapse via the merge step’s (locale, id) dedupe rule.

The adapter never emits a full node envelope — only partials. The primary adapter is responsible for canonical content; the i18n adapter contributes locale awareness on top.

Precedence: fallback

The adapter declares precedence: "fallback" to the framework. This means: when a scalar field (e.g., title, summary) is contributed by both the primary adapter and the i18n adapter, the primary’s value wins. The i18n adapter NEVER overrides scalar content; it only contributes metadata fields the primary did not author (metadata.locale, metadata.translation_status, metadata.fallback_from, metadata.translations).

The field surface contributed by the i18n adapter is closed: only the metadata keys above. The merge step has no opportunity to override title/summary/etc. because the i18n partial does not carry those keys at all.

Fallback chain

When a locale is missing translations for some keys, the adapter walks a configured fallback chain to find the first locale that does have them:

{
"locales.fallback_chain": {
"de-AT": ["de", "en-US"],
"es-MX": ["es-ES", "en-US"]
}
}

The walk is deterministic: the first chain locale that has the key is the source. metadata.fallback_from records that source. When no chain locale has the key, translation_status becomes "missing" and the field is left to the primary adapter’s default-locale value (which the merge step substitutes from the primary’s default-locale partial).

When fallback_chain is unconfigured, the implicit chain is [<locale>, <locales.default>].

Multi-source merge composition

The i18n adapter is the canonical secondary adapter that exercises the multi-source merge contract:

  1. The primary adapter (Markdown / Contentful / Sanity / Storyblok / Strapi / Builder / programmatic) emits one full node per (entry, locale) pair, with canonical content fields populated.
  2. The i18n adapter emits one partial node per (entry, locale) pair, carrying only the metadata keys above.
  3. The merge step deep-merges by ID. Scalar conflicts resolve via the precedence: "fallback" rule (primary wins). Array fields concatenate; metadata.translations is deduped by (locale, id) with later-wins precedence.
  4. The result is a fully-formed node carrying both the primary’s content and the i18n adapter’s locale metadata.

When a partial cannot be matched to a primary contributor (the primary did not emit a node with that ID), the merge step surfaces a “missing required fields” error and the build fails non-zero.

Locale-set arbitration

When the primary adapter and the i18n catalogs disagree on which locales exist, the primary adapter wins. Message catalogs without corresponding content are a translation-readiness signal that does NOT surface in the ACT wire format. The adapter MAY emit a logger.info line citing the orphan catalog locale.

Failure surface

  • Recoverable: missing locale catalog file → warning, skip emission for that locale; orphan partial (no primary contribution) → warning at adapter level (escalates to merge-step error if persistent post-merge).
  • Unrecoverable: malformed catalog JSON; missing required config (library, messagesDir, bindToAdapter); locale tag outside the BCP-47 subset; two i18n adapter instances configured for the same locale in a single build.

Conformance target

The i18n adapter is Strict-only: i18n composition is itself a Strict-tier feature per ../wire-format/conformance.md. A Standard build that does not require multi-locale output simply does not configure the i18n adapter; a Strict build composes it with a primary content adapter to satisfy the Strict-tier locale requirements.

Examples

A multi-locale fallback trace:

Locale request: de-AT
fallback_chain[de-AT]: ["de", "en-US"]
Catalog "messages/de-AT.json": missing key "home.hero.cta"
Catalog "messages/de.json": present "home.hero.cta"
Resolution:
metadata.locale: "de-AT"
metadata.translation_status: "fallback"
metadata.fallback_from: "de"

A merged node (primary CMS adapter + i18n adapter):

{
"act_version": "0.2",
"id": "cms/es/products/widget-pro",
"type": "article",
"title": "Widget Pro", // from CMS
"summary": "El producto principal Widget Pro.", // from CMS
"content": [ /* from CMS */ ],
"metadata": {
"locale": "es-ES", // from i18n adapter
"translation_status": "complete",
"translations": [ // dense, deduped by (locale, id)
{ "locale": "en-US", "id": "cms/en-us/products/widget-pro" },
{ "locale": "de", "id": "cms/de/products/widget-pro" }
],
"source": {
"adapter": "act-contentful",
"source_id": "entry-123",
"contributors": [
{ "adapter": "act-contentful", "source_id": "entry-123" },
{ "adapter": "act-i18n", "source_id": "es-ES:products.widget-pro" }
]
}
}
}

A locale-only-in-some-subtrees site (e.g., a docs site translated into Spanish but the marketing tree is English-only) is supported implicitly: the i18n adapter only contributes partials for IDs the primary actually emits for the relevant locale; missing primary contributions are silent.

Open questions / extension points

  • Per-key translation tracking. v0.2 reports translation_status at the node level only. Per-key tracking requires the primary adapter to emit translation-key markers (e.g., MDX components wrapping <FormattedMessage id=...> calls); additive ASP candidate.
  • TOML message catalogs for i18next users — additive ASP candidate.
  • Bridging additional libraries (vue-i18n, nuxt-i18n, lingui) — each is an additive enum value on the library config.

Sources

  • ../wire-format/manifest.md for the locale block and available / default semantics.
  • ../wire-format/node.md for metadata.locale, metadata.translations, metadata.translation_status, metadata.fallback_from.
  • ./markdown.md, ./contentful.md, ./sanity.md, ./storyblok.md, ./strapi.md, ./builder.md, ./programmatic.md for the primary adapters this one composes with.

Changelog

DateVersionChange
2026-05-030.2.0Initial spec drafted by BDFL