# @wrnexus/i18n

> Per-request translations plus locale-aware number, date, and currency formatting for WrNexus apps.

Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.

## Overview

`@wrnexus/i18n` loads locale files from `app/locales/<lang>.json`, resolves the
active language for each request (cookie → `Accept-Language` → default), and
builds a `t(key, params)` translator used both in server code and in `.wrn`
views. It also ships Intl-based formatting helpers and a tiny client runtime that
wires up a language switcher. Translation lookup, language resolution, and HTML
marker rewriting run server-side; only the small `I18N_RUNTIME` snippet runs in
the browser.

## Installation

```bash
bun add @wrnexus/i18n
```

> Private package — the machine must be authenticated to the `wrnexus` npm org
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).

## API

### Loading & resolving

| Export        | Signature                                                                                          | Description                                                                                                                             |
| ------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `loadLocales` | `(dir: string) => Record<string, Messages>`                                                        | Reads every `<lang>.json` in `dir` into a `{ lang: messages }` map. Missing dir → `{}`; a bad file is warned and skipped.               |
| `resolveI18n` | `(messages: Record<string, Messages>, config?: I18nConfig) => ResolvedI18n`                        | Merges loaded messages + config into a resolved bundle (default lang, supported langs, messages).                                       |
| `resolveLang` | `(i18n: ResolvedI18n, cookieValue: string \| undefined, acceptLanguage: string \| null) => string` | Picks the active language: matching cookie → best `Accept-Language` tag (falls back to base tag, e.g. `en-US` → `en`) → `i18n.default`. |
| `makeT`       | `(i18n: ResolvedI18n, lang: string) => TFunction`                                                  | Builds a translator resolving current language → default → the key itself, with `{param}` interpolation.                                |

### Types & constants

| Export         | Kind        | Notes                                                                          |
| -------------- | ----------- | ------------------------------------------------------------------------------ |
| `Messages`     | `type`      | `Record<string, unknown>` — a locale's messages (supports nested/dotted keys). |
| `I18nConfig`   | `interface` | `{ default?: string; locales?: string[] }`.                                    |
| `ResolvedI18n` | `interface` | `{ default: string; langs: string[]; messages: Record<string, Messages> }`.    |
| `LANG_COOKIE`  | `const`     | `"wire-lang"` — the cookie the language is read from / written to.             |
| `I18N_JS_HREF` | `const`     | `"/__wrnexus/i18n.js"` — URL the client runtime is served at.                  |

### HTML & client runtime

| Export           | Signature                                      | Description                                                                                                                                                                                                                 |
| ---------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `translateHtml`  | `(html: string, t: TFunction) => string`       | Rewrites markers in rendered HTML: `t:<attr>="key"` → `<attr>="<translation>"` (attribute-escaped) and `<tag data-t="key">…</tag>` → element text becomes the translation (HTML-escaped). No-op unless a marker is present. |
| `renderI18nData` | `(i18n: ResolvedI18n, lang: string) => string` | JS snippet setting `window.__wireI18n = { lang, langs, default }` for the client switcher.                                                                                                                                  |
| `I18N_RUNTIME`   | `const string`                                 | Browser IIFE that binds `[data-wire-lang-set="es"]` clicks and `select[data-wire-lang]` changes to set the `wire-lang` cookie and reload. Exposes `window.__wireLang.set(lang)`.                                            |

### Formatting helpers (re-exported from `./format.ts`)

| Export               | Signature                                                                                         | Example                                        |
| -------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| `formatNumber`       | `(value: number, lang: string, options?: Intl.NumberFormatOptions) => string`                     | `1234.5 → "1,234.5"`                           |
| `formatCurrency`     | `(value: number, currency: string, lang: string) => string`                                       | `9.99, "USD" → "$9.99"`                        |
| `formatDate`         | `(value: Date \| number \| string, lang: string, options?: Intl.DateTimeFormatOptions) => string` | defaults to `{ dateStyle: "medium" }`          |
| `formatRelativeTime` | `(value: number, unit: Intl.RelativeTimeFormatUnit, lang: string) => string`                      | `-3, "day" → "3 days ago"` (`numeric: "auto"`) |
| `plural`             | `(count: number, forms: Partial<Record<Intl.LDMLPluralRule, string>>, lang: string) => string`    | picks CLDR form; `#` is replaced by `count`    |

## Usage

### Server: load, resolve, translate

```ts
import {
  loadLocales,
  resolveI18n,
  resolveLang,
  makeT,
  translateHtml,
  LANG_COOKIE,
} from "@wrnexus/i18n";

// app/locales/en.json, app/locales/es.json
const messages = loadLocales("app/locales");
const i18n = resolveI18n(messages, { default: "en", locales: ["en", "es"] });

// Per request:
const lang = resolveLang(i18n, req.cookies?.[LANG_COOKIE], req.headers.get("accept-language"));
const t = makeT(i18n, lang);

t("nav.home"); // dotted key → "Home"
t("greeting", { name: "Ada" }); // "Hello, {name}" → "Hello, Ada"

// After rendering a .wrn view, resolve translation markers in the HTML:
const finalHtml = translateHtml(renderedHtml, t);
```

`app/locales/en.json`:

```json
{
  "nav": { "home": "Home" },
  "greeting": "Hello, {name}"
}
```

### Views: translation markers

```html
<h1 data-t="nav.home">Home</h1>
<input t:placeholder="search.placeholder" />
```

`translateHtml` replaces the element text for `data-t` and the attribute value for
any `t:<attr>` (e.g. `t:placeholder`, `t:aria-label`).

### Client: language switcher

```ts
import { renderI18nData, I18N_RUNTIME, I18N_JS_HREF } from "@wrnexus/i18n";

// In the document <head>:
const head = `
  <script>${renderI18nData(i18n, lang)}</script>
  <script src="${I18N_JS_HREF}"></script>
`;

// Serve I18N_RUNTIME at I18N_JS_HREF; then in markup:
// <button data-wire-lang-set="es">Español</button>
// <select data-wire-lang>…</select>
```

### Formatting

```ts
import {
  formatNumber,
  formatCurrency,
  formatDate,
  formatRelativeTime,
  plural,
} from "@wrnexus/i18n";

formatNumber(1234.5, lang); // "1,234.5"
formatCurrency(9.99, "USD", lang); // "$9.99"
formatDate(Date.now(), lang); // "Jul 4, 2026"
formatRelativeTime(-3, "day", lang); // "3 days ago"
plural(2, { one: "# item", other: "# items" }, lang); // "2 items"
```

## Configuration

`resolveI18n` accepts an `I18nConfig`:

- `default` — fallback language; used when nothing else matches. Ignored if it has
  no loaded messages, in which case the first supported language is used.
- `locales` — explicit supported-language list; defaults to the loaded locale names.

Language resolution order at request time (`resolveLang`): a supported `wire-lang`
cookie value → the first matching `Accept-Language` tag (or its base subtag) → the
resolved default.

## Requirements / Notes

- **Bun-only.** Locale loading uses `node:fs` (`existsSync`, `readdirSync`,
  `readFileSync`) and `node:path`; formatting relies on the platform `Intl` APIs.
- Works with [`@wrnexus/core`](../core) — `TFunction` (the `t(key, params)` type)
  comes from core, and the resolved translator is exposed as `ctx.t` / `ctx.lang`
  in request handling.
- Nested message objects are supported: keys are looked up whole first, then split
  on `.` to walk the object tree.
