first commit
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@wrnexus/i18n",
|
||||
"version": "0.2.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Locale-aware formatting helpers (Intl-based) + pluralization. Pair with the
|
||||
* request language (`ctx.lang`) so numbers, dates, and currencies render right
|
||||
* for each user.
|
||||
*/
|
||||
|
||||
/** Format a number for a locale (e.g. 1234.5 → "1,234.5"). */
|
||||
export function formatNumber(
|
||||
value: number,
|
||||
lang: string,
|
||||
options?: Intl.NumberFormatOptions,
|
||||
): string {
|
||||
return new Intl.NumberFormat(lang || undefined, options).format(value);
|
||||
}
|
||||
|
||||
/** Format a currency amount (e.g. 9.99, "USD" → "$9.99"). */
|
||||
export function formatCurrency(value: number, currency: string, lang: string): string {
|
||||
return new Intl.NumberFormat(lang || undefined, { style: "currency", currency }).format(value);
|
||||
}
|
||||
|
||||
/** Format a date/timestamp for a locale. */
|
||||
export function formatDate(
|
||||
value: Date | number | string,
|
||||
lang: string,
|
||||
options: Intl.DateTimeFormatOptions = { dateStyle: "medium" },
|
||||
): string {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return new Intl.DateTimeFormat(lang || undefined, options).format(date);
|
||||
}
|
||||
|
||||
/** Relative time, e.g. -3 days → "3 days ago" (localized). */
|
||||
export function formatRelativeTime(
|
||||
value: number,
|
||||
unit: Intl.RelativeTimeFormatUnit,
|
||||
lang: string,
|
||||
): string {
|
||||
return new Intl.RelativeTimeFormat(lang || undefined, { numeric: "auto" }).format(value, unit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a plural form for `count` in `lang` using CLDR rules, e.g.
|
||||
* `plural(n, { one: "1 item", other: "# items" }, lang)` — "#" is replaced by n.
|
||||
*/
|
||||
export function plural(
|
||||
count: number,
|
||||
forms: Partial<Record<Intl.LDMLPluralRule, string>>,
|
||||
lang: string,
|
||||
): string {
|
||||
const rule = new Intl.PluralRules(lang || undefined).select(count);
|
||||
const template = forms[rule] ?? forms.other ?? "";
|
||||
return template.replace(/#/g, String(count));
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* @wrnexus/i18n — translations for pages and API responses.
|
||||
*
|
||||
* Locales live in `app/locales/<lang>.json`. Per request the active language is
|
||||
* resolved from the `wire-lang` cookie, then Accept-Language, then the default.
|
||||
* `ctx.t(key, params)` translates on the server; in `.wrn` views `{t:key}` and
|
||||
* `t:attr="key"` markers are resolved by `translateHtml` before the HTML is sent.
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { TFunction } from "@wrnexus/core";
|
||||
|
||||
export { formatNumber, formatCurrency, formatDate, formatRelativeTime, plural } from "./format.ts";
|
||||
|
||||
export type Messages = Record<string, unknown>;
|
||||
|
||||
/** Load `<dir>/<lang>.json` files into a `{ lang: messages }` map. */
|
||||
export function loadLocales(dir: string): Record<string, Messages> {
|
||||
const out: Record<string, Messages> = {};
|
||||
if (!existsSync(dir)) return out;
|
||||
for (const file of readdirSync(dir)) {
|
||||
if (!file.endsWith(".json")) continue;
|
||||
const lang = file.replace(/\.json$/, "");
|
||||
try {
|
||||
out[lang] = JSON.parse(readFileSync(join(dir, file), "utf8")) as Messages;
|
||||
} catch (err) {
|
||||
console.warn(`[wrnexus] failed to load locale '${lang}'`, err);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface I18nConfig {
|
||||
/** Default language, used as the fallback and when nothing else matches. */
|
||||
default?: string;
|
||||
/** Explicit set of supported languages (defaults to the loaded locale names). */
|
||||
locales?: string[];
|
||||
}
|
||||
|
||||
export interface ResolvedI18n {
|
||||
default: string;
|
||||
langs: string[];
|
||||
messages: Record<string, Messages>;
|
||||
}
|
||||
|
||||
export const LANG_COOKIE = "wire-lang";
|
||||
export const I18N_JS_HREF = "/__wrnexus/i18n.js";
|
||||
|
||||
/** Merge loaded locale messages + config into a resolved i18n bundle. */
|
||||
export function resolveI18n(messages: Record<string, Messages>, config?: I18nConfig): ResolvedI18n {
|
||||
const langs = config?.locales ?? Object.keys(messages);
|
||||
const fallback = langs[0] ?? "en";
|
||||
const def = config?.default && messages[config.default] ? config.default : fallback;
|
||||
return { default: def, langs, messages };
|
||||
}
|
||||
|
||||
/** Look up a possibly-dotted key in a messages object. */
|
||||
function lookup(messages: Messages | undefined, key: string): string | undefined {
|
||||
if (!messages) return undefined;
|
||||
if (key in messages && typeof messages[key] === "string") return messages[key] as string;
|
||||
let node: unknown = messages;
|
||||
for (const part of key.split(".")) {
|
||||
if (node && typeof node === "object" && part in (node as Record<string, unknown>)) {
|
||||
node = (node as Record<string, unknown>)[part];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return typeof node === "string" ? node : undefined;
|
||||
}
|
||||
|
||||
/** Interpolate `{param}` placeholders in a message. */
|
||||
function interpolate(message: string, params?: Record<string, string | number>): string {
|
||||
if (!params) return message;
|
||||
return message.replace(/\{(\w+)\}/g, (_m, name: string) =>
|
||||
name in params ? String(params[name]) : `{${name}}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a `t()` for a language: current → default → the key itself. */
|
||||
export function makeT(i18n: ResolvedI18n, lang: string): TFunction {
|
||||
return (key, params) => {
|
||||
const message =
|
||||
lookup(i18n.messages[lang], key) ?? lookup(i18n.messages[i18n.default], key) ?? key;
|
||||
return interpolate(message, params);
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve the active language from a cookie, Accept-Language, then default. */
|
||||
export function resolveLang(
|
||||
i18n: ResolvedI18n,
|
||||
cookieValue: string | undefined,
|
||||
acceptLanguage: string | null,
|
||||
): string {
|
||||
if (cookieValue && i18n.langs.includes(cookieValue)) return cookieValue;
|
||||
for (const part of (acceptLanguage ?? "").split(",")) {
|
||||
const tag = part.split(";")[0]!.trim().toLowerCase();
|
||||
if (!tag) continue;
|
||||
if (i18n.langs.includes(tag)) return tag;
|
||||
const base = tag.split("-")[0]!;
|
||||
if (i18n.langs.includes(base)) return base;
|
||||
}
|
||||
return i18n.default;
|
||||
}
|
||||
|
||||
function attrEscape(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function htmlEscape(value: string): string {
|
||||
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve translation markers in rendered HTML:
|
||||
* t:<attr>="key" → <attr>="<translation>" (e.g. t:placeholder, t:aria-label)
|
||||
* <tag data-t="key">…</tag> → element text becomes the translation
|
||||
* Only runs when the HTML actually contains a marker.
|
||||
*/
|
||||
export function translateHtml(html: string, t: TFunction): string {
|
||||
if (!html.includes("data-t=") && !html.includes("t:")) return html;
|
||||
|
||||
let out = html.replace(
|
||||
/\bt:([A-Za-z][A-Za-z0-9:_-]*)="([^"]*)"/g,
|
||||
(_m, attr: string, key: string) => `${attr}="${attrEscape(t(key))}"`,
|
||||
);
|
||||
|
||||
out = out.replace(
|
||||
/<([A-Za-z][A-Za-z0-9-]*)\b([^>]*\bdata-t="([^"]*)"[^>]*)>([\s\S]*?)<\/\1>/g,
|
||||
(_m, tag: string, attrs: string, key: string) =>
|
||||
`<${tag}${attrs}>${htmlEscape(t(key))}</${tag}>`,
|
||||
);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `window.__wireI18n = { lang, langs }` for the client language switcher. */
|
||||
export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
|
||||
return `window.__wireI18n=${JSON.stringify({ lang, langs: i18n.langs, default: i18n.default })};`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client runtime: binds `[data-wire-lang-set="es"]` elements to set the
|
||||
* `wire-lang` cookie and reload, so the server re-renders in the new language.
|
||||
*/
|
||||
export const I18N_RUNTIME = String.raw`
|
||||
(function () {
|
||||
var COOKIE = "${LANG_COOKIE}";
|
||||
function set(lang) {
|
||||
document.cookie = COOKIE + "=" + encodeURIComponent(lang) + ";path=/;max-age=31536000;samesite=lax";
|
||||
location.reload();
|
||||
}
|
||||
function bind(root) {
|
||||
(root || document).querySelectorAll("[data-wire-lang-set]").forEach(function (n) {
|
||||
if (n.__wireLangBound) return; n.__wireLangBound = 1;
|
||||
n.addEventListener("click", function () { set(n.getAttribute("data-wire-lang-set")); });
|
||||
});
|
||||
(root || document).querySelectorAll("select[data-wire-lang]").forEach(function (n) {
|
||||
if (n.__wireLangBound) return; n.__wireLangBound = 1;
|
||||
n.addEventListener("change", function () { set(n.value); });
|
||||
});
|
||||
}
|
||||
window.__wireLang = { set: set };
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", function () { bind(document); });
|
||||
else bind(document);
|
||||
})();
|
||||
`.trim();
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import {
|
||||
formatNumber,
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
formatRelativeTime,
|
||||
plural,
|
||||
} from "../src/index.ts";
|
||||
|
||||
test("formatNumber is locale-aware", () => {
|
||||
expect(formatNumber(1234.5, "en-US")).toBe("1,234.5");
|
||||
expect(formatNumber(1234.5, "de-DE")).toBe("1.234,5");
|
||||
});
|
||||
|
||||
test("formatCurrency", () => {
|
||||
expect(formatCurrency(9.99, "USD", "en-US")).toBe("$9.99");
|
||||
});
|
||||
|
||||
test("formatDate", () => {
|
||||
const out = formatDate("2026-01-15T00:00:00Z", "en-US", { dateStyle: "medium", timeZone: "UTC" });
|
||||
expect(out).toContain("2026");
|
||||
expect(out).toContain("Jan");
|
||||
});
|
||||
|
||||
test("formatRelativeTime", () => {
|
||||
expect(formatRelativeTime(-1, "day", "en-US")).toBe("yesterday");
|
||||
expect(formatRelativeTime(3, "hour", "en-US")).toBe("in 3 hours");
|
||||
});
|
||||
|
||||
test("plural picks the CLDR form and fills #", () => {
|
||||
const forms = { one: "# item", other: "# items" };
|
||||
expect(plural(1, forms, "en")).toBe("1 item");
|
||||
expect(plural(5, forms, "en")).toBe("5 items");
|
||||
expect(plural(0, forms, "en")).toBe("0 items");
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { resolveI18n, makeT, resolveLang, translateHtml } from "../src/index.ts";
|
||||
|
||||
const i18n = resolveI18n(
|
||||
{
|
||||
en: { hi: "Hi {name}", nav: { home: "Home" }, ph: "Email" },
|
||||
es: { hi: "Hola {name}", nav: { home: "Inicio" } },
|
||||
},
|
||||
{ default: "en" },
|
||||
);
|
||||
|
||||
test("makeT interpolates params and nested keys", () => {
|
||||
const t = makeT(i18n, "es");
|
||||
expect(t("hi", { name: "Ana" })).toBe("Hola Ana");
|
||||
expect(t("nav.home")).toBe("Inicio");
|
||||
});
|
||||
|
||||
test("makeT falls back to default language, then the key", () => {
|
||||
const t = makeT(i18n, "es");
|
||||
expect(t("ph")).toBe("Email"); // missing in es → en
|
||||
expect(t("nope")).toBe("nope"); // missing everywhere → key
|
||||
});
|
||||
|
||||
test("resolveLang: cookie → Accept-Language → default", () => {
|
||||
expect(resolveLang(i18n, "es", null)).toBe("es");
|
||||
expect(resolveLang(i18n, undefined, "fr,es;q=0.8")).toBe("es");
|
||||
expect(resolveLang(i18n, "xx", "de")).toBe("en"); // invalid cookie + unsupported header
|
||||
});
|
||||
|
||||
test("translateHtml resolves data-t text and t:attr attributes", () => {
|
||||
const t = makeT(i18n, "en");
|
||||
const out = translateHtml('<input t:placeholder="ph"><span data-t="nav.home"></span>', t);
|
||||
expect(out).toContain('placeholder="Email"');
|
||||
expect(out).toContain('<span data-t="nav.home">Home</span>');
|
||||
});
|
||||
|
||||
test("translateHtml is a no-op without markers", () => {
|
||||
const t = makeT(i18n, "en");
|
||||
expect(translateHtml("<p>plain</p>", t)).toBe("<p>plain</p>");
|
||||
});
|
||||
Reference in New Issue
Block a user