@wrnexus/i18n
Translation loading, locale resolution, and Intl formatting.
Private registry access required
This package is not available from the public npm registry. After WorkRoot approves access and supplies private registry instructions, install the release-aligned package:
bun add @wrnexus/i18n@0.2.45Request preview access. Never put registry tokens in source control.
Per-request translations plus locale-aware number, date, and currency formatting for WRNexusJS apps.
Part of the WRNexusJS 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.
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
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:
{
"nav": { "home": "Home" },
"greeting": "Hello, {name}"
}
Views: translation markers
<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
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
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 haslocales— explicit supported-language list; defaults to the loaded locale names.
no loaded messages, in which case the first supported language is used.
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, - Works with [
@wrnexus/core](../core) —TFunction(thet(key, params)type) - Nested message objects are supported: keys are looked up whole first, then split
readFileSync) and node:path; formatting relies on the platform Intl APIs.
comes from core, and the resolved translator is exposed as ctx.t / ctx.lang in request handling.
on . to walk the object tree.
Complete TypeScript API
This declaration comes from the exact installed package and lists its exported functions, classes, interfaces, and types.
import { TFunction } from '@wrnexus/core';
/**
* 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"). */
declare function formatNumber(value: number, lang: string, options?: Intl.NumberFormatOptions): string;
/** Format a currency amount (e.g. 9.99, "USD" → "$9.99"). */
declare function formatCurrency(value: number, currency: string, lang: string): string;
/** Format a date/timestamp for a locale. */
declare function formatDate(value: Date | number | string, lang: string, options?: Intl.DateTimeFormatOptions): string;
/** Relative time, e.g. -3 days → "3 days ago" (localized). */
declare function formatRelativeTime(value: number, unit: Intl.RelativeTimeFormatUnit, lang: string): string;
/**
* 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.
*/
declare function plural(count: number, forms: Partial<Record<Intl.LDMLPluralRule, string>>, lang: string): string;
/**
* @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.
*/
type Messages = Record<string, unknown>;
/** Load `<dir>/<lang>.json` files into a `{ lang: messages }` map. */
declare function loadLocales(dir: string): Record<string, Messages>;
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[];
}
interface ResolvedI18n {
default: string;
langs: string[];
messages: Record<string, Messages>;
}
declare const LANG_COOKIE = "wire-lang";
declare const I18N_JS_HREF = "/__wrnexus/i18n.js";
/** Merge loaded locale messages + config into a resolved i18n bundle. */
declare function resolveI18n(messages: Record<string, Messages>, config?: I18nConfig): ResolvedI18n;
/** Build a `t()` for a language: current → default → the key itself. */
declare function makeT(i18n: ResolvedI18n, lang: string): TFunction;
/** Resolve the active language from a cookie, Accept-Language, then default. */
declare function resolveLang(i18n: ResolvedI18n, cookieValue: string | undefined, acceptLanguage: string | null): string;
/**
* 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.
*/
declare function translateHtml(html: string, t: TFunction): string;
/** `window.__wireI18n = { lang, langs }` for the client language switcher. */
declare function renderI18nData(i18n: ResolvedI18n, lang: string): string;
/**
* 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.
*/
declare const I18N_RUNTIME: string;
export { I18N_JS_HREF, I18N_RUNTIME, type I18nConfig, LANG_COOKIE, type Messages, type ResolvedI18n, formatCurrency, formatDate, formatNumber, formatRelativeTime, loadLocales, makeT, plural, renderI18nData, resolveI18n, resolveLang, translateHtml };
Examples
Examples are taken from this package's installed documentation and must be evaluated with its requirements and stability notes.
Server: load, resolve, translate
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);Server: load, resolve, translate
{
"nav": { "home": "Home" },
"greeting": "Hello, {name}"
}Views: translation markers
<h1 data-t="nav.home">Home</h1>
<input t:placeholder="search.placeholder" />Client: language switcher
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
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"