-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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 |
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.
| Form | Renders |
|---|---|
{name} |
String(vars.name) |
{{ / }}
|
a literal { / }
|
{name} with no vars.name
|
⟦name⟧ — loud, same reason a missing key is loud |
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.
| 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.
| 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.
| 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.
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
|
| 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.
| 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.
- Zero hardcoded user-facing strings. Everything through
t(). - One key per complete sentence. Never concatenate translated fragments.
-
enis 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 syncadds; deletion of a key is a deliberate edit in a PR. - Supported locales live in
app.config.ts;SUPPORTED_LOCALESis the framework's own set, which apps narrow or extend.
Source: packages/i18n/src
Ultimate — v1.1.0 As of 2026-08. Stable API, semver from here. MIT licensed.
Repository · Issues · Changelog · llms.txt
Edits to these pages are synced from wiki/ in the repository — change the file there, not the wiki, or the next sync overwrites it.
Start
Tutorials
- 1 · First app
- 2 · First feature
- 3 · Auth and admin
- 4 · Jobs and realtime
- 5 · Deploy free
- 6 · Growing up
Primitives
- The eight primitives
- Actions
- Entities and migrations
- Policies and authz
- Queries and live queries
- Jobs and workflows
- Scheduled tasks
- Routes and render modes
Capabilities
Cross-cutting
Reference