W WRNexusJS
Frontend

@wrnexus/i18n

Translation loading, locale resolution, and Intl formatting.

bun add @wrnexus/i18n@0.2.15
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.

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

ExportSignatureDescription
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) => ResolvedI18nMerges 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-USen) → i18n.default.
makeT(i18n: ResolvedI18n, lang: string) => TFunctionBuilds a translator resolving current language → default → the key itself, with {param} interpolation.

Types & constants

ExportKindNotes
MessagestypeRecord<string, unknown> — a locale's messages (supports nested/dotted keys).
I18nConfiginterface{ default?: string; locales?: string[] }.
ResolvedI18ninterface{ default: string; langs: string[]; messages: Record<string, Messages> }.
LANG_COOKIEconst"wire-lang" — the cookie the language is read from / written to.
I18N_JS_HREFconst"/__wrnexus/i18n.js" — URL the client runtime is served at.

HTML & client runtime

ExportSignatureDescription
translateHtml(html: string, t: TFunction) => stringRewrites 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) => stringJS snippet setting window.__wireI18n = { lang, langs, default } for the client switcher.
I18N_RUNTIMEconst stringBrowser 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)

ExportSignatureExample
formatNumber(value: number, lang: string, options?: Intl.NumberFormatOptions) => string1234.5 → "1,234.5"
formatCurrency(value: number, currency: string, lang: string) => string9.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) => stringpicks 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) 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.

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/i18n

Example 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" />