Skip to content
github-actions[bot] edited this page Aug 11, 2026 · 2 revisions

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/catalogs/en.json      # framework strings
packages/i18n/catalogs/es.json
apps/web/../packages/i18n/catalogs/ # the app's own catalogs, same shape
{
  "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
en the reference locale. Its key set is what every other locale is diffed against

t()

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

export function billingHeading(count: number): string {
  return t('post.count', { count });
}
Aspect Behavior
Signature t(key: string, vars?: TranslateVars): stringTranslateVars 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

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_pluralkey_otherkey. For count === 1: key_onekey. _plural is the two-form authoring shortcut; the _<category> suffixes are what a 3+ form locale needs.

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.

Commands

Command Does
x i18n add <locale> create catalogs/<locale>.json seeded with every default-locale key, values copied verbatim
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, report missing / unused / dynamic

Every command supports --json. add copies the source values rather than writing blanks: an untranslated string that renders beats a missing key that renders ⟦key⟧, and x i18n check still lists the locale's keys so a translator has a diffable starting point.

check reports three separate things, and only one of them fails the command:

Reported Meaning Exit
missing a key source calls that the locale does not define non-zero, one X_CATALOG_MISSING_KEYS finding per locale
unused a key the locale defines that no t() call names 0 — informational
dynamic t(plans.${plan}.name), a key the static extractor cannot resolve 0 — informational, with file, line and column

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

Path Locale Output
/ default locale from app.config.ts prerendered
/es/ es prerendered separately
/de/ de prerendered separately
Rule Detail
site/ path prefix is authoritative and prerendered per locale. No client-side locale swap
app/ / api/ resolution order header → cookie → user → query, configurable via configureLocales({ order })
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>

hreflang: the full reciprocal set is emitted per route from the route table, including x-default. Hand-written hreflang tags are a build error. See Routes and render modes.

Localized metadata: meta receives locale. A locale missing a description is the same build error as a route missing one — X_SEO_NO_DESCRIPTION, per-locale.

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 timeZoneTimezones and dates
Currency Money = { minor, currency } formatted at the edge → 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 used in source (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

Full list: 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

Clone this wiki locally