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-us→en-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>.jsonin either:- Flat (FormatJS extracted-messages):
{ "<id>": { "defaultMessage": "<text>", "description": "<text>" } }. The translation key is the outer key; the translated text is indefaultMessageregardless of locale. - Nested:
{ "<id>": "<text>" }(simple key-to-string map).
- Flat (FormatJS extracted-messages):
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.localeis 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_statusis computed at the node level, not the per-key level, in v0.2: a node iscompletewhen every catalog key its primary contributor depends on is translated;partialwhen some are missing;fallbackwhen the locale falls back to another via the chain;missingwhen no translation exists at all.translationsis 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:
- The primary adapter (Markdown / Contentful / Sanity / Storyblok / Strapi / Builder / programmatic) emits one full node per (entry, locale) pair, with canonical content fields populated.
- The i18n adapter emits one partial node per (entry, locale) pair, carrying only the metadata keys above.
- The merge step deep-merges by ID. Scalar conflicts resolve via
the
precedence: "fallback"rule (primary wins). Array fields concatenate;metadata.translationsis deduped by(locale, id)with later-wins precedence. - 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-ATfallback_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_statusat 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
i18nextusers — additive ASP candidate. - Bridging additional libraries (
vue-i18n,nuxt-i18n,lingui) — each is an additive enum value on thelibraryconfig.
Sources
../wire-format/manifest.mdfor the locale block andavailable/defaultsemantics.../wire-format/node.mdformetadata.locale,metadata.translations,metadata.translation_status,metadata.fallback_from../markdown.md,./contentful.md,./sanity.md,./storyblok.md,./strapi.md,./builder.md,./programmatic.mdfor the primary adapters this one composes with.
Changelog
| Date | Version | Change |
|---|---|---|
| 2026-05-03 | 0.2.0 | Initial spec drafted by BDFL |