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.
+13
View File
@@ -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:*"
}
}
+52
View File
@@ -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));
}
+172
View File
@@ -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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function htmlEscape(value: string): string {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/**
* 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();
+35
View File
@@ -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");
});
+40
View File
@@ -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>");
});