<!-- Source: README.md -->

# @codenhub/i18n

Translate your app's text with typed keys, ICU messages, plurals, and fallback languages, in the browser, on a server, or at build time.

## Installation

```sh
pnpm add @codenhub/i18n
```

## Usage

```ts
// src/i18n.ts
import { createI18n } from "@codenhub/i18n";

const dictionaries = {
  en: { home: { title: "Welcome", greeting: "Hello, {name}!" }, cart: { items: { one: "{count} item", other: "{count} items" } } },
  pt: { home: { title: "Bem-vindo", greeting: "Olá, {name}!" }, cart: { items: { one: "{count} item", other: "{count} itens" } } },
} as const;

export const i18n = createI18n({
  locales: ["en", "pt"] as const,
  defaultLocale: "en",
  loadLocale: (locale) => dictionaries[locale],
});

export const { t } = i18n;
```

```ts
// src/main.ts
import { i18n, t } from "./i18n";

await i18n.init({ locale: "pt" });

t("home.greeting", { name: "Ana" }); // "Olá, Ana!"
t("cart.items", { count: 3 }); // "3 itens"
t("home.titel"); // compile error: there is no such key
```

Two optional entry points cover what differs by environment:

- `@codenhub/i18n/browser` detects the visitor's language, remembers the one they choose, keeps `<html lang>` and `dir` in step, and can translate HTML marked with `data-i18n`.
- `@codenhub/i18n/routing` reads and builds localized paths, such as `/pt/about`, `/pt/sobre`, or `/sobre`.

## Documentation

- [Overview](docs/index.md): the ideas behind the package and a walkthrough.
- [Write your translations](docs/translations.md): JSON files, ICU messages, plurals, regional variants, and typed keys and values.
- [Translate a browser app](docs/browser.md)
- [Render on a server or at build time](docs/server-rendering.md)
- [Localized URLs](docs/localized-urls.md)
- [Troubleshooting](docs/troubleshooting.md)
- [API reference](docs/reference/index.md)
- [Changelog](docs/changelog/index.md)

## Requirements

- An ESM-aware bundler or runtime.
- Node.js 24 or newer, or a current Chromium, Firefox, or WebKit browser or Web Worker.
- No runtime dependencies.

## Notes

- On a server or in a static build, create one manager per request or page, not a shared one.
- The package does not render HTML from translations. ICU messages cover `number`, `date`, `time`, `plural`, `selectordinal`, and `select`, without number skeletons.

## License

Licensed under Apache-2.0.

<!-- Source: docs/index.md -->

# Translate your app

`@codenhub/i18n` turns keys such as `"home.title"` into text in the reader's language. You write your translations as plain objects or JSON files, one per language, and the package handles the rest: it loads the right one, falls back to your main language when a translation is missing, fills in values like names and counts, and tells TypeScript which keys exist, so a typo fails to compile.

It works the same in a browser, a Web Worker, a Node.js server, or a static site build. Two optional extras handle the jobs that differ by environment:

- **`@codenhub/i18n/browser`** picks the visitor's language, remembers their choice, and keeps the page's `lang` and `dir` attributes in step.
- **`@codenhub/i18n/routing`** puts the language in the URL, as in `/pt/about`, and can translate the path itself, as in `/pt/sobre` or `/sobre`.

## Four ideas

You only need four ideas to use this package.

**A locale** names a language, and sometimes a region: `en`, `pt`, `pt-BR`, `ar`. You list the locales your app supports, and pick one as the **default locale**: the language your app is written in first, which every other locale falls back to.

**A dictionary** holds every translation for one locale. Keys can be nested to keep related text together:

```json
{
  "home": {
    "title": "Welcome",
    "greeting": "Hello, {name}!"
  }
}
```

Nested keys are joined with dots, so this dictionary has the keys `home.title` and `home.greeting`.

**A loader** is a function you write that returns the dictionary for a locale. It can return an object you imported, read a file, or fetch JSON from your server. The package never decides where translations live; it calls your loader when it needs a locale, and keeps the result.

**A manager** ties these together. You create one with `createI18n()`, tell it which locale to use, and ask it for text with `t()`.

## Walkthrough

### 1. Install

```sh
pnpm add @codenhub/i18n
```

The package is an ES module and needs Node.js 24 or newer, or a current browser. It has no runtime dependencies.

### 2. Write two dictionaries

Start with your default language and one more. Here they are objects in a TypeScript file; [Write your translations](docs/translations.md) shows how to keep them in JSON files instead.

```ts
// src/locales.ts
export const dictionaries = {
  en: {
    home: { title: "Welcome", greeting: "Hello, {name}!" },
    cart: { items: { one: "{count} item", other: "{count} items" } },
  },
  pt: {
    home: { title: "Bem-vindo", greeting: "Olá, {name}!" },
    cart: { items: { one: "{count} item", other: "{count} itens" } },
  },
} as const;
```

The `as const` lets TypeScript see the exact keys, so it can check every `t()` call against them.

### 3. Create the manager

In a browser app, create the manager once in its own module and import it wherever you need text:

```ts
// src/i18n.ts
import { createI18n } from "@codenhub/i18n";
import { dictionaries } from "./locales";

export const i18n = createI18n({
  locales: ["en", "pt"] as const,
  defaultLocale: "en",
  loadLocale: (locale) => dictionaries[locale],
});

export const { t } = i18n;
```

`t` is a short name for `i18n.translate`. Every method on the manager works on its own after you pull it off, like `t` here, so you can import `t` directly.

### 4. Start it, then translate

The manager has to load a dictionary before it can translate, so start it once when your app starts and wait for it:

```ts
// src/main.ts
import { i18n, t } from "./i18n";

await i18n.init({ locale: "pt" });

t("home.title"); // "Bem-vindo"
t("home.greeting", { name: "Ana" }); // "Olá, Ana!"
t("cart.items", { count: 3 }); // "3 itens"
t("home.titel"); // compile error: there is no such key
```

`init()` without a locale starts with the default locale. Calling `t()` before `init()` has finished throws an error, because there is nothing to translate with yet.

### 5. Switch languages

```ts
await i18n.setLocale("en");
t("home.title"); // "Welcome"
```

`setLocale()` loads the new dictionary first and only switches once it has loaded, so text never shows half in one language and half in another. To redraw your interface when the language changes, pass a function to `i18n.subscribe()`; it runs after every switch.

## What happens when a translation is missing

Translations rarely stay complete. A new feature adds keys in English first, and the other languages catch up later. The package handles this in two steps:

