/** * @wrnexus/i18n — translations for pages and API responses. * * Locales live in `app/locales/.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; /** Load `/.json` files into a `{ lang: messages }` map. */ export function loadLocales(dir: string): Record { const out: Record = {}; 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; } 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, 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)) { node = (node as Record)[part]; } else { return undefined; } } return typeof node === "string" ? node : undefined; } /** Interpolate `{param}` placeholders in a message. */ function interpolate(message: string, params?: Record): 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, "&") .replace(/"/g, """) .replace(//g, ">"); } function htmlEscape(value: string): string { return value.replace(/&/g, "&").replace(//g, ">"); } /** * Resolve translation markers in rendered HTML: * t:="key" → ="" (e.g. t:placeholder, t:aria-label) * → 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))}`, ); 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();