
# Internationalization

Every engine already formats numbers, dates, lists, and plurals for every locale through `Intl`, so `@zerolith/i18n` ships no locale data and no message runtime. It does three things around that:

1. It decides which locale to use, and keeps it as a signal.
2. It wraps the `Intl` formatters so they follow that signal.
3. It compiles message catalogs ahead of time into plain functions, one module per locale, so a locale is one chunk and formatting a message is a string concatenation.

## The locale

`createLocale` negotiates what was requested, an `Accept-Language` header on the server or `navigator.languages` in the browser, against the locales the application supports. The result is a signal, with the writing direction derived from it:

```ts
import { computed } from "zerolith";
import { createLocale, formatters } from "@zerolith/i18n";

const locale = createLocale({
    supported: ["en", "de", "ar"],
    // An Accept-Language header on the server,
    // navigator.languages in the browser:
    initial: request.headers.get("accept-language")
});
const format = formatters(locale.locale);
const euros = { style: "currency", currency: "EUR" } as const;
const total = computed(() => format.number(1234.5, euros));

console.log(locale.locale.get());       // "de", for "de-AT,de;q=0.9,en;q=0.5"
console.log(total.get());               // "1.234,50 €"

locale.set("ar-EG");
console.log(locale.direction.get());    // "rtl"
```

Negotiation follows RFC 4647 lookup. Each requested range is tried in order of quality, and the first that finds a match wins. With `["en", "de", "pt-BR"]` supported and `en` as the fallback:

| Requested | Result | Why |
| --- | --- | --- |
| `de` | `de` | An exact match |
| `de-AT` | `de` | The request with its subtags dropped |
| `pt` | `pt-BR` | A more specific supported locale of the same language |
| `fr-CA,de;q=0.8` | `de` | No match for `fr-CA`, so the next range by quality |
| `fr` or `*` | `en` | Nothing matches, so the fallback |

The fallback is the first supported locale unless `fallback` names another, and `set` negotiates the same way, so the locale is always one the application supports. The signal is named `locale`, so hot replacement keeps it across an edit. `applyLocale(locale)` keeps `<html lang dir>` in step with it and returns the effect's disposer.

> [!TIP]
> A currency belongs to the amount, not to the reader. A price in euros stays in euros for every locale, and the locale only decides how it is written: `1.234,50 €` in German, `€1,234.50` in English. Take the currency from the data, never from the locale.

## Formatters

`formatters(locale.locale)` wraps the `Intl` formatters. Each call reads the locale signal, so a `computed` value or a template binding that formats updates when the locale changes, and nothing else does. Instances are cached per locale and options, so formatting in a long list builds each formatter once.

| Call | Wraps |
| --- | --- |
| `number(value, options)` | `Intl.NumberFormat`, for numbers, percentages, units, and currency |
| `date(value, options)` | `Intl.DateTimeFormat`, for a `Date` or a timestamp |
| `relative(value, unit, options)` | `Intl.RelativeTimeFormat`, as in `relative(-1, "day")` |
| `list(values, options)` | `Intl.ListFormat`, joining with the locale's "and" or "or" |
| `plural(value, options)` | `Intl.PluralRules`, returning the category, such as `"one"` or `"other"` |

`formatters` also takes a fixed tag, as `formatters("de")`, for a server that renders each request in one locale.

## Messages

Catalogs are JSON files, one per locale, whose values are MessageFormat 2, the Unicode standard that succeeds ICU MessageFormat: plain text with placeholders, functions such as `:number` and `:datetime` with their options, and `.match` for plural and other selection. This is `messages/en.json`:

```json wide
{
    "notes-count": ".input {$count :number}\n.match $count\n0 {{No notes yet}}\none {{One note}}\n* {{{$count} notes}}",
    "saved-at": "Saved {$when :datetime dateLength=medium timePrecision=minute hour12=false timeZone=UTC}",
    "welcome": "Welcome back, {$name}!"
}
```

