# I18n

Flat key catalog, one JSON file per locale. Loud misses. `Intl.*` for anything that is not a string.

Zero hardcoded user-facing strings — every string an end user can read goes through `t()`. A literal in JSX or in an error message is a lint failure and fails `x verify`.

## Catalogs

One file per locale, flat dot-keys after load, nested for authoring.

```
packages/i18n/src/catalogs/en.json  # framework strings — ships inside @ultimat3/i18n
packages/i18n/catalogs/en.json      # the app's own catalogs, same shape
packages/i18n/catalogs/es.json
```

```json
{
  "nav": { "home": "Home", "billing": "Billing" },
  "post": {
    "published": "Published {title}",
    "count_one": "{count} post",
    "count_other": "{count} posts"
  }
}
```

| Rule | Detail |
|---|---|
| Authoring | nested objects; `flattenCatalog` produces `nav.home`, `post.published` |
| Key segments | `/^[A-Za-z0-9_-]+$/` — anything else is `X_CATALOG_INVALID` |
| Leaves | strings only. An array or a number leaf is `X_CATALOG_INVALID` |
| Collisions | a nested branch and a dotted key that flatten to the same path throw |
| Merge order | framework catalog first, app catalog last — apps override framework keys. **Under `en` only**: the framework catalog is English, so `defineCatalogs` seats it under core's `DEFAULT_LOCALE` (`en`) and nowhere else. An app shipping only `es` renders `⟦errors.notFound.title⟧` for a framework key it has not translated, which is the golden rule working — it used to be served English with `isMiss` reading `false`, a fallback locale chain wearing registration as a disguise |
| `en` | the reference locale. Its key set is what every other locale is diffed against |

## `t()`

```ts
import { t } from '@ultimat3/i18n';

export function billingHeading(count: number): string {
  return t('post.count', { count });
}
```

| Aspect | Behavior |
|---|---|
| Signature | `t(key: string, vars?: TranslateVars): string` — `TranslateVars` is `Record<string, string \| number \| boolean>` plus optional `count` |
| Locale | ambient, read from the ALS request context. `t()` never takes a locale argument |
| Miss | returns `⟦key⟧`. Never throws, never falls back to another locale, never returns the key bare |
| Typed keys | keys are generated into a union from the `en` catalog; an unknown key is a type error |
| Introspection | `t.has(key)`, `t.raw(key)`, `t.keys()`; `isMiss(rendered)` for assertions in tests |
| No concatenation | `t('a') + ' ' + name` is banned — put the slot in the string: `t('a', { name })` |

Word order differs per language, so a concatenated sentence is untranslatable by construction. One key = one complete sentence or label.

## Interpolation

| Form | Renders |
|---|---|
| `{name}` | `String(vars.name)` |
| `{{` / `}}` | a literal `{` / `}` |
| `{name}` with no `vars.name` | `⟦name⟧` — loud, same reason a missing key is loud |

`t()` interpolates on **every** call, with `vars` or without: `t('greeting')` on `Hello {name}` is `Hello ⟦name⟧`, exactly as `t('greeting', {})` is, and `{{` is unescaped either way. The template itself — to hand a client island its plural forms, say — is `t.raw(key)`, never `t(key)`.

## Pluralization

CLDR categories via `Intl.PluralRules`, never an English `n === 1` branch.

| Locale | Categories it needs |
|---|---|
| `en`, `de`, `es` | `one`, `other` |
| `pl`, `ru`, `uk` | `one`, `few`, `many`, `other` |
| `ar` | `zero`, `one`, `two`, `few`, `many`, `other` |
| `ja`, `zh`, `ko` | `other` only |

Pass `count`; the translator picks the key. Candidates, most specific first: `key_<category>` → `key_plural` → `key_other` → `key`. For `count === 1`: `key_one` → `key`. `_plural` is the two-form authoring shortcut; the `_<category>` suffixes are what a 3+ form locale needs.

## Ordinals and `select`

`As of 2026-11-06`. **The suffix mechanism, two more families; no ICU syntax.** One selection
(`selectMessageKey`) serves `t()` and a mail's render alike.