1. **The key is missing in the current locale but present in the default one.** You get the default text. In the example above, a key only in `en` shows English to Portuguese readers. Nothing is logged, because this is normal while translators catch up; [a key check in your tests](docs/translations.md#find-untranslated-keys) finds these.
2. **The key is missing everywhere.** `t()` returns the key, trimmed, such as `"home.subtitle"`, so the gap is visible on screen and easy to search for, and the manager logs a warning once. You can send these to your own logger with the `onMissingKey` option.

`t()` always returns a string, so you never need `?? ""` after it.

## Where to go next

- [Write your translations](docs/translations.md): dictionaries, JSON files, placeholders, plurals, typed keys, and checking for untranslated keys.
- [Translate a browser app](docs/browser.md): detect and remember the visitor's language, translate HTML with `data-i18n`, and re-render a framework.
- [Render on a server or at build time](docs/server-rendering.md): one manager per request, choosing a locale from `Accept-Language`, and static site builds.
- [Localized URLs](docs/localized-urls.md): put the locale in the path, build links to other languages, and generate every localized page.
- [Troubleshooting](docs/troubleshooting.md): what each error means and how to fix it.
- [API reference](docs/reference/index.md): every export, option, and type.
- [Changelog](docs/changelog/index.md): what changed in each version, and how to upgrade.

## What this package does not do

It translates and picks locales. It does not format numbers or dates (use the built-in `Intl.NumberFormat` and `Intl.DateTimeFormat` with `i18n.locale`), render HTML from translations, or support the full ICU message format. Locale routing reads and builds paths, but your framework or server still registers routes, redirects, and navigates.

<!-- Source: docs/translations.md -->

# Write your translations

A dictionary is a plain object whose values are strings or more objects. This page covers how to organize dictionaries, load them, put values and counts into text, and keep every language complete.

## Dictionary shape

Nest keys to group related text. Every leaf must be a string:

```json
{
  "nav": { "home": "Home", "settings": "Settings" },
  "settings": {
    "title": "Settings",
    "language": { "label": "Language", "hint": "Used across the whole app" }
  }
}
```

The manager flattens nesting into dot-separated keys: `nav.home`, `settings.language.hint`. You can also write flat keys directly, as `{ "nav.home": "Home" }`, or mix both, as long as two spellings never produce the same key.

A dictionary is rejected with a `TypeError` when it contains something that is not text or a group: a number, an array, `null`, a `Date`, a function, a key with an empty segment such as `"nav..home"`, or a `__proto__` key. [Troubleshooting](docs/troubleshooting.md#a-dictionary-is-rejected) lists every rule.

An empty dictionary is fine. A locale you have added but not translated yet starts normally and shows default-locale text everywhere.

## Keep dictionaries in JSON files

Most apps keep one JSON file per locale, often because translators or a translation service edit them:

```text
src/locales/
  en.json
  pt.json
```

Load them with a dynamic `import()`, so a visitor downloads only the languages the manager needs: their own, plus your default language as the fallback:

```ts
import { createI18n } from "@codenhub/i18n";
import type en from "./locales/en.json";

export const i18n = createI18n({
  locales: ["en", "pt"] as const,
  defaultLocale: "en",
  async loadLocale(locale): Promise<typeof en> {
    const module = await import(`./locales/${locale}.json`);
    return module.default;
  },
});
```

This shares one manager across a browser app. On a server or in a static build, wrap `createI18n()` in a function and call it for each request or page instead, as [Render on a server](docs/server-rendering.md#one-manager-per-request) explains.

The return type `Promise<typeof en>` is what gives you typed keys: a dynamic import returns `any`, which would otherwise let any string through. `import type` needs `"resolveJsonModule": true` in your `tsconfig.json`, and never ends up in your bundle.

To fetch dictionaries from a server instead, return the parsed JSON and throw on a failed response, so the manager reports the failure instead of loading an error page as a dictionary:

```ts
async loadLocale(locale): Promise<typeof en> {
  const response = await fetch(`/locales/${locale}.json`);
  if (!response.ok) throw new Error(`Locale request failed: ${response.status}`);
  return response.json();
}
```

Your loader owns everything about transport: URLs, caching headers, retries, timeouts, and credentials. The manager only calls it and checks what comes back.

### How loading behaves

- The manager loads a locale the first time it needs it and keeps the result, so switching back to a language does not call your loader again.
- Starting or switching to a non-default locale loads that locale, the locales it falls back to, and the default locale together, as [Regional variants](#regional-variants) explains.
- If your loader throws or rejects, the call that needed it rejects with an `I18nError`, and the manager keeps its current language. The next call tries the loader again.
- Returning the same object every time, such as a module-level import, lets every manager share one checked copy. That matters on a server, which [creates a manager per request](docs/server-rendering.md).
- Treat a dictionary as read-only once loaded: the manager keeps a frozen, checked copy, shared by every manager that loads the same object, so later changes to the object do nothing.

### Regional variants

A regional locale only needs the words that differ. With `pt` and `pt-BR` both in `locales`, `pt-BR.json` can hold just the Brazilian words, and every other key comes from `pt.json`:

```ts
export const i18n = createI18n({
  locales: ["en", "pt", "pt-BR"] as const,
  defaultLocale: "en",
  loadLocale: (locale) => dictionaries[locale],
});
// pt-BR: { "greeting": "Oi" }   pt: { "greeting": "Olá", "farewell": "Tchau" }
await i18n.init({ locale: "pt-BR" });
t("greeting"); // "Oi"
t("farewell"); // "Tchau", from pt
```

A key is looked up in the active locale, then in each configured locale made by dropping its last subtags, such as `zh-Hant` for `zh-Hant-TW`, then in the default locale. A parent written in another script is skipped: `zh` is Simplified Chinese, so a `zh-Hant` page falls back to the default rather than mixing scripts. A plural group uses the rules of the locale whose dictionary has it.

Every locale in that chain loads with the active one, so starting in `pt-BR` loads `pt-BR`, `pt`, and `en`. If one of them fails, the call rejects as for any locale.

## Put values into text

Write `{name}` where a value goes, and pass the value to `t()`:

```json
{ "welcome": "Welcome back, {name}. You have {count} new messages." }
```

```ts
t("welcome", { name: "Ana", count: 4 });
// "Welcome back, Ana. You have 4 new messages."
```

Placeholder names use letters, digits, and `_`. Values can be strings, numbers, or dates.

Use placeholders instead of joining fragments. `t("before") + name + t("after")` locks every language into English word order, while a whole sentence with a placeholder lets each translation put the value where it belongs.

A few details:

- A placeholder with no matching value stays as written, so a missing value shows up as `{name}` on screen rather than disappearing.
- Numbers in a plain `{name}` are inserted as JavaScript writes them: `1234.5`. Write `{name, number}` to format them for the reader, as the next section shows.
- Braces and apostrophes are message syntax, so text that shows them literally needs quoting; see [Literal braces and apostrophes](#literal-braces-and-apostrophes).

### Format numbers and dates in the text

Messages use [ICU MessageFormat](https://unicode-org.github.io/icu/userguide/format_parse/messages/) syntax, so a translation can say how each value is written:

```json
{
  "total": "Total: {amount, number}",
  "progress": "{ratio, number, percent} done",
  "shipped": "Shipped on {date, date, long} at {date, time, short}"
}
```

```ts
t("total", { amount: 1234.5 }); // "Total: 1,234.5", or "Total: 1.234,5" from the Portuguese text
t("shipped", { date: new Date() });
```

| Argument                | Styles                                                             | Value                 |
| ----------------------- | ------------------------------------------------------------------ | --------------------- |
| `{n, number}`           | none, `integer`, `percent`                                         | number                |
| `{d, date}`             | `short`, `medium` (the default), `long`, `full`                    | `Date` or a timestamp |
| `{d, time}`             | `short`, `medium` (the default), `long`, `full`                    | `Date` or a timestamp |
| `{n, plural, …}`        | see [Plurals](#plurals)                                            | number                |
| `{n, selectordinal, …}` | ordinal forms, such as `one {#st} two {#nd} few {#rd} other {#th}` | number                |
| `{v, select, …}`        | any words, plus `other`                                            | string or number      |

Formatting uses the language of the dictionary the text came from, so English text that fills in for a missing Portuguese key is written the English way. An argument whose value has the wrong type, such as a string for `{n, number}`, stays as written. Skeletons such as `{n, number, ::currency/EUR}` are not supported; format such values in code, as below, and pass the string.

A `select` picks text by a value, such as a pronoun, and needs an `other` option:

```json
{ "replied": "{gender, select, female {She} male {He} other {They}} replied" }
```

To format a value in code, use the manager's formatters. They follow the active locale:

```ts
i18n.formatNumber(9.99, { style: "currency", currency: "EUR" }); // "€9.99"
i18n.formatDate(new Date(), { dateStyle: "long" }); // "November 2, 2026"
i18n.formatRelativeTime(-1, "day", { numeric: "auto" }); // "yesterday"
i18n.formatList(["Ana", "Bia", "Caio"]); // "Ana, Bia, and Caio"
```

They take the same options as `Intl.NumberFormat`, `Intl.DateTimeFormat`, `Intl.RelativeTimeFormat`, and `Intl.ListFormat`, and throw what those throw for invalid options.

### Time zones

Dates and times are written in the runtime's time zone. In a browser that is the reader's, which is usually right. On a server it is the server's, so 01:00 UTC on 2 November renders as "November 2" for a reader in São Paulo, where it is still 1 November, and the text changes once the browser takes over. Set `timeZone` to the zone the text should use:

```ts
createI18n({
  // ...
  timeZone: "America/Sao_Paulo",
});
```

It applies to `{d, date}`, `{d, time}`, and a date in `{d}`, and is `formatDate`'s default; `formatDate(value, { timeZone: "UTC" })` still overrides it. A zone `Intl` doesn't know throws a `TypeError` when the manager is created. On a server, set it per request, as in [Show dates in the reader's time zone](docs/server-rendering.md#show-dates-in-the-readers-time-zone).

### Literal braces and apostrophes

An apostrophe before `{` or `}` quotes text up to the next apostrophe, and two apostrophes make one. An apostrophe anywhere else is an ordinary apostrophe, so "Don't" needs nothing:

| Written in the dictionary | Shown                   |
| ------------------------- | ----------------------- |
| `Don't forget {name}`     | `Don't forget Ana`      |
| `Type '{name}' to insert` | `Type {name} to insert` |
| `It''s '{'here'}'`        | `It's {here}`           |

Inside a plural, `#` stands for the number; write `'#'` for a literal `#`.

Text that is not valid syntax, such as an unclosed `{`, logs a warning once, unless the manager is silent, and is shown with only its `{name}` placeholders filled. [Find untranslated keys](#find-untranslated-keys) shows a test that checks every message.

## Plurals

"1 items" is wrong in English, and other languages have more forms than English does. Arabic has six. To let each language choose, write the key as a group of forms instead of a single string:

```json
{
  "cart": {
    "items": {
      "one": "{count} item",
      "other": "{count} items"
    }
  }
}
```

Then pass a number as `count`:

```ts
t("cart.items", { count: 1 }); // "1 item"
t("cart.items", { count: 5 }); // "5 items"
```

The manager asks the built-in `Intl.PluralRules` which form the language uses for that number, and `count` also fills the `{count}` placeholder. The form names are the standard [CLDR plural categories](https://cldr.unicode.org/index/cldr-spec/plural-rules):

| Form    | Used for                                                                                       |
| ------- | ---------------------------------------------------------------------------------------------- |
| `zero`  | Languages with a zero form, such as Arabic and Latvian. English uses `other` for 0.            |
| `one`   | Singular. French and Brazilian Portuguese (`pt`, `pt-BR`) also use it for 0; `pt-PT` does not. |
| `two`   | Dual, such as Arabic, Hebrew, and Welsh.                                                       |
| `few`   | Small numbers, such as 2–4 in Polish and Czech.                                                |
| `many`  | Large numbers in some languages, such as Polish and Arabic.                                    |
| `other` | Everything else. **Always include it**; it is the fallback for any form you leave out.         |

Each dictionary only needs the forms its own language uses: the English file has `one` and `other`, and the Arabic file has all six. Look up a language's forms in the [CLDR plural rules chart](https://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html).

When a plural group is missing from the current locale, the default locale's group is used, with the default language's rules, since that text is in the default language.

Two things to watch for:

- `count` must be a number. A string such as `"5"` does not select a form; the group is then treated as a missing key.
- A plain string under the same key wins over forms. If `cart.items` is a string, `count` only fills `{count}`.

### Show a formatted count

A plain `{count}` writes the number as JavaScript does, so 1234 items reads "1234 items" rather than "1,234 items", or "1.234 itens" in Portuguese. Write `{count, number}` in the forms instead:

```json
{
  "cart": {
    "items": {
      "one": "{count, number} item",
      "other": "{count, number} items"
    }
  }
}
```

```ts
t("cart.items", { count: 1234 }); // "1,234 items"
```

### Plurals inside a sentence

A plural group chooses the whole text. To choose only part of a sentence, or to give `0` its own wording, write an ICU plural in a single string. `#` is the number, formatted for the language, and `=N` matches an exact number before the forms are asked:

```json
{
  "inbox": "Hi {name}, {count, plural, =0 {you have no messages} one {you have # message} other {you have # messages}}."
}
```

```ts
t("inbox", { name: "Ana", count: 0 }); // "Hi Ana, you have no messages."
t("inbox", { name: "Ana", count: 1200 }); // "Hi Ana, you have 1,200 messages."
```

`offset:N` subtracts from the number before choosing a form and filling `#`, but not before matching `=N`, as in `{count, plural, offset:1 =0 {Nobody} =1 {{name}} other {{name} and # others}}`. Both ways of writing plurals are supported; plural groups keep each form an ordinary key, which some translation tools handle better.

## Typed keys

When TypeScript can see your dictionary's type, `t()` only accepts keys that exist:

```ts
t("cart.items", { count: 2 }); // ok: a plural group
t("cart.items.one"); // ok: a single form is a key too
t("cart"); // compile error: a group of text is not a key
t("cart.itmes", { count: 2 }); // compile error: misspelled
```

The type comes from what your loader returns. If it returns several `as const` dictionaries, a key from any of them is accepted. If it returns `any` or `unknown`, as `response.json()` and dynamic imports do, every string is accepted; give the loader a return type, as in the JSON example above, to get checking back.

A key you build at runtime, such as ``t(`errors.${code}`)``, fails to compile even when it exists, because TypeScript can't know the value of `code`. Check it with `has()`, which narrows it to a key `t()` accepts, and pick a fallback when it isn't there:

```ts
const key = `errors.${code}`;
const message = i18n.has(key) ? i18n.t(key) : i18n.t("errors.unknown");
```

`has()` looks through the same dictionaries `t()` does, and a plural group counts as a key. A miss neither calls `onMissingKey` nor warns, since a key you expect to be missing sometimes is not a gap in the translations.

When you know the keys are there, cast it instead:

```ts
import type { TranslationKey } from "@codenhub/i18n";
import type en from "./locales/en.json";

type Key = TranslationKey<typeof en>;

t(`errors.${code}` as Key);
```

`TranslationKey` is also useful for typing a component prop or helper that takes a key.

### Typed values

When the default dictionary is an `as const` object in a `.ts` file, TypeScript sees each message's text, and `t()` also checks its values:

```ts
// locales/en.ts
export default {
  welcome: "Welcome back, {name}.",
  total: "Total: {amount, number}",
  cart: { items: { one: "{count} item", other: "{count} items" } },
} as const;
```

```ts
t("welcome", { name: "Ana" }); // ok
t("welcome"); // compile error: name is required
t("total", { amount: "3" }); // compile error: {amount, number} needs a number
t("cart.items", { count: 2 }); // ok: a plural group requires a numeric count
```

`number`, `plural`, and `selectordinal` arguments take a number, `date` and `time` take a `Date` or a timestamp, `select` takes a string or number, and a plain `{name}` takes any of these. Extra values are allowed. When several dictionaries are typed, a key needs the values of every one that has it.

A JSON import types every text as `string`, since TypeScript doesn't keep JSON string values, so a JSON dictionary checks keys but accepts any values. To check values, write the default language as `as const` TypeScript and keep the other languages in JSON, typed as the default:

```ts
import en from "./locales/en";

const loadLocale = async (locale: Locale): Promise<typeof en> => (locale === "en" ? en : (await import(`./locales/${locale}.json`)).default);
```

The loader's return type is what types `t()`, so a translation must use the same arguments as the default text; the compiler can't check the JSON files. A key chosen at runtime, a message TypeScript can't read, such as one with broken syntax, and a manager typed by hand as `I18n<"en" | "pt", Key>` accept any values. Pass the dictionary type as a third type argument, `I18n<"en" | "pt", Key, typeof en>`, to keep them checked.

If you write the manager's type yourself, as in `let i18n: I18n<"en" | "pt">`, pass the key type too: `I18n<"en" | "pt", Key>`. The second type argument defaults to `string`, which accepts any key. `createI18n()` fills it in for you.

Let TypeScript infer the manager's types from the configuration. Passing some type arguments yourself, such as `createI18n<"en" | "pt">(...)`, turns off inference for the rest, and keys go back to plain `string`.

If you keep the configuration in a separate variable, mark the default locale `as const`:

```ts
const config = {
  locales: ["en", "pt"] as const,
  defaultLocale: "en" as const,
  loadLocale: (locale: "en" | "pt") => dictionaries[locale],
};
```

Without `as const`, TypeScript sees `defaultLocale` as any `string`, and `createI18n(config)` reports that `string` is not `"en" | "pt"`.

## Find untranslated keys

A key missing from one language quietly shows default-language text, which is easy to miss. Catch it in a test by comparing every dictionary with the default one. `loadLocale()` returns a dictionary without changing the manager's language:

```ts
import { expect, test } from "vitest";
import { i18n } from "./i18n";

// Plural forms other than `other` differ by language, so they are not compared.
// A key such as `settings.one` is still compared unless its group also has `other`.
const isLanguageSpecificForm = (key: string, dictionary: Readonly<Record<string, string>>) => {
  const form = /^(.+)\.(zero|one|two|few|many)$/.exec(key);
  return form !== null && `${form[1]}.other` in dictionary;
};

test("every locale has every key", async () => {
  const defaults = await i18n.loadLocale(i18n.defaultLocale);
  const reference = Object.keys(defaults).filter((key) => !isLanguageSpecificForm(key, defaults));

  for (const locale of i18n.locales) {
    const dictionary = await i18n.loadLocale(locale);
    const missing = reference.filter((key) => !(key in dictionary));
    expect(missing, `missing in ${locale}`).toEqual([]);
  }
});
```

To catch a message with broken syntax, such as an unclosed `{` a translator left, look up every key in every locale and fail on the warning. The manager must not be silent for this:

```ts
import { expect, test, vi } from "vitest";
import { i18n } from "./i18n";

test("every message is valid", async () => {
  const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
  await i18n.init();

  for (const locale of i18n.locales) {
    await i18n.setLocale(locale);
    for (const key of Object.keys(await i18n.loadLocale(locale))) {
      i18n.t(key as Parameters<typeof i18n.t>[0]);
    }
  }

  expect(warn.mock.calls.filter(([text]) => String(text).startsWith("[I18n] Invalid message"))).toEqual([]);
  warn.mockRestore();
});
```

To fail a test on keys missing from every dictionary, such as a typo in a key you built at runtime, throw from `onMissingKey`:

```ts
createI18n({
  // ...
  onMissingKey(key, locale) {
    throw new Error(`Missing translation "${key}" in ${locale}`);
  },
});
```

## Size limits

Dictionaries come from files and servers, so the manager limits how large they can be. This stops a corrupted or hostile file from freezing the page. The defaults fit any real app:

| Limit                      | Default   | Option            |
| -------------------------- | --------- | ----------------- |
| Nesting depth              | 100       | `maxDepth`        |
| Translations               | 10,000    | `maxTranslations` |
| Keys and groups together   | 20,000    | `maxProperties`   |
| Characters in one text     | 100,000   | `maxValueLength`  |
| Characters in all texts    | 5,000,000 | `maxTotalLength`  |
| Characters in one full key | 1,000     | fixed             |

Raise one with `dictionaryLimits`, which keeps the default for any limit you leave out:

```ts
createI18n({
  // ...
  dictionaryLimits: { maxTranslations: 50_000, maxProperties: 100_000 },
});
```

A nested dictionary counts each group as a property, so raising `maxTranslations` often means raising `maxProperties` too.

<!-- Source: docs/browser.md -->

# Translate a browser app

In a browser, the manager from the [overview](docs/index.md) still does the translating. `@codenhub/i18n/browser` adds the parts only a browser has: the visitor's language settings, a place to remember their choice, and the page itself.

## Start with `initializeBrowserI18n`

Use `initializeBrowserI18n()` instead of `i18n.init()`. It picks a locale, starts the manager with it, and then keeps the page in step:

```ts
// src/main.ts
import { initializeBrowserI18n } from "@codenhub/i18n/browser";
import { i18n } from "./i18n";

await initializeBrowserI18n({ i18n });
```

With no other options, this:

1. Picks the visitor's locale (see below) and loads it.
2. Sets `<html lang="pt" dir="ltr">` to match, so screen readers pronounce text correctly and right-to-left languages lay out correctly.
3. Saves the visitor's choice whenever your app changes the language with `setLocale()`, and reads it back on their next visit.

It returns a binding with a `disconnect()` method; you only need it when you tear the app down, as in tests or micro-frontends.

### How the locale is chosen

The first of these that gives a supported locale wins:

1. The `locale` option, when you pass one.
2. The visitor's saved choice from an earlier visit.
3. The browser's language settings, `navigator.languages`, matched to your locales. A visitor whose browser asks for `pt-BR` gets your `pt`.
4. Your default locale.

Pass `locale` when the page already knows its language, such as from the URL in [a localized site](docs/localized-urls.md#read-the-locale-on-the-client). It then overrides everything else, so the browser can't disagree with the server about which language the page is in.

If your loader fails for a saved or detected locale, the visitor gets the default locale and a console warning instead of a broken app, and their saved choice is kept for next time. A loader failure for a locale you passed explicitly, or for the default locale, still rejects, since there is nothing sensible to fall back to. So does a dictionary that loads but [breaks the rules](docs/troubleshooting.md#a-dictionary-is-rejected), whatever the locale: that is a bug to fix, not a network hiccup to ride out.

## Let visitors switch languages

Call `setLocale()` from your language picker. The binding reacts to every change: it saves the choice and updates `lang` and `dir`.

```ts
const picker = document.querySelector<HTMLSelectElement>("#language")!;

picker.value = i18n.locale;
picker.addEventListener("change", async () => {
  await i18n.setLocale(picker.value as (typeof i18n.locales)[number]);
});
```

Only choices your app makes are saved. A language detected from the browser settings is not, so a visitor who never picked one keeps following their browser settings, including when you add their language later.

The choice is stored in `localStorage` as `{"locale":"pt"}` under the key `"i18n"`. Change the key with `storageKey`, or turn saving off with `persistLocale: false`. If storage is unavailable, as in some private browsing modes, the binding warns and carries on without it.

## Re-render a framework

Frameworks such as React, Vue, Svelte, or Solid render the text themselves by calling `t()`. They need to know when to render again, and `i18n.subscribe()` tells them: it calls your function after every language change and returns a function that stops it.

In React, `useSyncExternalStore` connects the two:

```tsx
import { useSyncExternalStore } from "react";
import { i18n, t } from "./i18n";

export function useLocale() {
  return useSyncExternalStore(i18n.subscribe, () => i18n.locale);
}

export function CartBadge({ count }: { count: number }) {
  useLocale(); // re-renders this component when the language changes
  return <span>{t("cart.items", { count })}</span>;
}
```

Other frameworks follow the same pattern: store `i18n.locale` in a reactive value and update it from `subscribe()`.

## Translate HTML without a framework

For static pages, or HTML your framework doesn't render, the binding can translate the page itself. Mark elements with the key, and pass the part of the page to translate as `root`:

```html
<h1 data-i18n="home.title">Welcome</h1>
<input data-i18n-placeholder="search.hint" placeholder="Search" />
<button data-i18n-aria-label="menu.open"><svg>…</svg></button>
```

```ts
await initializeBrowserI18n({ i18n, root: document });
```

- `data-i18n` replaces an element's text. The element must contain only text, with no child elements, so a translation can never remove your markup. Elements with children are skipped with a warning.
- `data-i18n-<attribute>` translates one attribute. Only attributes that hold text for people or screen readers are allowed: `alt`, `title`, `placeholder`, `label`, `aria-label`, `aria-description`, `aria-placeholder`, `aria-roledescription`, and `aria-valuetext`. Others, such as `data-i18n-href` or `data-i18n-onclick`, are ignored, so a translation can never change a link, a style, or an event handler.
- The text already in the HTML is the fallback. If a later language lacks the key, the original text comes back.
- Text is set as plain text, never as HTML, and `<script>`, `<style>`, `<noscript>`, and `<template>` are never touched.
- Placeholders such as `{name}` are not filled here, because there is nowhere in the HTML to pass a value from. Render text with values from your code with `t()`.

The page is retranslated on every language change. To also translate elements your code adds later, turn on `observe`:

```ts
await initializeBrowserI18n({ i18n, root: document.querySelector("#app")!, observe: true });
```

With `observe`, adding an element, or changing or removing a `data-i18n` key, is handled as it happens. Leave `observe` off when a framework renders the page: the framework already calls `t()`, and watching the page would only duplicate its work.

The binding does not look inside custom elements (tags with a dash, such as `<my-widget>`) from an outer root, since the component owns its content. To translate a component's content, pass the component, or its shadow root, as the `root` of its own binding.

## Options

| Option          | Default    | What it does                                                   |
| --------------- | ---------- | -------------------------------------------------------------- |
| `i18n`          | (required) | The manager to start.                                          |
| `locale`        | none       | A locale that overrides saved and detected ones.               |
| `persistLocale` | `true`     | Read the saved choice, and save choices your app makes.        |
| `storageKey`    | `"i18n"`   | The `localStorage` key for the saved choice.                   |
| `syncDocument`  | `true`     | Keep `<html lang>` and `dir` in step with the locale.          |
| `root`          | none       | The part of the page to translate with `data-i18n` attributes. |
| `observe`       | `false`    | Also translate changes below `root` as they happen.            |

## Rules to know

- **One binding per manager.** A second `initializeBrowserI18n()` call for the same manager rejects until the first is disconnected.
- **Don't mix it with `init()`.** The binding starts the manager. If your code calls `i18n.init()` while the binding is starting, the binding rejects rather than fight over the language. Calling `init()` or `setLocale()` after it has started is fine.
- **`disconnect()` stops reacting but undoes nothing.** It stops saving, syncing, and translating, but leaves the current text, attributes, and saved choice as they are. You can then start a new binding.

The [browser reference](docs/reference/browser.md) documents every option and failure.

<!-- Source: docs/server-rendering.md -->

# Render on a server or at build time

A browser has one visitor, so one manager for the whole app is right. A server renders pages for many visitors at once, often in different languages, and a static site build renders every language in one run. Both need a different shape: **one manager per page you render**.

## One manager per request

A manager has one current language. If two requests shared one, a Portuguese request calling `setLocale("pt")` would change the language of an English page still rendering. So create the manager inside the request:

```ts
// src/i18n.ts
import { createI18n } from "@codenhub/i18n";
import { dictionaries } from "./locales";

export const createAppI18n = () =>
  createI18n({
    locales: ["en", "pt"] as const,
    defaultLocale: "en",
    loadLocale: (locale) => dictionaries[locale],
  });
```

```ts
// src/render.ts
import { createAppI18n } from "./i18n";

export async function renderPage(request: Request) {
  const i18n = createAppI18n();
  await i18n.init({ locale: pickLocale(i18n, request) });
  const { t } = i18n;

  return `<!doctype html>
<html lang="${i18n.locale}" dir="${i18n.direction}">
  <title>${escapeHtml(t("home.title"))}</title>
</html>`;
}
```

Creating a manager is cheap, and the work that isn't is shared. When your loader returns the same object on every call, as an imported module does, every manager reuses the one checked copy of that dictionary instead of checking it again. A loader that reads and parses a file on every call loses that, so read each file once and keep the result.

Pass the manager, or `t`, down to whatever renders the page, rather than importing a shared one.

`t()` returns plain text. Escape it before putting it into HTML, as you would any other text, or let your template engine do it.

## Choose a locale from `Accept-Language`

Browsers send the visitor's languages in the `Accept-Language` header, such as `pt-BR,pt;q=0.9,en;q=0.8`. Turn it into a list, most preferred first, and let `matchLocale()` pick the best locale you support:

```ts
import type { I18n } from "@codenhub/i18n";

function pickLocale<TLocale extends string>(i18n: I18n<TLocale>, request: Request): TLocale {
  const preferences = (request.headers.get("accept-language") ?? "")
    .split(",")
    .map((entry) => {
      const [tag = "", ...parameters] = entry.split(";");
      const weight = parameters.map((parameter) => parameter.trim()).find((parameter) => /^q=/i.test(parameter));
      // A weight is 0 to 1 with up to three decimals; anything else, such as `q=0x1`, is malformed.
      const quality = weight === undefined ? 1 : /^q=(0(\.\d{0,3})?|1(\.0{0,3})?)$/i.test(weight) ? Number(weight.slice(2)) : 0;
      return { tag: tag.trim(), quality };
    })
    // Drops refused languages (q=0) and malformed weights.
    .filter(({ quality }) => quality > 0)
    .sort((left, right) => right.quality - left.quality)
    .map(({ tag }) => tag);

  return i18n.matchLocale(preferences) ?? i18n.defaultLocale;
}
```

A visitor whose languages all fail to match, including one who refuses your default with `en;q=0`, still gets the default locale, since the page has to be in some language. This helper does not implement the rest of `Accept-Language`, such as `*`; use your server framework's parser if you need it, and pass its result to `matchLocale()`.

`matchLocale()` takes a list rather than the header because parsing the header, and limiting its size, belongs to your server. It matches the same way the browser binding does, so a visitor gets the same language from the server as from the browser: `pt-BR` gets your `pt`, and `zh-TW` gets your `zh-Hant`. Malformed tags are skipped, and `undefined` means nothing matched.

When the URL carries the language, as on [a localized site](docs/localized-urls.md), read it from the URL instead and use `Accept-Language` only to choose where to redirect a visitor who arrives at `/`.

## Show dates in the reader's time zone

A server formats dates in its own time zone, which is rarely the reader's. Text such as "Shipped on {date, date, long}" can then show another day than the reader's calendar, and change when the browser takes over. Give each request's manager the reader's zone, such as one your app stores per account or the browser reports in a cookie:

```ts
export const createAppI18n = (timeZone?: string) =>
  createI18n({
    locales: ["en", "pt"] as const,
    defaultLocale: "en",
    loadLocale: (locale) => dictionaries[locale],
    timeZone,
  });
```

`Intl.DateTimeFormat().resolvedOptions().timeZone` reads the zone in the browser. A zone `Intl` doesn't know throws a `TypeError`, so check a value from a cookie before using it, or fall back to a fixed zone such as `"UTC"`.

## Keep production logs useful

A missing key logs a warning once per manager. With a manager per request, that is once per request, which floods the logs. In production, send misses to your logger, or turn the warnings off:

```ts
createI18n({
  // ...
  onMissingKey(key, locale) {
    logger.warn({ key, locale }, "missing translation");
  },
});
```

`isSilent: true` turns off warnings only. Errors, such as a failed load, still throw.

In Node.js, an exception thrown from a function passed to `subscribe()`, or from a `ready` or `locale-change` listener, crashes the process, as with any `EventTarget`. Servers rarely need these, but if you use them, keep them from throwing.

## Build a static site

A static build renders every page in every language ahead of time. It follows the same rule: one manager per page. List your localized pages with [locale routing](docs/localized-urls.md#generate-every-localized-page), then render each one:

```ts
import { createLocaleRouting } from "@codenhub/i18n/routing";
import { createAppI18n } from "./i18n";

const routing = createLocaleRouting({
  locales: ["en", "pt"] as const,
  defaultLocale: "en",
  localePrefix: "except-default",
});

// Your pages, keyed by their canonical path: the path without a locale, in the words your code routes on.
const pages = { "/": renderHome, "/about": renderAbout };

const builds = Object.entries(pages).flatMap(([page, render]) => routing.localizeAll(page).map(({ locale, pathname }) => ({ locale, pathname, render })));
// [{ locale: "en", pathname: "/", render: renderHome }, { locale: "pt", pathname: "/pt", render: renderHome }, ...]

await Promise.all(
  builds.map(async ({ locale, pathname, render }) => {
    const i18n = createAppI18n();
    await i18n.init({ locale });
    await writePage(pathname, render(i18n.t));
  }),
);
```

A dictionary that fails to load or check makes `init()` reject, so the build fails instead of publishing pages without text. A key that only your default language has does not fail the build; it renders in the default language. Add [the untranslated-keys test](docs/translations.md#find-untranslated-keys) to catch those, and make `onMissingKey` throw during the build to fail on keys missing from every language.

## Hand the locale to the browser

When the browser takes over a server-rendered page, it must start in the language the server used, or the text changes as the page loads. Pass that locale explicitly; it overrides saved and detected ones:

```ts
await initializeBrowserI18n({ i18n, locale: document.documentElement.lang as "en" | "pt" });
```

On a localized site, read it from the URL with `routing.parse()` instead, as [Localized URLs](docs/localized-urls.md#read-the-locale-on-the-client) shows.

<!-- Source: docs/localized-urls.md -->

# Localized URLs

Giving each language its own URL, such as `/about` and `/pt/about`, lets people share a link in the language they read, and lets search engines index every language. `@codenhub/i18n/routing` handles the part that is the same in every framework: reading the locale from a path, and building the path for another locale. It can also translate the path itself, so the Portuguese about page is `/pt/sobre`, or even just `/sobre`.

## Set up routing

```ts
// src/routing.ts
import { createLocaleRouting } from "@codenhub/i18n/routing";

export const routing = createLocaleRouting({
  locales: ["en", "pt"] as const,
  defaultLocale: "en",
  localePrefix: "except-default",
});
```

`localePrefix` decides which paths start with their locale:

| `localePrefix`     | English about page | Portuguese about page                                                             |
| ------------------ | ------------------ | --------------------------------------------------------------------------------- |
| `"except-default"` | `/about`           | `/pt/about`                                                                       |
| `"always"`         | `/en/about`        | `/pt/about`                                                                       |
| `"when-ambiguous"` | `/about`           | `/pt/about`, or `/sobre` once translated; see [Drop the prefix](#drop-the-prefix) |

`"except-default"` keeps your existing URLs when you add a language. `"always"` makes every URL name its language, so `/about` is not a page, which suits a site that redirects `/` to the visitor's language.

Use the same `locales` and `defaultLocale` as your manager. Routing is separate from the manager, so it also works where no translation happens, such as in a server's redirect logic.

If you keep the configuration in a variable before passing it in, write `localePrefix: "except-default" as const`. Without `as const`, TypeScript widens the value to `string`, and `createLocaleRouting` rejects it at compile time.

## Read the locale from a path

`parse()` splits a path into its locale and the rest:

```ts
routing.parse("/pt/about"); // { locale: "pt", pathname: "/about" }
routing.parse("/about"); // { locale: "en", pathname: "/about" }
routing.parse("/PT/about"); // { locale: "pt", pathname: "/about" }
```

The `pathname` it returns is the **canonical path**: the path your application routes on, the same in every language. Hold on to that idea; it matters once paths are translated.

`parse()` returns `undefined` for a path that is not a page in any locale, such as `/pt/en/about`: once its prefix is removed it starts with another locale, so no link ever leads there. Answer it with a 404.

With `localePrefix: "always"`, a path without a locale returns `undefined` too, so you can redirect it. Add the chosen locale's prefix and parse that, so a path in that locale's words, such as `/sobre`, is read as one, and a path that is no page in that locale either gets its 404:

```ts
const route = routing.parse(url.pathname);
if (route === undefined) {
  const locale = pickLocale(i18n, request);
  const guess = routing.parse(`/${locale}${url.pathname === "/" ? "" : url.pathname}`);
  if (guess === undefined) {
    return new Response("Not found", { status: 404 });
  }
  const target = new URL(url);
  target.pathname = routing.localize(guess.pathname, guess.locale);
  return Response.redirect(target, 302);
}
```

`pickLocale` is the `Accept-Language` helper from [Render on a server](docs/server-rendering.md#choose-a-locale-from-accept-language).

### Read the locale on the client

In the browser, take the locale from the URL and pass it to the browser binding. The URL then wins over the visitor's saved and detected languages, so the page always matches its address:

```ts
import { initializeBrowserI18n } from "@codenhub/i18n/browser";
import { i18n } from "./i18n";
import { routing } from "./routing";

const route = routing.parse(window.location.pathname);
await initializeBrowserI18n({ i18n, locale: route?.locale });
```

### Redirect to the canonical URL

`parse()` is forgiving: `/PT/about` and `/en/about` are read as well as `/pt/about` and `/about`. That is friendly to someone typing a URL, but a search engine that finds one page at several addresses splits its ranking between them. Send every other spelling to the one `localize()` builds. Every path `parse()` returns has one, so this never throws:

```ts
const route = routing.parse(url.pathname);
if (route !== undefined) {
  const canonical = routing.localize(route.pathname, route.locale);
  if (canonical !== url.pathname) {
    const target = new URL(url);
    target.pathname = canonical;
    return Response.redirect(target, 301);
  }
}
```

## Link to another language

`localize()` builds the path of a page in a locale. Give it the canonical path:

```ts
routing.localize("/about", "pt"); // "/pt/about"
routing.localize("/about", "en"); // "/about"
```

A path that already starts with a locale is read as that locale's URL, so the prefix is replaced:

```ts
routing.localize("/pt/about", "en"); // "/about"
```

On a localized site, switching language is a navigation, not just `setLocale()`: `setLocale()` changes the text but never the URL. Read the current page with `parse()`, build its path in the new language, keep the query string and fragment, and let your router go there:

```ts
const route = routing.parse(window.location.pathname);
if (route !== undefined) {
  await navigate(routing.localize(route.pathname, "pt") + window.location.search + window.location.hash);
}
```

Going through `parse()` matters once paths are translated: `/sobre` is a Portuguese URL, not a canonical path, and only `parse()` knows to read it as `/about`.

Use the same call for the `<link rel="alternate" hreflang>` tags that tell search engines about each language, and add an `x-default` tag for readers whose language you don't offer, pointing at the default locale's page:

```ts
const defaultLocale = "en"; // the defaultLocale you configured
const link = (hreflang: string, pathname: string) => `<link rel="alternate" hreflang="${escapeHtml(hreflang)}" href="${escapeHtml(new URL(pathname, "https://example.com").href)}">`;

const alternates = [...routing.localizeAll(route.pathname).map((page) => link(page.locale, page.pathname)), link("x-default", routing.localize(route.pathname, defaultLocale))];
```

Escape both values as you would any text in HTML, or let your framework create the tags. A path can contain characters, such as `"` or `&`, that are allowed in a URL but not raw inside an attribute.

## Translate paths

`/pt/about` is a Portuguese page with an English address. To give it Portuguese words, list the translated paths in `pathnames`, keyed by canonical path:

```ts
export const routing = createLocaleRouting({
  locales: ["en", "pt", "es"] as const,
  defaultLocale: "en",
  localePrefix: "except-default",
  pathnames: {
    "/about": { pt: "/sobre", es: "/acerca" },
    "/products/:id": { pt: "/produtos/:id" },
    "/products/:id/reviews/:reviewId": { pt: "/avaliacoes/:reviewId/produto/:id" },
  },
});
```

```ts
routing.localize("/about", "pt"); // "/pt/sobre"
routing.localize("/products/42", "pt"); // "/pt/produtos/42"
routing.parse("/pt/produtos/42"); // { locale: "pt", pathname: "/products/42" }
```

Your application keeps routing on `/products/42`, whatever the language. Only the URL changes.

How the table reads:

- **Parameters** are segments that start with `:`, such as `:id`. They match any one segment, and the value is copied across unchanged. Every form must use the same parameters as the canonical path, but in any order, because languages don't agree on word order: `/products/42/reviews/7` is `/pt/avaliacoes/7/produto/42`.
- **A catch-all** is a last segment written `:name*`, such as `"/docs/:path*": { pt: "/documentos/:path*" }`. It matches one or more segments and copies them across, so `/docs/guides/setup` is `/pt/documentos/guides/setup`. It does not match zero segments: list `/docs` itself as its own entry.
- **Anything the table doesn't list keeps its words.** `/blog` is `/pt/blog`, and `/products/42` in Spanish is `/es/products/42`, since only Portuguese translates it.
- **Fixed segments match case-sensitively**, as URLs do, so `/pt/Sobre` is not the about page. Only the locale prefix ignores case.
- **Write words as you would read them.** `"/configurações"` matches the `/configura%C3%A7%C3%B5es` that browsers send, and `localize()` returns the encoded form.
- **A fixed segment beats a parameter, and a parameter beats a catch-all.** With both `/products/new` and `/products/:id` listed, `/products/new` is the new-product page, whichever order the table lists them in. With `/docs/intro` and `/docs/:path*`, `/docs/intro` is the intro page and every other `/docs/…` path goes to the catch-all.
- **The default locale can be translated too.** A Portuguese-first site with English code can use `defaultLocale: "pt"` and `{ "/about": { pt: "/sobre" } }`, so `/sobre` is the Portuguese page and `/en/about` the English one.

### Mistakes it catches

`createLocaleRouting` checks the table when you create it, so a mistake fails at startup instead of on some unlucky click. Each of these throws a `TypeError` that names the entry:

- **Two pages with the same path in one locale.** `{ "/about": { pt: "/sobre" }, "/company": { pt: "/sobre" } }` gives Portuguese two pages at `/pt/sobre`. Give one of them other words. The same goes for a form that takes words another page keeps: if `/contact` is `/about` in Spanish, the Spanish `/about` would be two pages.
- **Two canonical paths with the same shape.** `/products/:id` and `/products/:slug` match the same paths. Keep one.
- **Parameters that differ from the canonical path's.** `{ "/products/:id": { pt: "/produtos/:slug" } }` loses `:id`. Rename it, or move it, but keep it. A catch-all must stay a catch-all in every form.
- **A catch-all that isn't last**, such as `/docs/:path*/edit`.
- **A path that starts with a locale**, such as `/pt/about` as a canonical path. The prefix would be read as the locale. List `/about` instead.
- **A locale that isn't configured**, or one listed twice in different letter case.
- **A path `parse()` would reject**, as listed under [Paths it accepts](#paths-it-accepts).

### Register translated routes

Routing reads and builds paths; your framework still has to send `/pt/sobre` to the about page. There are two ways to do that, and both work with any framework.

**Rewrite to the canonical path.** If your framework or server can change the path it routes on, as server middleware and request hooks usually can, register only the canonical routes and route on `parse()`'s result:

```ts
const route = routing.parse(url.pathname);
// Route on `route.pathname`, such as "/about" for "/pt/sobre", and render in `route.locale`.
```

**Register one route per locale.** A route pattern is a path, so `localizeAll()` translates it:

```ts
routing.localizeAll("/products/:id");
// [{ locale: "en", pathname: "/products/:id" }, { locale: "pt", pathname: "/pt/produtos/:id" }, { locale: "es", pathname: "/es/products/:id" }]
```

Register each one with its locale. With `@codenhub/router`, that is:

```ts
for (const { locale, pathname } of routing.localizeAll("/products/:id")) {
  router.on(pathname, ({ params }) => renderProduct(params["id"], locale));
}
```

`@codenhub/router` takes the first route that matches, in the order you register them. When two of a locale's paths can match the same URL, such as `/pt/produtos/novo` and `/pt/produtos/:id`, register the one with fixed words first.

`localizeAll()` writes a catch-all as `:name*`. Translate that into your framework's own syntax when you register it, such as `[...path]` in a file-based router. `@codenhub/router` has no catch-all, so with it, route such paths by rewriting to the canonical path instead.

## Drop the prefix

`/pt/sobre` already says Portuguese twice. With `localePrefix: "when-ambiguous"`, a translated path drops the prefix when its words can only be Portuguese:

```ts
export const routing = createLocaleRouting({
  locales: ["en", "pt"] as const,
  defaultLocale: "en",
  localePrefix: "when-ambiguous",
  pathnames: { "/about": { pt: "/sobre" } },
});

routing.localize("/about", "pt"); // "/sobre"
routing.parse("/sobre"); // { locale: "pt", pathname: "/about" }
routing.localize("/", "pt"); // "/pt"
routing.localize("/blog", "pt"); // "/pt/blog"
```

The prefix stays wherever the path alone can't say which language it is in:

- **Paths with the same words in two languages.** `/` and `/blog` exist in English too, so Portuguese keeps `/pt` and `/pt/blog`. Two languages translating to the same words, such as `/contato` in both Portuguese and Spanish, keep the prefix as well.
- **Paths another language's parameter could match.** If Spanish lists `/:tag`, every one-segment path could be a Spanish tag, so `/sobre` keeps its prefix.
- **Paths made only of parameters**, such as `/:tag`, which could be anything.

The default locale never has a prefix in this mode, and `parse()` still accepts `/pt/sobre`, so redirect it as in [Redirect to the canonical URL](#redirect-to-the-canonical-url).

### The risk you take on

Routing only knows the paths in your table. A path it doesn't list is taken to be the default locale's, so it can't see a clash with a page it was never told about. Say your English blog has a post at `/sobre`, a slug that came from your CMS. `/sobre` now opens the Portuguese about page, and the post can't be reached.

`localize()` catches this when it builds a path, and throws a `RangeError` rather than link to the wrong page:

```ts
routing.localize("/sobre", "en");
// RangeError: [I18n] "/sobre" in "en" would be read as "/about" in "pt"; …
```

A static build that localizes every page it renders finds every clash at build time. A server only finds one when it builds that page's links, so if your URLs come from anywhere outside the table, such as a CMS, check new slugs against `parse()`, or keep the prefix with `"except-default"`.

The same error tells you when you passed a URL where `localize()` wants a canonical path, such as `localize("/sobre", "en")` when you meant the page at `/sobre`. Pass `parse("/sobre").pathname` instead.

## Generate every localized page

`localizeAll()` returns a path for every locale, in the order of `locales`. With the table from [Translate paths](#translate-paths):

```ts
routing.localizeAll("/about");
// [{ locale: "en", pathname: "/about" }, { locale: "pt", pathname: "/pt/sobre" }, { locale: "es", pathname: "/es/acerca" }]
```

Feed it your page list to get every page a static build needs, then render each one with its own manager, as in [Build a static site](docs/server-rendering.md#build-a-static-site).

## Paths it accepts

Routing works on the path of a URL only. Give it `url.pathname`, not the full URL, and handle the query string and fragment yourself.

To keep a crafted URL from reaching a different page than the one checked, every method throws a `TypeError` for a path that:

- does not start with `/`, or is a full or protocol-relative URL, such as `https://…` or `//host`;
- contains `?`, `#`, `\`, `//`, or a control character;
- contains a `.` or `..` segment, or encodes one, a slash, a backslash, a `%`, or a control character with `%`;
- has a malformed `%` escape, or is longer than 8,192 characters.

Ordinary escapes, such as `%20`, are fine, and a trailing slash is kept: `/pt/sobre/` parses to `/about/`.

`localize()` throws a `RangeError` instead when the path it builds would be longer than 8,192 characters, as a path just under the limit can be once its prefix or translated words are added.

## What routing leaves to your framework

Routing reads and builds paths. Your framework or server still registers the routes, redirects, navigates, and renders. It also doesn't know about a base path: if your site lives under `/docs`, remove that before calling `parse()` and add it back after `localize()`.

The table covers your application's own routes. Content slugs, such as a blog post's title in its URL, belong to wherever the content lives, such as your CMS, which knows each post's slug in each language.

<!-- Source: docs/troubleshooting.md -->

# Troubleshooting

Each section starts from what you see, then explains the cause and the fix.

## A key shows on screen instead of text

You see `home.subtitle` where text should be, and the console says the key is missing.

Neither the current language nor the default language has that key. Check for a typo in the key or the dictionary, and that the dictionary file you edited is the one your loader returns. A plural group shows its key when `count` is missing or isn't a number: `t("cart.items")` or `t("cart.items", { count: "2" })`. Pass a number.

If TypeScript didn't catch the typo, your loader's return type is `any` or `unknown`. See [Typed keys](docs/translations.md#typed-keys).

## Text shows in the default language

The current language lacks the key, so the default language's text is shown. This is the intended fallback and logs nothing. [Find untranslated keys](docs/translations.md#find-untranslated-keys) lists every gap.

## `t()` throws "requires successful initialization"

`t()` and `setLocale()` throw an `Error` until the manager has started, because there is no dictionary to use yet. Wait for `i18n.init()`, or `initializeBrowserI18n()` in a browser, before rendering. In a framework, render a loading state until the promise resolves, or start rendering after it.

## A locale is rejected with `RangeError`

`init()`, `setLocale()`, and `loadLocale()` throw a `RangeError` for a locale that isn't in `locales`. Matching ignores case and surrounding spaces, so `"PT"` finds `pt`, but `pt-BR` does not find `pt`. To find the closest supported locale for a visitor's language, use `matchLocale()`, which does map `pt-BR` to `pt`.

## Loading a locale fails with `I18nError`

Your loader threw or rejected, and the manager wrapped the failure in an `I18nError`:

- `error.code` is `"i18n.locale_load_failed"`.
- `error.locale` is the locale that failed.
- `error.cause` is what your loader threw, such as a network error.

The manager keeps its current language, and the next call tries the loader again, so a retry button can just call `setLocale()` again. In a browser, a saved or detected locale that fails falls back to the default; see [How the locale is chosen](docs/browser.md#how-the-locale-is-chosen).

To show these errors with `@codenhub/error`, register `i18nErrors`, which gives the code a safe message for users.

## A dictionary is rejected

`init()`, `setLocale()`, or `loadLocale()` throws a `TypeError` naming the problem when a dictionary doesn't fit the rules. These failures are not wrapped in `I18nError`, because they come from the dictionary's content rather than from loading it.

A dictionary must be a plain object, such as an object literal or parsed JSON, and:

- **Every value is a string or a group.** Numbers, booleans, `null`, arrays, functions, and objects such as `Date` or `Map` are rejected. Write numbers as text, and put lists under numbered or named keys.
- **Keys are well formed.** No empty segment, as in `"nav..home"` or a trailing dot; no control characters; no `__proto__` segment. A full key, with its dots, has at most 1,000 characters.
- **Two spellings never meet.** `{ "nav.home": "A", nav: { home: "B" } }` is rejected, because both are `nav.home`.
- **Each group appears once.** The same object used under two keys, or containing itself, is rejected. Copy it instead.
- **No getters or symbol keys.** Data from JSON never has these.
- **It fits the size limits.** See [Size limits](docs/translations.md#size-limits) to raise them.

An empty dictionary is accepted.

## `createI18n()` throws `TypeError`

The configuration is invalid. Check that:

- `locales` is a non-empty list with no duplicates, ignoring case, and each locale is ASCII letters and digits separated by hyphens, such as `pt-BR`.
- `defaultLocale` is one of `locales`.
- `loadLocale` is a function.
- The configuration is a plain object. A class instance, or an object that inherits its options, is rejected so that inherited values can't slip in.

`createLocaleRouting()` follows the same rules, and `localePrefix` must be `"always"`, `"except-default"`, or `"when-ambiguous"`. A problem in `pathnames` names its entry, such as `pathnames["/about"].pt`; [Mistakes it catches](docs/localized-urls.md#mistakes-it-catches) explains each one and its fix.

## TypeScript says `string` is not assignable to the locale type

```text
Type 'string' is not assignable to type '"en" | "pt"'.
```

The default locale lost its exact type, usually because the configuration lives in a separate variable. Mark it `as const`, as in [Typed keys](docs/translations.md#typed-keys). Locales are taken from `locales` only, so a plain `string` default can't widen them.

The same goes for `localePrefix` in a routing configuration kept in a variable, where the message names `"always" | "except-default" | "when-ambiguous"`: write `localePrefix: "except-default" as const`.

## A routing method throws `TypeError`

The path is not an absolute path or contains something routing refuses, such as `?`, `#`, `//`, or a dot segment. Pass `url.pathname` rather than the full URL. [Paths it accepts](docs/localized-urls.md#paths-it-accepts) has the full list.

## `localize()` throws `RangeError`: "would be read as"

The path `localize()` would build opens a different page, or the same page in another language. There are two usual causes:

- **You passed a URL, not a canonical path.** `localize("/sobre", "en")` takes `/sobre` as the canonical path of an English page. If `/sobre` is the Portuguese about page's URL, read it first: `localize(routing.parse("/sobre").pathname, "en")`.
- **A page the table doesn't list has another page's translated path.** With `localePrefix: "when-ambiguous"`, an English page at `/sobre` clashes with a Portuguese `/sobre`. Rename one of them, list the page in `pathnames`, or keep the prefix with `"except-default"`. [The risk you take on](docs/localized-urls.md#the-risk-you-take-on) explains why routing can't catch this sooner.

## `localize()` throws `RangeError`: "would be longer than 8,192 characters"

The path you passed is valid, but the locale prefix or the translated words make the result longer than `parse()` accepts. Shorten the path; a link that long is also likely to be cut off by servers and proxies.

## The browser binding warns or rejects

- **"The manager already has a browser binding"**: you called `initializeBrowserI18n()` twice for the same manager. Call `disconnect()` on the first binding, or keep one.
- **"Browser initialization was superseded"**: your code called `i18n.init()` while the binding was starting the manager. Let the binding start the manager.
- **"Browser observation requires a root"**: `observe: true` needs a `root` to watch.
- **Storage warning**: `localStorage` is unavailable, as in some private browsing modes. The visitor's choice is not saved; everything else works.
- **An element with `data-i18n` is skipped**: the element has child elements. Put the key on an element that contains only text, or translate it with `t()`.
- **"Skipping translation on <style> because the element can activate or hide translated content"**: a `data-i18n` key is on a `<script>`, `<style>`, `<noscript>`, or `<template>` element, whose text runs as code or decides what shows. Move the key to an ordinary element.
- **`data-i18n` text shows `{name}`**: HTML translation can't fill placeholders. Render that text with `t()` and pass the values.
- **A `data-i18n-href` attribute does nothing**: only text attributes can be translated. See [Translate HTML without a framework](docs/browser.md#translate-html-without-a-framework).

<!-- Source: docs/changelog/0.2.0.md -->

# 0.2.0

## Added

- **ICU messages.** `t("welcome", { name: "Ana" })` fills `{name}` in the text, and a text can format its values: `{amount, number}`, `{when, date, long}`, `{count, plural, =0 {…} one {# item} other {# items}}`, `{n, selectordinal, …}`, and `{gender, select, …}`. Formatting follows the language of the text. An argument without a value is left as written. See [Put values into text](docs/translations.md#put-values-into-text).
- **Formatters.** `formatNumber`, `formatDate`, `formatRelativeTime`, and `formatList` format values for the active locale with `Intl`.
- **`timeZone`** on `createI18n` sets the time zone of dates and times in messages and `formatDate`'s default, so a server renders the reader's day rather than its own. See [Time zones](docs/translations.md#time-zones).
- **`has(key)`** tells whether a key exists, without calling `onMissingKey` or warning, and narrows a key built at runtime to one `t()` accepts. See [Typed keys](docs/translations.md#typed-keys).
- **Regional fallback.** A key missing from `pt-BR` comes from `pt` before the default locale, when both are configured. See [Regional variants](docs/translations.md#regional-variants).
- **Plurals.** A key can hold CLDR plural forms, such as `{ one: "{count} item", other: "{count} items" }`, and `t(key, { count })` picks the right one for the language with `Intl.PluralRules`. See [Plurals](docs/translations.md#plurals).
- **Typed keys.** Keys are inferred from what `loadLocale` returns, so a misspelled key fails to compile. `TranslationKey<typeof en>` names the key type for your own helpers. With an `as const` dictionary, `t()` also requires each message's values with the type its format needs. See [Typed values](docs/translations.md#typed-values).
- **`t`**, a short alias of `translate`, and every manager method keeps working when pulled off the manager, as in `export const { t } = i18n`.
- **`matchLocale(preferences)`** picks the best supported locale for a list of language tags, such as `pt` for `pt-BR`. Servers can now resolve `Accept-Language` the way the browser binding resolves `navigator.languages`.
- **`subscribe(listener)`** calls `listener` after every language change and returns a function that stops it, for framework stores such as React's `useSyncExternalStore`.
- **`onMissingKey(key, locale)`** receives every key missing from all dictionaries, in place of the default warning. Throw from it to fail tests on missing keys.
- **Translated paths.** `pathnames` on `createLocaleRouting` gives each locale its own words, so the about page can be `/pt/sobre`: `{ "/about": { pt: "/sobre" }, "/products/:id": { pt: "/produtos/:id" } }`. `parse()` reads a translated path back to the canonical path your app routes on, and `localize()` builds it. Mistakes in the table, such as two pages with the same path in one language, throw when routing is created. A last `:name*` segment matches the rest of the path, such as `/docs/:path*`. See [Translate paths](docs/localized-urls.md#translate-paths).
- **`localePrefix: "when-ambiguous"`** drops the locale prefix from a translated path whose words belong to one language only, so the about page can be just `/sobre`, while `/pt` and `/pt/blog` keep theirs. `localize()` throws a `RangeError` when the path it builds would open another page, such as an English page the table doesn't list at `/sobre`. See [Drop the prefix](docs/localized-urls.md#drop-the-prefix).
- **A default text direction.** `getLocaleDirection` is now optional; without it, Arabic, Hebrew, Persian, Urdu, and other right-to-left languages and scripts get `rtl`.
- Guides for writing translations, browser apps, server rendering and static builds, localized URLs, and troubleshooting, and a generated API reference.

## Changed

- **Breaking.** `t()` and `translate()` return a `string`, never `undefined`. A key missing from every dictionary returns the key, trimmed, such as `"home.subtitle"`, so the gap is visible on screen.
- **Breaking.** `init()` resolves `void` instead of `true` or `false`, like `setLocale()`. Read `i18n.locale` or `i18n.isReady` to see what was applied.
- **Breaking, types only.** When `loadLocale` returns a typed dictionary, `t()` accepts only its keys, so a key built at runtime, such as ``t(`errors.${code}`)``, no longer compiles.
- **Breaking, types only.** Only `locales` sets the locale type; `defaultLocale` and a callback parameter typed `string`, such as `loadLocale: (locale: string) => …`, no longer widen it. A configuration kept in a variable without `as const` on `defaultLocale` is now a compile error, where before it silently accepted any string as a locale.

- **Breaking.** `createLocaleRouting` takes `localePrefix` instead of `prefixDefaultLocale`: `"always"` for `true`, `"except-default"` for `false`, or the new `"when-ambiguous"`.
- **Breaking.** `localize()` and `localizeAll()` throw a `RangeError` when the path they build would be longer than 8,192 characters, where they returned a path that `parse()` rejects.
- **Breaking.** `parse()` returns `undefined` for a path no link leads to, such as `/pt/en/about`, so it can answer 404 and `localize(parse(path).pathname, …)` never throws.
- **Breaking.** An apostrophe before `{` or `}`, and `''`, now quote text, as ICU does, and a text with invalid ICU syntax, such as an unclosed `{`, logs a warning. A literal brace is written `'{'`.

## Migrating from 0.1.0

1. **Remove the fallbacks after `t()`.** `t(key) ?? ""` and `t(key) ?? key` still work but are no longer needed. Code that checked `t(key) === undefined` to detect a missing key never matches now; use `onMissingKey` instead.
2. **Stop reading `init()`'s result.** Replace `if (await i18n.init()) { … }` with `await i18n.init(); if (i18n.isReady) { … }`. A stale `init()`, superseded by a later call, resolves without applying, so compare `i18n.locale` when the outcome matters.
3. **Check or cast keys built at runtime.** Where TypeScript now rejects a computed key, check it with `i18n.has(key)`, or, when you know it exists, cast it with `as TranslationKey<typeof en>`, as in [Typed keys](docs/translations.md#typed-keys).
4. **Mark a separate configuration's default locale `as const`.** Fix `Type 'string' is not assignable to type '"en" | "pt"'` at `createI18n(config)` or `createLocaleRouting(config)` with `defaultLocale: "en" as const`.
5. **Replace `prefixDefaultLocale`.** In `createLocaleRouting`, `prefixDefaultLocale: true` becomes `localePrefix: "always"`, and `false` becomes `localePrefix: "except-default"`. In a configuration kept in a variable, add `as const`.
6. **Quote literal braces.** A text that shows `{` or `}` without meaning a value, such as a code sample, needs `'{'` and `'}'`. Run the message check in [Find untranslated keys](docs/translations.md#find-untranslated-keys) to find them.
7. **Optionally, drop `getLocaleDirection: () => "ltr"`.** The default gets right-to-left languages right; keep the option only to override it.
