first commit

This commit is contained in:
2026-07-12 15:55:18 +05:30
commit ee98026cc5
404 changed files with 44522 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
# @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.