| Call | Reads, in order |
|---|---|
| `t('place', { ordinal: 2 })` | `place_ordinal_<category>` by CLDR's **ordinal** rules (`en`: 1 one, 2 two, 3 few, 4 and 11 other), then `place_ordinal_other` |
| `t('invite', { select: 'female' })` | `invite_select_female`, then `invite_select_other` |
| `t('invite', { select: 'female', count: 2 })` | each of those stems with `count`'s plural chain: `invite_select_female_other`, … |

A family the catalog lacks falls through to the key's own plural, as before. A `select` value is a
key segment (`[A-Za-z0-9-]`, ≤ 32) or it selects nothing. The typed key admits the stem (`invite`),
as it does a plural stem. `x i18n check` counts every member used through its stem, a locale owes
the stem and not each value (a language without gender defines `invite_select_other` alone), and a
family with no `_other` is `X_I18N_FAMILY_INCOMPLETE`, red in the `i18n` step.

## Loud misses

| Surface | Behavior on a missing key |
|---|---|
| dev render | `⟦post.published⟧` in the DOM — visible in the page and in review screenshots |
| `x verify` | check 8 (SEO + i18n) fails with `X_CATALOG_MISSING_KEYS` |
| CI | same failure, `--json` shaped |
| Tests | `isMiss(t('k'))` is the assertion — never assert on `''` |

Silently falling back to English is what makes a half-translated app ship. The framework does not do it.

## Strings in an island

`t()`'s catalog does not cross into an island, and a translator is not a prop. An island's own copy
arrives already translated, as text. When it needs keys — `t(key, { count })` after the page
rendered, or the design system's `ui.*` strings — the page sends a slice and the island builds a
translator over it (`As of 2026-10`):

| Side | Call |
|---|---|
| page (server) | `catalogSubset(t, ['app.post.likes'])` from `@ultimat3/i18n`; `uiCatalog(t)` from `@ultimat3/ui` for every `ui.*` key |
| island | `subsetTranslator(props.strings)` from `@ultimat3/i18n/subset`; `<UiProvider t={subsetTranslator(props.ui)}>` |

The slice carries every plural variant of each key, and a key it lacks renders `⟦key⟧` in the
island exactly as on the server.

**A form's validation messages key on the issue's `rule`**, never on its English `message` — the
framework's diagnostic text changes without a semver signal, the rule names do not
(`SCHEMA_RULES`, `@ultimat3/schema`). Ship one key per rule in the slice and pass `params`:
``messageFor: (issue) => t(`form.${issue.rule}`, issue.params)`` — `form.string.min` → `At least
{min} characters`. A third-party schema names no rule; fall back to a generic key for those.

## Commands

| Command | Does |
|---|---|
| `x i18n add <locale>` | create `catalogs/<locale>.json` seeded with every default-locale key, each value copied inside `⟦…⟧` |
| `x i18n sync <locale>` | add keys the locale is missing; a key it already has is never overwritten, translated or not; never deletes |
| `x i18n check --json` | extract keys from source, diff against every catalog and against `en`, report missing / unused / dynamic / slot mismatch / untranslated |

Every command supports `--json`. `add` and `sync` copy the source value inside the marker, so the text sits where the translation goes and `x i18n check` counts the key as missing until a person replaces it. Source means every shape below; a key only a mail or a push names is a key.

| Shape in source | Key |
|---|---|
| `t('k')`, `$t('k')`, `translate('k')` | first argument |
| `blocks.heading`, `paragraph`, `button`, `callout`, `detail` | first argument |
| `titleKey:`, `bodyKey:` | a literal value |
| `subject:` | a literal value, in a file that names `defineMail` |

Any other value of these is listed `dynamic`.

`check` reports five separate things, and three of them fail the command:

