8.8 KiB
@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
bun add @wrnexus/i18n
Private package — the machine must be authenticated to the
wrnexusnpm 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 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) andnode:path; formatting relies on the platformIntlAPIs. - Works with
@wrnexus/core—TFunction(thet(key, params)type) comes from core, and the resolved translator is exposed asctx.t/ctx.langin request handling. - Nested message objects are supported: keys are looked up whole first, then split
on
.to walk the object tree.