@wrnexus/i18n
Translation loading, locale resolution, and Intl formatting.
bun add @wrnexus/i18n@0.2.15Per-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.
Installation
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 is generated from the exact published 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
Copy-ready examples taken from this package's published documentation.
Example 1
bun add @wrnexus/i18nExample 2
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);Example 3
{
"nav": { "home": "Home" },
"greeting": "Hello, {name}"
}Example 4
<h1 data-t="nav.home">Home</h1>
<input t:placeholder="search.placeholder" />