| Reported | Meaning | Exit |
|---|---|---|
| `missing` | a key source names, or a key `en` defines (compared by plural stem, so `pl`'s `_few` and `ja`'s lone `_other` answer it), that the locale does not define | non-zero, one `X_CATALOG_MISSING_KEYS` finding per locale |
| `unused` | a key the locale defines that no shape above names | 0 — informational |
| `dynamic` | `t(`plans.${plan}.name`)`, a key the static extractor cannot resolve | 0 — informational, with file, line and column |
| `slot mismatch` | two locales define one key with different interpolation slots — the slot sets are compared by name, so the same slots in another order are not a mismatch | non-zero, one `X_CATALOG_SLOT_MISMATCH` finding per direction |
| `untranslated` | a locale still carries the reference locale's value — byte-identical to it and longer than 24 characters | non-zero, one `X_CATALOG_UNTRANSLATED` finding per key |

The last two are the parity rules, and both compare a locale with the reference locale `en` (`As of 2026-10`). A key `en` defines and another locale lacks is a missing key, not a parity finding, and that holds for a key only reached dynamically (`t(`plans.${plan}.name`)`). A key only another locale defines is `unused` there. A `⟦key⟧` placeholder is a missing key too — never a copy. Every finding names the locale, the catalog key and the file `packages/i18n/catalogs/<locale>.json`; `x verify`'s `i18n` step and `x i18n check` report them from one implementation, so the command and the gate answer about an app with one voice.

Before any of the five, `check` resolves `@ultimat3/i18n` from the app root, from every workspace and from the CLI itself. Two real directories is `X_PACKAGE_DUPLICATED`, and every registration gap is then reported as the install's fault with the install's fix (pin one version, `bun install`) — not as a misplaced `defineCatalogs()`: each copy keeps its own registry, so the app registers into one and the check reads the other.

A dynamic call contributes its static head (`plans.`) as a runtime-key prefix, so a key only ever reached that way is never listed `unused` — otherwise the list reads as "safe to delete" over live keys. An expression with no static head (a ternary over literals, a bare variable) contributes nothing: a guessed prefix would suppress real gaps.

## Locale routing

`As of 22.3.0`. The routed locales are `routedLocales()`: the ones `defineCatalogs()` declared, default first. An app that declared none routes its fallback alone — no prefix, no per-locale files.

| Path (default `es-co`, also `en`) | Locale | Answer |
|---|---|---|
| `/precios` | `es-co` | the `/precios` route |
| `/en/precios` | `en` | the `/precios` route — the prefix is stripped before the route table is matched. An `isr` page keeps the prefix in its store key and is revalidated by that route's `ttl` and `tags` ([Routes](/routes-and-render-modes)) |
| `/es-co/precios` | — | `301` to `/precios` (`308` for a non-GET); the default is never prefixed |
| `/fr/precios` | — | `404`: `fr` is not routed, so `/fr/precios` is an ordinary path |
| `/en/nothing` | `en` | `404`, its error page in English |

| Rule | Detail |
|---|---|
| Prefix | authoritative on every surface: it beats `?locale=`, the cookie, the user and `Accept-Language`. Stripped in `@ultimat3/http`'s `context` stage, never by middleware — middleware wraps matched routes only |
| `site/` | the URL alone (`RouteMeta.localeSource: 'path'`). Unprefixed is **always** `defaultLocale` — no `Accept-Language` negotiation, because a CDN serves `/` as one file — and `Vary` drops `accept-language` |
| `app/` / `api/` | unprefixed keeps `query → cookie → user → header`, configurable via `configureLocales({ order })`. The `user` rung is the actor's saved `locale`, which your `authenticate` hook sets (`userActor({ id, locale, tz })`); `@ultimat3/http` re-resolves once `auth` has run. An explicit choice outranks the browser: `?locale=` is how an email preview link pins a locale, the cookie is what the language switcher writes, and `Accept-Language` is only the fallback when nobody has chosen. An `order` without `'header'` never reads it, and the response drops `accept-language` from `Vary` (`As of 22.10`) — a CDN no longer stores one copy per browser language of a page that cannot differ by it |
| Static export | `x build --target static` renders each `site/` page once per routed locale, in that locale's context: `index.html` (`lang="es-co"`), `en/index.html` (`lang="en"`), `en/precios/index.html`. Every static host serves `/en/precios` from that layout with no rewrite |
| Cookie | `x_locale`, written only by an explicit language switcher |
| Unsupported tag | skipped, not thrown — a stale cookie must never 500 a page. `assertSupportedLocale` throws `X_LOCALE_UNSUPPORTED` where an unknown tag is a caller bug |
| Direction | `currentDirection()` returns `'rtl'` for `ar`, `he`, `fa`, `ur`, … and is written to `<html dir>` |

