---
id: "packages/active-i18n/api"
title: "API reference"
description: "Every public export of active-i18n, per entry point - the package's own reference, and every export generated from the source."
url: "/packages/active-i18n/api"
locale: "en"
revision: "16d205c0f7fdfb24"
---

# API reference

> [!NOTE]
> **Two views of one API**
>
> First the package's own `docs/API.md`, included unchanged: the contract. Then
> every export of each entry point, generated from the source.

Every public export of `@workspace/active-i18n`, per entry point. The
authority is the `exports` map in `package.json` and the three barrels
(`src/index.ts`, `src/server/index.ts`, `src/react/index.ts`); the compiler is authoritative on exact shapes.
Each symbol has exactly one public home. Structure and rationale:
`ARCHITECTURE.md`.

| Entry point | Runtime | Section |
| --- | --- | --- |
| `@workspace/active-i18n` | Environment-agnostic | [Root](#root) |
| `@workspace/active-i18n/server` | `server-only` | [Server](#server) |
| `@workspace/active-i18n/server/get-locale` | `server-only`, request | [Server](#server) |
| `@workspace/active-i18n/react` | Client Components | [React](#react) |

Formatting and parsing, and the language code type `LocaleCode`, are not
exported here: they are owned by `@workspace/active-format`, which this package
imports (`active-format/docs/API.md`). The
former `/formatters` entry and the root `LocaleCode` were removed on
2026-10-18 without a second path.

`react` is an optional peer dependency: only `/react` consumers need it,
and they need **React 19 or later** (`^19.0.0`; D30). `/react` reads its
context with `React.use`, which React 18 does not have, so React 18 is not
supported. A consumer that typechecks the source provides `@types/react`
`^19.0.0` beside it. The root has no React dependency.
`next` is an optional peer dependency as well: only the Next framework
integrations `/server` and `/server/get-locale` import it, and both mark
themselves with `server-only` (they load in a React Server Components server
and throw anywhere else). The root and `/react` never reach them (D28).

A host with a resource package usually binds the server runtime once there and
lets its applications import the bound, typed entries from that package rather
than calling `createServerTranslations` themselves.

---

## Root

`import {...} from '@workspace/active-i18n';`

### Translation

| Export | Kind | Contract |
| --- | --- | --- |
| `createTranslateFunction` | function | `(translations: LocaleTranslations, locale: LocaleCode, options?: {onWarning?, onError?, onMissingKey?}) => TranslateFunction`. Pure. A miss returns the raw key and calls `onMissingKey({key, locale})`; invalid path, invalid value and failed plural selection call `onWarning(message, {key, locale})`; an exception calls `onError(message, {key, locale, error})`. Every failure returns the raw key |
| `MissingKeyCallback` | type | `(missing: MissingTranslationKey) => void`: the optional observer of a key the tree does not carry. It observes; the result stays the raw key |
| `MissingTranslationKey` | type | `{readonly key: string; readonly locale: LocaleCode}` |
| `TranslationCapability` | type | `TranslationCapability<PlainKey extends string = string, Params = Record<string, variables \| undefined>>`: `(key: PlainKey): string` and `<K extends keyof Params & string>(key: K, variables): string`, where `variables` must satisfy EVERY key in `K` (the intersection of their variables, so a key union cannot compile with the variables of one member). The canonical translation contract; a host instantiates it with its own key types |
| `TranslateFunction` | type | `TranslationCapability` with its defaults: any `string` key, optionally with a variables bag `{count?: number; [name: string]: string \| number \| undefined}` |
| `LocaleTranslations` | type | `Record<string, Record<string, unknown>>`: the translation tree keyed by namespace |
| `LanguageConfig` | type | `{code: string; label: string; flag?: string; direction?: 'ltr' \| 'rtl'}`: one entry of the provider's `supportedLanguages`. Nothing in this package reads `flag`, and no language UI may show a flag |

Translation semantics (paths, interpolation, plural classification, callback
table): `ARCHITECTURE.md` § 6 to § 9.

### Locale lookup

RFC 4647 § 3.4 lookup (D29, D33): lookup only, not a negotiation engine. No
best fit, no likely subtags, no sibling region and no default; the set and
what follows a miss are the host's. Framework-free, like the rest of the root.

| Export | Kind | Contract |
| --- | --- | --- |
| `lookupSupportedLocale` | function | `(requested: string, supportedLocales: readonly string[]) => string \| undefined`. The request is canonicalised (malformed: `undefined`); the exact canonical tag wins, then the request is truncated one subtag at a time (`zh-Hant-TW` → `zh-Hant` → `zh`). Never more specific: `pt` does not reach `pt-BR`. The set must be canonical |
| `lookupFirstSupportedLocale` | function | `(requested: readonly string[], supportedLocales: readonly string[]) => string \| undefined`. The first request of a priority list that `lookupSupportedLocale` answers; each request is truncated before the next is tried (`['de-CH', 'fr']` against `de, fr` gives `de`). `undefined` for an empty list or no hit |

### Language names

| Export | Kind | Contract |
| --- | --- | --- |
| `getLanguageName` | function | `(languageCode: LocaleCode, displayLocale: LocaleCode, knownLabels?: KnownLanguageLabels) => string`. The in-sentence name from `Intl.DisplayNames` in the display language; where Intl gives no name (it cannot be constructed, or answers with the code), the host's label for that code from `knownLabels`, then the upper-case code. Never empty (D28) |
| `getStandaloneLanguageName` | function | Same signature. The standalone-label form: the first grapheme cluster upper-cased with the display language's casing rules, for a list of names from different languages |
| `KnownLanguageLabels` | type | `readonly Pick<LanguageConfig, 'code' \| 'label'>[]`: host labels, used only when Intl gives no name |

### Diagnostics

| Export | Kind | Contract |
| --- | --- | --- |
| `I18nLoggerFactory` | type | `(source: string) => I18nLogger`. The host's logger factory: the runtime asks it once per part for `active-i18n/client`, `/server`, `/loader` or `/metadata`. A factory such as active-logging's `createLogger` fits as it is (D7, D28) |
| `I18nLogger` | type | `{warn(message, meta?), error(message, meta?)}`: stable English messages, context in `meta` |

### Currency names

| Export | Kind | Contract |
| --- | --- | --- |
| `getCurrencyName` | function | `(currencyCode: string, displayLocale: LocaleCode) => string`. The currency name from `Intl.DisplayNames` (`type: 'currency'`) in the display language; falls back to the code itself. Never empty |
| `getRegionName` | function | `(regionCode: string, displayLocale: LocaleCode) => string`. The region (country) name from `Intl.DisplayNames` (`type: 'region'`) in the display language; falls back to the code itself. Never empty for a non-empty code |
| `getCurrencyOptionLabel` | function | Same signature. The choice label `CODE - name` (hyphen, never an em-dash), or the bare code when the runtime does not know the name. Resolve it on the server for any server-rendered text: server and browser ICU data may differ |
| `getCurrencyOptions` | function | `(codes: readonly string[], displayLocale: LocaleCode, config?: CurrencyOptionsConfig) => CurrencyOption[]`. The options of a currency picker in display order: `config.priorityCodes` first, as given and unpruned (deduplicated), then every other code of `codes` sorted by its name with `Intl.Collator(displayLocale)`. `config.current` (`{code, name?}`, the value a record carries) is kept at the front when it is in neither list, and its `name`, resolved on the server, replaces the local one. Names come from `getCurrencyName` (the code when unknown). The universe of codes is the caller's; nothing is derived from a language or a country and no default is chosen. Fits `CurrencyCombobox.options` in active-ui |
| `CurrencyOption`, `CurrencyOptionsConfig` | type | `{code, name}`; `{priorityCodes?, current?: {code, name?}}` |

---

## Server

`import {...} from '@workspace/active-i18n/server';`

A Next framework integration (D28): it marks itself with `server-only`, so it
loads in a React Server Components server (Next, or a worker that resolves
`server-only` to an empty module) and throws in plain Node and in a client
bundle.

| Export | Kind | Contract |
| --- | --- | --- |
| `createServerTranslations` | function | `(source: TranslationSource, options: ServerTranslatorOptions) => ServerTranslations`. Binds the server runtime to one host source and the host's values. Call once per source at module scope; the loader cache belongs to the returned instance and is on when `NODE_ENV` is `production` at creation. Throws for an empty language set, an entry that is not a well-formed and canonical BCP 47 tag or that is listed twice, and a fallback that is not spelled as one of the entries (D29). `options.onMissingKey` is the host's one server installation of missing-key observability |
| `ServerTranslations` | type | `{getServerTranslations, getServerTranslator, createServerTranslator, getTranslatedMetadata}`, described below |
| `ServerTranslatorOptions` | type | `{readonly supportedLocales: readonly string[]; readonly fallbackLocale: string; readonly onMissingKey?: MissingKeyCallback; readonly logger?: I18nLoggerFactory}`. The language set and fallback are required (D28); every entry is a canonical BCP 47 tag (`en-GB`, `pt-BR`, `zh-Hant`), and the fallback is one of them (D29); without `onMissingKey` a miss is not reported, without `logger` nothing is |
| `TranslationSource` | type | `{readonly namespaces: readonly string[]; importNamespace(locale, namespace): Promise<{readonly default: unknown}>}`. Every namespace is imported per locale; `MODULE_NOT_FOUND` is skipped silently, any other failure with a warning; an empty bundle is left out |
| `TranslatedMetadataOptions` | type | `{preferredLocale: string; descriptionKey?: string; additionalMetadata?: Partial<Metadata>}` |
| `getAcceptLanguageCandidates` | function | From `@workspace/active-i18n/server/get-locale`, not from the barrel: it reads the request (`next/headers`), and the barrel is the runtime a request-free worker may load. `() => Promise<string[]>`. The `Accept-Language` request header as canonical BCP 47 tags with region and script kept (`pt-BR`, `zh-Hant-TW`), quality descending and header order for equal quality, **unfiltered** against any language set and never truncated (D33). Whitespace around `;` and `=` is allowed and only `q` counts; dropped are an invalid qvalue (outside 0 to 1 or more than three decimals), `q=0`, `*` and a tag `Intl` refuses (`en_US`); a repeated tag keeps its highest quality; at most 32 entries are read. A list of positive preferences only: it carries no exclusion and no default. For anonymous surfaces only; the host matches it, for example with `lookupFirstSupportedLocale` |

The members of `ServerTranslations`:

| Member | Contract |
| --- | --- |
| `getServerTranslations` | `(locale: string) => Promise<{translations, locale}>`. Loads every namespace in the entry of the host's `supportedLocales` that answers the given locale (D29: the request is canonicalised; the exact tag wins; only then the same tag with subtags removed from the end, so `en-US` reaches `en` but never `en-GB`, and `pt` never reaches `pt-BR`), otherwise in the host's `fallbackLocale` with a warning. A malformed locale takes the fallback path too. The returned `locale` is the entry as the host spelled it. A load failure yields an empty tree and an error report |
| `getServerTranslator` | `(locale: string) => Promise<{t, locale, translations}>`. Same loading; `t` comes from `createServerTranslator` |
| `createServerTranslator` | `(translations, locale) => TranslateFunction`. The one server translator constructor of the instance: server-side `onWarning`/`onError` logging and the host's `onMissingKey`. A host binding that types keys binds to it |
| `getTranslatedMetadata` | `(titleKey: string, options: TranslatedMetadataOptions) => Promise<Metadata>`. Translated `title`, optional `description`, then `additionalMetadata` merged last. A missing key renders as the raw key, like every translation, and is reported through `onMissingKey` only; a load failure logs an error |

The locale is required on all three; omitting it does not compile.

---

## React

`import {...} from '@workspace/active-i18n/react';`

Client Components only, except the pure time-zone state constructors, which
are safe in a Server Component.

### Provider and translation hooks

| Export | Kind | Contract |
| --- | --- | --- |
| `I18nProvider` | component | Props `I18nProviderProps`. Distributes the translator for one server-resolved locale, wired to the `active-i18n/client` logger of the host's factory when given |
| `I18nProviderProps` | type | `{children; locale; translations: LocaleTranslations; supportedLanguages: readonly LanguageConfig[]; allowLocaleSwitching?: boolean (default true); persistLocale?: (locale) => void \| PromiseLike<void>; confirmLocaleSwitch?: (locale) => boolean \| PromiseLike<boolean>; logger?: I18nLoggerFactory}`. Without `persistLocale` the provider touches no storage; it never builds a cookie itself (D28). `persistLocale` may be synchronous or return any `PromiseLike` (native promise, object or callable thenable, assimilated like `Promise.resolve`); the switch navigates only after it returned or its promise fulfilled (D31, D32). `confirmLocaleSwitch` is the host's guard for leaving the page, asked before anything is stored; anything other than `true`, a throw or a rejection resolves `'declined'` (D32). A logger that throws never changes an outcome (D32) |
| `I18nContextValue` | type | `{locale; t; translations; setLocale(newLocale): Promise<LocaleSwitchOutcome>; availableLocales; availableLanguages; allowLocaleSwitching; getLanguage(code); currentLanguage}`. Public as the return type of `useI18n`, so a caller can name what the hook returns; no workspace consumer uses either today (I18N-FINAL-015) |
| `useI18n` | hook | `() => I18nContextValue`. Throws outside `<I18nProvider>` |
| `useTranslations` | hook | `() => TranslateFunction`. Throws outside the provider |
| `useLocale` | hook | `() => LocaleCode`. Throws outside the provider |
| `useLocaleSwitcher` | hook | `() => {locale, setLocale, availableLocales, availableLanguages, allowLocaleSwitching, currentLanguage, getLanguage}`. `setLocale` matches the request canonically against the offered codes and resolves to a `LocaleSwitchOutcome`; it never throws and the promise never rejects (D32). Order: `confirmLocaleSwitch` (when given), `persistLocale` (when given), then `<html lang>` and a full navigation; the outcome of the navigation comes from the browser's `beforeunload` (`'switched'` without an objection, `'navigation-guarded'` with one, `'navigation-failed'` when starting it threw). Every outcome except `'switched'` and `'pending'` leaves the page consistent and accepts a new choice. One switch at a time per provider: a further valid choice while one is being decided, stored or navigated resolves `'pending'` (D31) |
| `LocaleSwitchOutcome` | type | `'switched' \| 'disabled' \| 'unchanged' \| 'unavailable' \| 'pending' \| 'declined' \| 'persist-failed' \| 'navigation-failed' \| 'navigation-guarded'`: how a `setLocale()` call ended. Only `'switched'` leaves the page. `'navigation-failed'` and `'navigation-guarded'` mean the choice IS stored but this page stays, with `<html lang>` on its own language; choosing the shown language again stores it again (D31, D32) |

### Presentation and time zone

| Export | Kind | Contract |
| --- | --- | --- |
| `PresentationProfileProvider` | component | Props `{profile: PresentationProfileConfig \| null; children}`. Distributes the owner's profile; `null` means "no configured profile" |
| `usePresentation` | hook | `() => PresentationContext`. Composes provider locale, profile and time zone; pass it as the `presentation` argument of any formatter. Without profile and zone it behaves like the bare locale. The surface's zone is its `timeZone` field |
| `TimeZoneProvider` | component | Props `{value; children}`, where `value` is a state built with `fixedTimeZone` or `viewerTimeZone`. Distributes the surface's time-zone state |
| `useRequiredTimeZone` | hook | `() => IanaTimeZone`. The surface's resolved zone, for a calendar-day question (`todayIsoDate`, `calendarDayOf`); throws when the surface has no resolved zone (D15) |
| `fixedTimeZone` | function | `(timeZone: IanaTimeZone)`: the state of a surface whose host determined the zone. Pure; callable from a Server Component |
| `viewerTimeZone` | function | `(timeZone: IanaTimeZone \| null \| undefined)`: the state of a viewer-owned surface, resolved or unresolved. Pure |
| `detectViewerTimeZone` | function | `() => IanaTimeZone \| null`. The browser's reported zone, or `null` |

The time-zone state is mechanical and owner-blind: `fixed`, `viewer` or
`viewer-unresolved` (no provider). The state type and its readers are internal.

### Formatting hook and components

| Export | Kind | Contract |
| --- | --- | --- |
| `useFormatters` | hook | `() => {currency(value: MonetaryAmount, options?); number(value, format?: NumberFormat); fractionAsPercent(value, preset?: PercentFormat); percentagePoints(value, preset?: PercentFormat); date(value, preset?: DatePreset); dateOnly(value, preset?: DateOnlyPreset); dateTime(value, preset?: DatePreset)}`. The formatters bound to `usePresentation()`; no relative-time helper (D18) |
| `RecencyTime` | component | Props `{value: Date \| string \| number \| null \| undefined; fallback: string; absoluteLabel?: (absoluteText: string) => string; options?: {absolutePreset?; now?; thresholdDays?}; phrase?: (recency: string) => string; className?: string}`. Relative text within the recency threshold, absolute beyond it, with the canonical instant in `<time dateTime>` and a reachable absolute reading. The server and the hydrating render show the absolute reading; the policy applies once mounted (D18). The absolute reading uses the surface zone: mount it under a resolved `<TimeZoneProvider>`, because without one the server and the browser each read their own zone and the hydrating text can differ. `phrase` embeds the `<time>` in a translated sentence: it receives a stand-in and returns the translation with that stand-in interpolated exactly once (it throws otherwise). Not for the displayed value of a sortable or filterable date column |
| `ViewerDateTime` | component | Props `{value: string; preset?: DatePreset; fallback: string; className?: string}`. An instant on a viewer-owned surface, read in the viewer's zone; renders directly when the provider's viewer zone is resolved, otherwise after browser detection. Never on a surface with an administration or system owner |

## All exports, from the source

### `@workspace/active-i18n`

**createTranslateFunction**: `(localeTranslations: LocaleTranslations, currentLocale: LocaleCode, options?: CreateTranslateFunctionOptions) => TranslateFunction`

Creates a translation function for a specific locale and translations set.

**getLanguageName**: `(languageCode: LocaleCode, displayLocale: LocaleCode, knownLabels?: KnownLanguageLabels) => string`

The name of a language, written in the language of the reader.

**getStandaloneLanguageName**: `(languageCode: LocaleCode, displayLocale: LocaleCode, knownLabels?: KnownLanguageLabels) => string`

The name of a language in its **standalone form**: the form that applies when
the name stands as a separate label and not inside a sentence.

WHY THIS FUNCTION EXISTS NEXT TO {@link getLanguageName}

`Intl.DisplayNames` yields the form CLDR calls the *in-sentence* form: the
spelling a language name has in the middle of running text. In
Dutch, English and German that is a capital letter (`Nederlands`, `English`,
`Deutsch`); in French and Spanish a lower-case letter (`français`,
`español`), and that is the correct form there. Both are correct, and
{@link getLanguageName} must therefore keep yielding them that way: whoever interpolates a name
into a sentence (the fallback notice on the public site) needs
exactly that form.

A LIST of names from DIFFERENT languages side by side is something else. There
none of those orthographies applies, because there is no sentence and no
carrying language; what appears is a series of separate labels. CLDR has
the standalone form for that case, and it starts with a capital letter where the script
has case. Without it the public language picker reads as
`Nederlands · English · Deutsch · français · español`: no error per line,
but an incoherent list.

WHAT IT IS NOT

Not `capitalize()`, not `charAt(0).toUpperCase()`, not CSS
`text-transform`. It touches exclusively the first GRAPHEME CLUSTER, leaves the
rest of the name untouched (so `Kreyòl ayisyen` stays intact), and uses
the casing rules of the DISPLAY LANGUAGE (`toLocaleUpperCase`), so that the Turkish
`i` ends up as `İ` and not as `I`.

It is moreover safe for scripts WITHOUT case: in Chinese,
Japanese, Arabic or Hebrew the upper-case mapping is the identity, so
`中文` and `العربية` come out unchanged. It makes no assumption about the
length of a language code, the script or the number of words, and therefore works
just as well for `pt-BR`, `en-GB` and `zh-Hant`.

**getCurrencyName**: `(currencyCode: string, displayLocale: LocaleCode) => string`

The name of a currency, written in the language of the reader.

**getCurrencyOptionLabel**: `(currencyCode: string, displayLocale: LocaleCode) => string`

The visible text of a currency choice: `CODE - localized name`, or only the
code when this runtime does not know the name.

The code comes first: it is the identity an administrator recognizes. The
symbol stays out on purpose: `$` stands for dozens of currencies and `kr`
for several, so it is no identity.

**The separator is a hyphen (U+002D) and never an em-dash**: the em-dash is
forbidden in user-visible text, and an option
label is that.

**getCurrencyOptions**: `(codes: readonly string[], displayLocale: LocaleCode, config?: CurrencyOptionsConfig) => CurrencyOption[]`

The options of a currency picker, in display order: the priority codes,
then every other code sorted by its name in the reader's language.

The universe of codes is the caller's (in Datyx `listCurrencyCodes()` from
`@workspace/active-types`); this helper only names and orders them. Nothing
here derives a currency from a language or a country, and nothing chooses a
default.

**getRegionName**: `(regionCode: string, displayLocale: LocaleCode) => string`

The name of a region, written in the language of the reader.

**lookupFirstSupportedLocale**: `(requested: readonly string[], supportedLocales: readonly string[]) => string \| undefined`

The first entry of a canonical supported set that answers a priority list
of requested tags, or `undefined` when none does: RFC 4647 § 3.4 lookup
over the list, each request in turn through {@link lookupSupportedLocale}.

A request is truncated before the next one is tried, so `pt-BR, en` against
`pt, en` gives `pt`. This is lookup only and not a negotiation engine: no
best fit, no likely subtags, no sibling region (`en-US` never reaches
`en-GB`) and no default. Which set applies and what follows a miss are the
host's (D33).

**lookupSupportedLocale**: `(requested: string, supportedLocales: readonly string[]) => string \| undefined`

The entry of a canonical supported set that answers a requested tag, or
`undefined` when none does (D29).

1. The request is canonicalised; a malformed request matches nothing.
2. The exact canonical tag wins when the set carries it.
3. Only then is the request made less specific, one subtag at a time from
the end (RFC 4647 § 3.4 lookup): `zh-Hant-TW` → `zh-Hant` → `zh`. A
request is never made MORE specific, so `pt` does not pick `pt-BR` or
`pt-PT`, and `en-US` reaches `en` but never `en-GB`.

The set must already be canonical; `createServerTranslations` refuses one
that is not.

**LocaleTranslations**

```ts
type LocaleTranslations = Record<string, Translations>;
```

A collection of translations organized by namespace.

Each key is a namespace (e.g., 'common', 'admin', 'sidebar'),
and the value contains all translations for that namespace.

**TranslateFunction**

```ts
type TranslateFunction = TranslationCapability;
```

This package's generic translator: the canonical capability without key
knowledge.

**TranslationCapability**

```ts
interface TranslationCapability<
  PlainKey extends string = string,
  Params = Record<string, TranslationVariables | undefined>,
> {
  (key: PlainKey): string;
  <K extends keyof Params & string>(
    key: K,
    variables: VariablesForEvery<Params, K>,
  ): string;
}
```

THE CANONICAL TRANSLATION CAPABILITY.

This is the ONE contract that says "this value translates". Everything that
translates -- this package's generic runtime translator, and every TYPED
translator of a host -- is an INSTANTIATION of this type, not a second,
parallel concept next to it.

## Generic, and still owner-blind

The two parameters carry the key knowledge, and their DEFAULTS make it empty:

- `PlainKey` is the set of keys that require no variables; by default
`string`, so the generic translator accepts any key;
- `Params` binds a key to the variables it requires; by default an open
record, so the generic translator accepts any variable bag.

A host that KNOWS its keys fills them in --
`TranslationCapability<MyKeys, MyParams>` -- and obtains a STRICTER
translator, without this package ever seeing one of that host's keys.
`active-i18n` describes the SHAPE of translating and never the content.

NOTE: `lint-error-key-rendering.mjs` does not recognise translators through
this type. It knows an explicit list of official translation operations
(`useTranslations`, `useI18n`, `createTranslateFunction` here, and the
`@workspace/locales` binders) and infers nothing from a type.

**MissingKeyCallback**

```ts
type MissingKeyCallback = (missing: MissingTranslationKey) => void;
```

Callback type for observing a missing key.

It fires only when the namespace or the key path does not exist; the
translator still returns the raw key. A malformed path, a non-string value
and a failed plural selection stay with `onWarning`, and a thrown exception
with `onError`.

**MissingTranslationKey**

```ts
interface MissingTranslationKey {
  readonly key: string;
  readonly locale: LocaleCode;
}
```

A translation key that the loaded translations do not carry.

**KnownLanguageLabels**

```ts
type KnownLanguageLabels = readonly Pick<
  LanguageConfig,
  'code' | 'label'
>[];
```

Labels the host already knows for some languages, used only when the
environment's `Intl.DisplayNames` gives no name (D28). The host's own
language list (`LanguageConfig`) has this shape; Datyx passes its platform
language set through `@workspace/locales/language-names`.

**CurrencyOption**

```ts
interface CurrencyOption {
  code: string;
  name: string;
}
```

One currency choice: the code as data, the name in the reader's language.

**CurrencyOptionsConfig**

```ts
interface CurrencyOptionsConfig {
  priorityCodes?: readonly string[];
  current?: {code: string; name?: string};
}
```

**LanguageConfig**

```ts
interface LanguageConfig {
  code: string;
  label: string;
  flag?: string;
  direction?: 'ltr' | 'rtl';
}
```

Configuration for a supported language.

**I18nLogger**

```ts
interface I18nLogger {
  warn(message: string, meta?: Record<string, unknown>): void;
  error(message: string, meta?: Record<string, unknown>): void;
}
```

One logger, bound to one source name by the host.

**I18nLoggerFactory**

```ts
type I18nLoggerFactory = (source: string) => I18nLogger;
```

Returns the host's logger for one source name of this package.

### `@workspace/active-i18n/react`

**I18nProvider**

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | yes |  |
| `locale` | `string` | yes |  |
| `supportedLanguages` | `readonly LanguageConfig[]` | yes |  |
| `translations` | `LocaleTranslations` | yes |  |
| `allowLocaleSwitching` | `boolean` | no |  |
| `confirmLocaleSwitch` | `((locale: LocaleCode) => boolean \| PromiseLike<boolean>)` | no | The host's guard for leaving the page (D32), asked with the offered code BEFORE anything is stored. Return (or resolve to) `true` to go on, or `false` to keep the page: `setLocale()` then resolves `'declined'`, nothing is stored and nothing changes. A guard that throws or rejects counts as a refusal and is reported through `logger`.  A page that protects unsaved work wires that protection here, so the user decides in the host's own words before the choice is stored. When it returns `true`, the host lets the coming navigation through (its own `beforeunload` objection is released). Without a guard every switch goes on; a `beforeunload` objection is then still detected and reported as `'navigation-guarded'`. |
| `logger` | `I18nLoggerFactory` | no | The host's logger factory (`core/diagnostics.ts`). The provider asks it for the `active-i18n/client` logger and reports translation warnings and refused switches through it; without it nothing is reported. A logger that throws is ignored for that report: diagnostics never change an outcome (D32). |
| `persistLocale` | `((locale: LocaleCode) => void \| PromiseLike<void>)` | no | Records a language choice where the host keeps it (a cookie, storage, a server call), before the provider reloads the page (D28, D31).  SYNCHRONOUS or ASYNCHRONOUS: return nothing when the write is done on return (a cookie: the navigation then starts in the same task), or any `PromiseLike` that settles when it is done (a server call; a native promise, an object or a function with `then`, assimilated as `Promise.resolve` does). The provider navigates only after it returned or its promise fulfilled. A throw, a throwing `then` or a rejection is a failed switch: no navigation, no `<html lang>` change, `setLocale()` resolves `'persist-failed'` and the failure is reported through `logger`.  OMITTING it means: touch NO storage. The package never builds a cookie itself: the name, the domain, the lifetime and `Secure` are the host's contract, in the same shape and for the same reason as the persistence of `ThemeProvider` in active-ui. Datyx passes it through `PlatformI18nProvider` (`@workspace/active-auth-ui`). |

**PresentationProfileProvider**

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | yes |  |
| `profile` | `PresentationProfileConfig \| null` | yes | The profile of the active owner, resolved server-side by the host. `null` is a statement ("no owner selected" or "owner without a profile") and not a missing value; there is therefore deliberately no default. |

**TimeZoneProvider**

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | yes |  |
| `value` | `TimeZoneState` | yes | The time zone state of this surface, composed with `fixedTimeZone()` or `viewerTimeZone()`. Deliberately not a bare `IanaTimeZone \| null`: that form let "no business owner" and "viewer, detect yourself" coincide. |

**RecencyTime**

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `fallback` | `string` | yes | What is shown when the value is missing or unreadable. Required and must be supplied translated; `active-i18n` does not translate. |
| `value` | `string \| number \| Date \| null \| undefined` | yes | The instant: an ISO 8601 instant with `Z` or an offset, a `Date` or an epoch number. A zone-less date-time is not an instant and shows `fallback` (D18). |
| `absoluteLabel` | `((absoluteText: string) => string)` | no | Accessible label for the absolute reading, translated by the caller. Appears as screen-reader text next to the relative text, for example `"On 9 March 2026 at 21:18"`. If it is missing, only `title` plus `dateTime` carry the absolute value. |
| `className` | `string` | no |  |
| `options` | `RecencyOptions` | no | Passed to {@link resolveRecency}: preset and threshold. |
| `phrase` | `((recency: string) => string)` | no | Embeds the recency in a translated sentence (D18). Receives a stand-in for the recency and returns the caller's translated text with that stand-in interpolated exactly once, for example `(lastUsed) => t('profile.x.lastUsed', {lastUsed, expires})`. The component renders the sentence around a semantic `<time>`, so a translation stays one whole sentence in every language. Without it, the component renders the `<time>` alone. |

**ViewerDateTime**

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `fallback` | `string` | yes | What is shown while the viewer's zone is not known. That is only the case when the host supplies no resolved viewer zone: a public or anonymous screen, or a user before their first bootstrap. Required and must be supplied translated: `active-i18n` does not translate. |
| `value` | `string` | yes | The instant as a **canonical ISO instant** with `Z`. Deliberately a string and not a `Date`: this is the form that travels across the RSC boundary, and by contract it is zone-unambiguous. |
| `className` | `string` | no |  |
| `preset` | `DatePreset` | no | What is shown. Defaults to the readable date-time. |

**useI18n**: `() => I18nContextValue`

Returns an object with:

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `locale` | `string` | yes |  |
| `t` | `TranslateFunction` | yes |  |
| `translations` | `LocaleTranslations` | yes |  |
| `setLocale` | `(newLocale: LocaleCode) => Promise<LocaleSwitchOutcome>` | yes | Switches the language (D31, D32). Never throws and the promise never rejects, whatever the host's guard, writer or logger does: it resolves to the {@link LocaleSwitchOutcome}. A caller that does not need the outcome may ignore it (`void setLocale(code)`). |
| `availableLocales` | `readonly string[]` | yes |  |
| `availableLanguages` | `readonly LanguageConfig[]` | yes |  |
| `allowLocaleSwitching` | `boolean` | yes |  |
| `getLanguage` | `(code: string) => LanguageConfig \| undefined` | yes |  |
| `currentLanguage` | `LanguageConfig \| undefined` | yes |  |

**useLocale**: `() => LocaleCode`

**useLocaleSwitcher**: `() => Pick<I18nContextValue, "locale" \| "setLocale" \| "availableLocales" \| "availableLanguages" \| "allowLocaleSwitching" \| "currentLanguage" \| "getLanguage">`

Returns an object with:

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `locale` | `string` | yes |  |
| `setLocale` | `(newLocale: LocaleCode) => Promise<LocaleSwitchOutcome>` | yes | Switches the language (D31, D32). Never throws and the promise never rejects, whatever the host's guard, writer or logger does: it resolves to the {@link LocaleSwitchOutcome}. A caller that does not need the outcome may ignore it (`void setLocale(code)`). |
| `availableLocales` | `readonly string[]` | yes |  |
| `availableLanguages` | `readonly LanguageConfig[]` | yes |  |
| `allowLocaleSwitching` | `boolean` | yes |  |
| `getLanguage` | `(code: string) => LanguageConfig \| undefined` | yes |  |
| `currentLanguage` | `LanguageConfig \| undefined` | yes |  |

**useTranslations**: `() => TranslateFunction`

**usePresentation**: `() => PresentationContext`

The composed presentation context of the current reader: language from
`I18nProvider`, notation from the owner's profile, and the business
**time zone** from `TimeZoneProvider`. The result fits unchanged as the
`presentation` argument of every central formatter (date, number,
percentage, currency). Without mounted providers the outcome behaves the
same as the bare language code.

**Three sources, one composition site.** Language belongs to the reader, notation to the owner, zone also to the owner but from
its own column and its own chain. They come together here and
nowhere else, so no second composition can arise that falls out of step.

The memo keys on **content** (five scalar fields) and not on object
identity: the profile object arrives freshly deserialized from the
server on every RSC render, and a referentially fresh-but-equal context
would make every consumer with the object in a dependency array (grids,
charts, formatter hooks) recompute needlessly; in charts this is visible
as an aborted entrance animation.

Returns an object with:

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `languageLocale` | `string` | yes | The language of the reader: words, labels, month names. |
| `dateShape` | `"LONG_TEXTUAL" \| "NUMERIC" \| "SHORT_TEXTUAL"` | no | The normal human date shape of the owner of this surface. If absent, the platform fallback `SHORT_TEXTUAL` applies (PP-054 level 3). |
| `dateFormat` | `"DMY_NUMERIC" \| "ISO_NUMERIC" \| "MDY_NUMERIC"` | no | Structural date notation of the administration, if configured. |
| `timeFormat` | `"H12" \| "H24"` | no | Clock notation of the administration, if configured. |
| `numberFormat` | `"COMMA_DECIMAL_DOT_GROUP" \| "COMMA_DECIMAL_SPACE_GROUP" \| "DOT_DECIMAL_COMMA_GROUP"` | no | Number notation of the administration, if configured. |
| `currencyDisplay` | `"CODE" \| "SYMBOL"` | no | Currency display style of the administration, if configured. |
| `timeZone` | `IanaTimeZone` | no | The business time zone of the owner of this surface.  **A sister dimension, not a fifth notation field.** It comes from its own storage field (`Tenant.timeZone` or `ApplicationConfig.timeZone` respectively) and its own resolution chain, and is explicitly not part of {@link PresentationProfileConfig} — the stored notation carries exactly five dimensions per test, and that is a decision.  It travels along here because the call site of a formatter needs both: the shape of an instant and the moment being shown. That way no existing call needs to learn an extra argument.  If absent, the viewer reads their own zone — the correct outcome on personal and public surfaces. |

**useRequiredTimeZone**: `() => IanaTimeZone`

The zone of this surface, for a calendar-day question that cannot be
answered without one: "which day is today" (`todayIsoDate`) or "on which day
does this instant fall" (`calendarDayOf`) (D15).

The same zone as {@link useTimeZone}, but required: a surface whose zone has
not been resolved throws a configuration error instead of letting the
runtime decide the day. There is no fallback to UTC, to the browser or to
another owner. A surface that asks a calendar-day question mounts a
`<TimeZoneProvider>` with a resolved state.

Returns an object with:

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `__brand` | `"IanaTimeZone"` | yes |  |

**fixedTimeZone**: `(timeZone: IanaTimeZone) => TimeZoneState`

A surface whose host has determined the zone, for example from the owner of
the data on screen. This package does not know those owners and need not
know them.

**viewerTimeZone**: `(timeZone: IanaTimeZone \| null \| undefined) => TimeZoneState`

A surface that belongs to the viewer.

`null` means "the viewer zone has not (yet) been resolved" and yields
{@link UNRESOLVED_TIME_ZONE} — explicitly not a fallback to a zone from
another source.

**useFormatters**: `() => FormatUtils`

Hook to get locale-aware formatting utilities.

Returns an object with:

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `currency` | `(value: MonetaryAmount, options?: CurrencyFormatOptions) => string` | yes | Format a monetary amount according to locale conventions.  The currency identity is required and comes from the data; the hook supplies the presentation from `usePresentation()`. |
| `number` | `(value: number \| null \| undefined, format?: NumberFormat) => string` | yes | Format a number with a semantic preset (never a free Intl options bag) |
| `fractionAsPercent` | `(value: number \| null \| undefined, preset?: PercentFormat) => string` | yes | Format a stored FRACTION (0.125 means 12.5%) as a percentage. For values stored in percentage points (12.5 means 12.5%) use {@link FormatUtils.percentagePoints} — the source unit is part of the name so no call site can guess it. |
| `percentagePoints` | `(value: number \| null \| undefined, preset?: PercentFormat) => string` | yes | Format stored PERCENTAGE POINTS (12.5 means 12.5%) as a percentage. |
| `date` | `(date: DateInput, preset?: DatePreset) => string` | yes | Format a date. **Without a preset the call inherits the normal human date form of the presentation**; a preset is a semantic exception with a justification. |
| `dateOnly` | `(date: DateInput, preset?: DateOnlyPreset) => string` | yes | Format a **calendar day** (date of birth, start date, any `@db.Date` column): the same day in every time zone. For an instant use {@link FormatUtils.dateTime}. Without a preset: inherit. |
| `dateTime` | `(date: DateInput, preset?: DatePreset) => string` | yes | Format a date with time. Without a preset: inherit. |

**detectViewerTimeZone**: `() => IanaTimeZone \| null`

The IANA zone the browser reports, or `null` when it does not supply a
usable one.

`null` is a valid outcome and not an error: the caller then omits the zone,
and the formatting layer uses the runtime zone, which in a browser is
exactly the same zone. The degradation is therefore behaviour-neutral, and
it is explicitly **not** a fallback to UTC or to a business zone.

Server-side this function always returns `null`: `Intl` reports the process
zone there (UTC, ADR-016), and that says nothing about the viewer.

This is also the source of the one-time bootstrap of TZ-P21: the host reads
it, validates it and stores it once. This component itself never stores
anything.

**I18nContextValue**

```ts
interface I18nContextValue {
  locale: LocaleCode;
  t: TranslateFunction;
  translations: LocaleTranslations;
  setLocale: (newLocale: LocaleCode) => Promise<LocaleSwitchOutcome>;
  availableLocales: readonly string[];
  availableLanguages: readonly LanguageConfig[];
  allowLocaleSwitching: boolean;
  getLanguage: (code: string) => LanguageConfig | undefined;
  currentLanguage: LanguageConfig | undefined;
}
```

**I18nProviderProps**

```ts
interface I18nProviderProps {
  children: ReactNode;
  locale: LocaleCode;
  translations: LocaleTranslations;
  supportedLanguages: readonly LanguageConfig[];
  allowLocaleSwitching?: boolean;
  persistLocale?: (locale: LocaleCode) => void | PromiseLike<void>;
  confirmLocaleSwitch?: (locale: LocaleCode) => boolean | PromiseLike<boolean>;
  logger?: I18nLoggerFactory;
}
```

**LocaleSwitchOutcome**

```ts
type LocaleSwitchOutcome =
  | 'switched'
  | 'disabled'
  | 'unchanged'
  | 'unavailable'
  | 'pending'
  | 'declined'
  | 'persist-failed'
  | 'navigation-failed'
  | 'navigation-guarded';
```

How a `setLocale()` call ended (D31, D32). Only `'switched'` leaves the
page; every other outcome leaves it consistent and accepts a new choice,
except `'pending'`, which reports that a switch is still under way.

- `'switched'`: the choice is stored (or there is no storage), `<html lang>`
carries it, and the browser is leaving the page: its `beforeunload` came
without an objection.
- `'disabled'`: the provider has `allowLocaleSwitching={false}`.
- `'unchanged'`: the requested language is the current one.
- `'unavailable'`: the requested tag is malformed or not offered.
- `'pending'`: an earlier switch of this provider is still being decided,
stored or navigated; this call did nothing.
- `'declined'`: `confirmLocaleSwitch` refused, or failed; nothing was
stored and nothing changed.
- `'persist-failed'`: `persistLocale` threw or its promise rejected;
nothing navigated and `<html lang>` is untouched.
- `'navigation-failed'`: the choice IS stored, but starting the navigation
threw; `<html lang>` is back on the current language.
- `'navigation-guarded'`: the choice IS stored, but a `beforeunload`
handler of the page objected to leaving, so the browser asks the user.
The provider has put `<html lang>` back on the current language: if the
user stays, the page is consistent and a new choice is accepted (choosing
the current language again stores it again); if the user leaves, the
next page renders the stored choice.

### `@workspace/active-i18n/server`

**createServerTranslations**: `(source: TranslationSource, options: ServerTranslatorOptions) => ServerTranslations`

Binds the generic server translation runtime to a host's resources.

This package knows no namespace and no resource location; the host declares
both in `source` and publishes the returned entry points from its own
server module. Call it once per source, at module scope: the loader cache
belongs to the returned instance. The host also supplies the language set
and fallback its resources carry (required; a configuration that cannot
answer every request throws here), its logger factory, and
`options.onMissingKey`, the host's one installation point for missing-key
observability on the server: every translator of the returned instance
carries it (D28).

**ServerTranslations**

```ts
interface ServerTranslations extends ServerTranslators {
  getTranslatedMetadata: TranslatedMetadata;
}
```

The server translation entry points of one host's translation source.

**TranslationSource**

```ts
interface TranslationSource {
  readonly namespaces: readonly string[];
  importNamespace(
    locale: string,
    namespace: string,
  ): Promise<{readonly default: unknown}>;
}
```

Where a host's translation resources come from.

`importNamespace` performs the host's own module import; the loader reads its
`default` export as the namespace bundle. A module that does not exist
(`MODULE_NOT_FOUND`) yields no entry silently; any other failure yields no
entry and a warning.

**ServerTranslatorOptions**

```ts
interface ServerTranslatorOptions {
  readonly supportedLocales: readonly string[];
  readonly fallbackLocale: string;
  readonly onMissingKey?: MissingKeyCallback;
  readonly logger?: I18nLoggerFactory;
}
```

Host choices for the server translators of one source.

**TranslatedMetadataOptions**

```ts
interface TranslatedMetadataOptions {
  descriptionKey?: string;
  preferredLocale: string;
  additionalMetadata?: Partial<Metadata>;
}
```

Options for translated metadata generation.

### `@workspace/active-i18n/server/get-locale`

**getAcceptLanguageCandidates**: `() => Promise<string[]>`

The language preferences from the `Accept-Language` header, sorted by
quality and normalised -- **unfiltered**.

Deliberately not checked against a language set: which set applies is a
runtime policy question (ENABLED), and it does not belong in an owner-blind
package. The caller filters, and that is
`resolvePublicLanguage()` in `@workspace/active-auth/language`.

This source is valid **only** on an anonymous surface (`LANG-18` class 2).
On a surface with a known identity the browser language is no ongoing
authority: it never overrides a stored choice.

It is not a precedence chain but a plain reading of one request header.