Compile the folder into modules:

```sh
npx zerolith-i18n messages --out src/messages
```

The command compiles every `<locale>.json` in the folder into `<locale>.ts`, and writes an `index.ts` with the list of `locales` and the `loaders` that import one locale at a time. It compiles every catalog before it writes a file, so a catalog it cannot compile, or a file named other than a language tag, fails the command, naming the file, and leaves the output as it was. `--js` writes JavaScript with JSDoc types instead, for buildless projects. A key that some locales lack is reported, and `--strict` makes it fail the build.

Each message becomes a function named from its key in camel case, with an `args` object typed from its placeholders. A key that is a reserved word, such as `delete` or `new`, keeps its name as an export, and `locale` is refused, since every compiled module exports the locale it holds. A placeholder such as `{$first-name}` is an argument named `first-name`:

| A placeholder formatted with | Takes |
| --- | --- |
| `:number`, `:integer`, `:percent`, `:currency`, or `:offset` | A `number` |
| `:date`, `:time`, or `:datetime` | A `Date` or a timestamp |
| Anything else, or no function | A string or a number |

A formatter whose options are all literals, as in `{$price :currency currency=EUR}`, is created once per locale module, on first use, and shared by every message that formats the same way. Options that read an argument, such as `currency=$code`, find their formatter in a cache keyed by the options.

The application imports the functions it needs:

```ts
import { notesCount, savedAt, welcome } from "./messages/en.ts";

const saved = Date.UTC(2026, 9, 28, 9, 30);

console.log(notesCount({ count: 0 }));        // "No notes yet"
console.log(notesCount({ count: 1 }));        // "One note"
console.log(notesCount({ count: 12 }));       // "12 notes"
console.log(savedAt({ when: saved }));        // "Saved Oct 28, 2026, 09:30"
console.log(welcome({ name: "Ada" }));        // "Welcome back, Ada!"
```

Selection follows the specification: an exact numeric key beats a plural category, which beats `*`, and with several selectors the keys are compared left to right.

> [!NOTE]
> The compiler covers the subset catalogs use: text and placeholders, quoted and unquoted literals, `.input` and `.local` declarations, `.match` with numeric and string selectors, and the default functions `:string`, `:number`, `:integer`, `:percent`, `:currency`, `:offset`, `:date`, `:time`, and `:datetime`. Markup such as `{#b}` is accepted and formats to nothing. Anything outside that subset fails at compile time with the message's key, never at run time with a fallback string.

## Loading one locale

The messages are a resource over the locale signal. Switching locales loads the next module, and everything that reads the messages updates once it arrives:

```ts
import { computed, resource } from "zerolith";
import { createLocale } from "@zerolith/i18n";
import { loaders, locales } from "./messages/index.ts";

const locale = createLocale({
    supported: locales,
    initial: navigator.languages
});
const messages = resource({
    params: () => locale.locale.get(),
    loader: async ({ params }) => loaders[params]?.()
});
const heading = computed(() => messages.get()?.notesCount({ count: 3 }) ?? "");
// Once de.ts has loaded, heading.get() is "3 Notizen".

// This loads en.ts, after which heading.get() is "3 notes".
locale.set("en");
```

While the next locale loads, the resource keeps the messages it has, so the page never shows an empty heading in between. Nothing about the first locale stays in memory except what the engine caches for `Intl`.

> [!WARNING]
> Each locale module is independent, so a bundler splits it into its own chunk and an import map serves it as its own file. Import a locale module only through `loaders`: with `@zerolith/vite`, a build that imports one statically fails with `lazy_module_not_split`, since that would pull it into the importing chunk.

## Locales in the URL

In a routed application the locale can be a route segment: declare paths such as `/:locale/notes/:id`, pass `params.locale` to `set`, and build links with `router.href` as usual. Negotiating on the server and redirecting a bare `/` to `/de/` keeps the URL the single source of truth, and every page can be shared in the language it was read in.