| API | From | Answers |
|---|---|---|
| `routedLocales()` | `@ultimat3/i18n` | the locales a URL may name, default first |
| `localizedPath(path, locale)` | `@ultimat3/i18n` | `/precios` + `en` → `/en/precios`; the default → `/precios`; `/` + `en` → `/en/`; an existing prefix is re-spelled, never doubled |
| `splitLocalePrefix(pathname)` / `unlocalizedPath(pathname)` | `@ultimat3/i18n` | the prefix split off, or the bare path |
| `localizePath(path, locale, locales, defaultLocale)` | `@ultimat3/core` | the same arithmetic over an explicit list — for browser code, which must not import the i18n barrel |
| `<LocaleSwitcher locales={routedLocales()} path={pathname} />` | `@ultimat3/ui` | links to this page in each locale; `hrefFor` overrides, `defaultLocale` defaults to `locales[0]` |

`meta` receives `locale`, `localizedPath(locale)` and `alternates` (`{ locale, path }` per routed locale). The head then carries, with no route code:

| Tag | Value |
|---|---|
| `<link rel="canonical">`, `og:url` | absolute, against `APP_URL` / `SITE_ORIGIN` / `site.origin` ([Configuration](/configuration)) |
| `<link rel="alternate" hreflang>` | one per routed locale in BCP 47 region form (`es-co` → `es-CO`), plus `x-default` → the default locale's page. A route's own `alternates` replace the set whole |
| `og:locale` / `og:locale:alternate` | `es_CO` form — language and region only, so `zh-hant-tw` is `zh_TW`; the document's locale, then every other one |

`sitemap.xml` lists every page once per locale, each with the same `xhtml:link` cluster. A single-locale app emits none of the cluster.

## Numbers, dates, money

Never hand-rolled, never string-formatted in a component.

| Value | Route |
|---|---|
| Number / percent | `Intl.NumberFormat` with the request locale |
| Date / time | `Intl.DateTimeFormat` with the request locale **and an explicit IANA `timeZone`** → [Timezones and dates](/timezones-and-dates) |
| Currency | `Money = { minor, currency }` formatted at the edge → [Money](/money) |

A colour is a token, a string is a key, a date needs a zone, an amount is minor units. Same four rules everywhere.

## Errors

| Code | Cause | Fix |
|---|---|---|
| `X_LOCALE_UNSUPPORTED` | tag is not in the supported set | `x i18n add <locale>` |
| `X_CATALOG_MISSING_KEYS` | a locale file is missing keys source names or `en` defines (names up to 12 of them) | `x i18n sync <locale>` |
| `X_CATALOG_INVALID` | non-string leaf, bad key segment, or duplicate flat key | `x i18n check --json` |
| `X_CATALOG_SLOT_MISMATCH` | two locales define one key with different interpolation slots | `x i18n check --json` — the finding names the locale, the key and the slots that differ |
| `X_CATALOG_UNTRANSLATED` | a locale still carries the reference locale's value | `x i18n check --json` — the finding names the locale and the key still carrying the reference value |
| `X_PACKAGE_DUPLICATED` | two installed copies of `@ultimat3/i18n`, each with its own registry | pin one version in the `package.json` the finding names, then `bun install` |

Full list: [Error codes](/error-codes).

## Rules

- Zero hardcoded user-facing strings. Everything through `t()`.
- One key per complete sentence. Never concatenate translated fragments.
- `en` is the reference key set; other locales are diffed against it, never the reverse.
- A miss is `⟦key⟧` and a red build, never English.
- `x i18n sync` adds; deletion of a key is a deliberate edit in a PR.
- Supported locales live in `app.config.ts`; `SUPPORTED_LOCALES` is the framework's own set, which apps narrow or extend.

Source: [`packages/i18n/src`](https://github.com/developerz-ai/ultimate/blob/main/packages/i18n/src)
