/** * @wrnexus/i18n — deterministic locale loading, fallback resolution, SSR * translation markers, browser translation helpers, and UI language controls. */ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { basename, extname, join, relative, sep } from "node:path"; import type { TFunction } from "@wrnexus/core"; export { formatNumber, formatCurrency, formatDate, formatRelativeTime, plural } from "./format.ts"; export { extractTranslationKeys, extractTranslationKeysFromFiles, flattenMessageKeys, auditLocaleKeys, pseudoLocalize, createPseudoLocale, } from "./tooling.ts"; export type { ExtractedTranslationKey } from "./tooling.ts"; export type Messages = Record; export interface LocaleLoadOptions { /** Throw on invalid JSON instead of warning and continuing. */ strict?: boolean; /** Maximum JSON file size. Default 1 MiB. */ maxFileBytes?: number; } export interface I18nCookieConfig { name?: string; maxAge?: number; path?: string; sameSite?: "Strict" | "Lax" | "None"; secure?: boolean; } export interface I18nConfig { default?: string; locales?: string[]; /** Human-readable locale names used by package language controls. */ labels?: Record; /** Per-locale fallback override. Example: `{ "fr-CA": ["fr", "en"] }`. */ fallbacks?: Record; /** Locale direction overrides. Arabic/Hebrew/Persian/Urdu are RTL automatically. */ direction?: Record; cookie?: I18nCookieConfig; strict?: boolean; } export interface ResolvedI18n { default: string; langs: string[]; messages: Record; fallbacks: Record; direction: Record; labels: Record; cookie: Required; } export const LANG_COOKIE = "wire-lang"; export const I18N_JS_HREF = "/__wrnexus/i18n.js"; const UNSAFE_KEYS = new Set(["__proto__", "prototype", "constructor"]); const RTL_LANGS = new Set(["ar", "dv", "fa", "he", "ku", "ps", "sd", "ug", "ur", "yi"]); export function normalizeLocale(locale: string): string { const value = locale.trim().replace(/_/g, "-"); if (!value || !/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(value)) return ""; const parts = value.split("-"); return parts .map((part, index) => { if (index === 0) return part.toLowerCase(); if (part.length === 2) return part.toUpperCase(); if (part.length === 4) return part[0]!.toUpperCase() + part.slice(1).toLowerCase(); return part; }) .join("-"); } function safeObject(value: unknown, path: string): Messages { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new TypeError(`Locale file '${path}' must contain a JSON object.`); } const output: Messages = Object.create(null) as Messages; for (const [key, child] of Object.entries(value)) { if (UNSAFE_KEYS.has(key)) throw new TypeError(`Unsafe translation key '${key}' in ${path}.`); output[key] = child && typeof child === "object" && !Array.isArray(child) ? safeObject(child, `${path}.${key}`) : child; } return output; } function mergeMessages(target: Messages, source: Messages): Messages { for (const [key, value] of Object.entries(source)) { if (UNSAFE_KEYS.has(key)) continue; const current = target[key]; target[key] = value && typeof value === "object" && !Array.isArray(value) ? mergeMessages( current && typeof current === "object" && !Array.isArray(current) ? (current as Messages) : (Object.create(null) as Messages), value as Messages, ) : value; } return target; } function localeFiles(dir: string): string[] { if (!existsSync(dir)) return []; const files: string[] = []; const visit = (current: string) => { for (const entry of readdirSync(current, { withFileTypes: true })) { const path = join(current, entry.name); if (entry.isDirectory()) visit(path); else if (entry.isFile() && entry.name.endsWith(".json")) files.push(path); } }; visit(dir); return files.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)); } /** * Load both supported layouts: * - `locales/en.json` * - `locales/en/common.json`, `locales/en/auth.json` * * Namespaced files become `messages.en.common` and `messages.en.auth`. */ export function loadLocales( dir: string, options: LocaleLoadOptions = {}, ): Record { const output: Record = Object.create(null) as Record; const maxFileBytes = Math.max(1_024, options.maxFileBytes ?? 1_048_576); for (const file of localeFiles(dir)) { const rel = relative(dir, file).split(sep); const rootFile = rel.length === 1; const rawLocale = rootFile ? basename(file, extname(file)) : rel[0]!; const locale = normalizeLocale(rawLocale); if (!locale) { const error = new TypeError(`Invalid locale name '${rawLocale}' in ${file}.`); if (options.strict) throw error; console.warn(`[wrnexus] ${error.message}`); continue; } try { const info = statSync(file); if (info.size > maxFileBytes) throw new Error(`Locale file exceeds ${maxFileBytes} bytes.`); const parsed = safeObject(JSON.parse(readFileSync(file, "utf8")), file); const messages = (output[locale] ??= Object.create(null) as Messages); if (rootFile) mergeMessages(messages, parsed); else { const namespace = rel .slice(1) .join("/") .replace(/\.json$/, "") .replace(/\//g, "."); const parts = namespace.split(".").filter(Boolean); let node = messages; for (const part of parts.slice(0, -1)) { const current = node[part]; if (!current || typeof current !== "object" || Array.isArray(current)) { node[part] = Object.create(null) as Messages; } node = node[part] as Messages; } const leaf = parts.at(-1); if (leaf) { const current = node[leaf]; const target = current && typeof current === "object" && !Array.isArray(current) ? (current as Messages) : (Object.create(null) as Messages); node[leaf] = mergeMessages(target, parsed); } } } catch (error) { if (options.strict) throw error; console.warn(`[wrnexus] failed to load locale '${locale}' from ${file}`, error); } } return output; } export function localeDirection( locale: string, overrides: Record = {}, ): "ltr" | "rtl" { const normalized = normalizeLocale(locale); return ( overrides[normalized] ?? overrides[normalized.split("-")[0]!] ?? (RTL_LANGS.has(normalized.split("-")[0]!) ? "rtl" : "ltr") ); } export function resolveI18n( messages: Record, config: I18nConfig = {}, ): ResolvedI18n { const normalizedMessages: Record = Object.create(null) as Record< string, Messages >; for (const [locale, value] of Object.entries(messages)) { const normalized = normalizeLocale(locale); if (normalized) normalizedMessages[normalized] = value; } const configured = (config.locales ?? Object.keys(normalizedMessages)) .map(normalizeLocale) .filter((locale, index, values) => locale && values.indexOf(locale) === index); const langs = configured.filter((locale) => normalizedMessages[locale]); const fallback = langs[0] ?? (normalizeLocale(config.default ?? "en") || "en"); const requestedDefault = normalizeLocale(config.default ?? ""); const defaultLocale = requestedDefault && normalizedMessages[requestedDefault] ? requestedDefault : fallback; if (config.strict && !normalizedMessages[defaultLocale]) { throw new Error(`WRN-I18N-DEFAULT-MISSING: ${defaultLocale}`); } const fallbacks: Record = Object.create(null) as Record; const direction: Record = Object.create(null) as Record< string, "ltr" | "rtl" >; const labels: Record = Object.create(null) as Record; for (const locale of langs) { const custom = config.fallbacks?.[locale] ?? config.fallbacks?.[locale.toLowerCase()] ?? []; const base = locale.split("-")[0]!; fallbacks[locale] = [ ...new Set([ locale, ...(base !== locale ? [base] : []), ...custom.map(normalizeLocale), defaultLocale, ]), ].filter((entry) => entry && normalizedMessages[entry]); direction[locale] = localeDirection(locale, config.direction); labels[locale] = config.labels?.[locale] ?? config.labels?.[locale.toLowerCase()] ?? locale.toUpperCase(); } return { default: defaultLocale, langs, messages: normalizedMessages, fallbacks, direction, labels, cookie: { name: config.cookie?.name ?? LANG_COOKIE, maxAge: config.cookie?.maxAge ?? 31_536_000, path: config.cookie?.path ?? "/", sameSite: config.cookie?.sameSite ?? "Lax", secure: config.cookie?.sameSite === "None" ? true : (config.cookie?.secure ?? false), }, }; } export function lookupMessage(messages: Messages | undefined, key: string): string | undefined { if (!messages || !key) return undefined; let node: unknown = messages; for (const part of key.split(".")) { if (UNSAFE_KEYS.has(part)) return undefined; if (node && typeof node === "object" && !Array.isArray(node) && part in (node as Messages)) { node = (node as Messages)[part]; } else return undefined; } return typeof node === "string" ? node : undefined; } export function interpolate(message: string, params?: Record): string { if (!params) return message; return message.replace(/\{([A-Za-z0-9_.-]+)\}/g, (_match, name: string) => Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : `{${name}}`, ); } export function translationChain(i18n: ResolvedI18n, locale: string): string[] { const normalized = normalizeLocale(locale); return ( i18n.fallbacks[normalized] ?? [...new Set([normalized, normalized.split("-")[0], i18n.default])].filter( (entry) => entry && i18n.messages[entry], ) ); } export function makeT(i18n: ResolvedI18n, lang: string): TFunction { const chain = translationChain(i18n, lang); return (key, params) => { for (const locale of chain) { const message = lookupMessage(i18n.messages[locale], key); if (message !== undefined) return interpolate(message, params); } return key; }; } /** Deeply apply tenant-specific translations without mutating the shared locale bundle. */ export function withTenantMessages( i18n: ResolvedI18n, overrides: Record, ): ResolvedI18n { const messages: Record = Object.create(null) as Record; for (const [locale, value] of Object.entries(i18n.messages)) messages[locale] = mergeMessages( safeObject(structuredClone(value), `tenant:${locale}`), overrides[locale] ? safeObject(structuredClone(overrides[locale]), `tenant:${locale}:override`) : {}, ); return { ...i18n, messages }; } /** Load only common and route-specific messages for one locale. */ export function loadRouteMessages(directory: string, locale: string, route: string): Messages { const normalized = normalizeLocale(locale); if (!normalized) throw new Error("WRN-I18N-ROUTE-LOCALE: invalid locale."); const cleanRoute = route.replace(/^\/+|\/+$/g, "").replace(/\[[^\]]+\]/g, "_") || "index"; if (!/^[A-Za-z0-9_/-]+$/.test(cleanRoute) || cleanRoute.includes("..")) throw new Error("WRN-I18N-ROUTE-PATH: invalid route namespace."); const result = Object.create(null) as Messages; const candidates = [ join(directory, `${normalized}.json`), join(directory, normalized, "common.json"), join(directory, normalized, "routes", `${cleanRoute}.json`), ]; for (const file of candidates) { if (!existsSync(file)) continue; mergeMessages(result, safeObject(JSON.parse(readFileSync(file, "utf8")), file)); } return result; } export function parseAcceptLanguage(value: string | null): string[] { return (value ?? "") .split(",") .map((part, index) => { const [tag, ...params] = part.trim().split(";"); const quality = params.map((entry) => /^q=([0-9.]+)$/i.exec(entry.trim())?.[1]).find(Boolean); const normalizedTag = tag?.trim() === "*" ? "*" : normalizeLocale(tag ?? ""); return { locale: normalizedTag, quality: quality ? Number(quality) : 1, index }; }) .filter( (entry) => entry.locale && Number.isFinite(entry.quality) && entry.quality > 0 && entry.quality <= 1, ) .sort((left, right) => right.quality - left.quality || left.index - right.index) .map((entry) => entry.locale); } export function resolveLang( i18n: ResolvedI18n, cookieValue: string | undefined, acceptLanguage: string | null, ): string { const cookie = normalizeLocale(cookieValue ?? ""); if (cookie && i18n.langs.includes(cookie)) return cookie; for (const locale of parseAcceptLanguage(acceptLanguage)) { if (locale === "*") return i18n.default; if (i18n.langs.includes(locale)) return locale; const base = locale.split("-")[0]!; const match = i18n.langs.find( (supported) => supported === base || supported.startsWith(`${base}-`), ); if (match) return match; } 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, ">"); } export function translateHtml(html: string, t: TFunction): string { if (!html.includes("data-t=") && !html.includes("t:")) return html; let output = html.replace( /\bt:([A-Za-z][A-Za-z0-9:_-]*)=(?:"([^"]*)"|'([^']*)')/g, (_match, attr: string, doubleKey: string | undefined, singleKey: string | undefined) => { const key = doubleKey ?? singleKey ?? ""; const translated = t(key); return translated === key ? `${attr}=""` : `${attr}="${attrEscape(translated)}"`; }, ); output = output.replace( /<([A-Za-z][A-Za-z0-9-]*)\b([^>]*?)\sdata-t=(?:"([^"]*)"|'([^']*)')([^>]*)>([\s\S]*?)<\/\1>/g, ( _match, tag: string, before: string, doubleKey: string | undefined, singleKey: string | undefined, after: string, content: string, ) => { const key = doubleKey ?? singleKey ?? ""; const translated = t(key); return `<${tag}${before} data-t="${attrEscape(key)}"${after}>${ translated === key ? content : htmlEscape(translated) }`; }, ); return output; } function safeJson(value: unknown): string { return JSON.stringify(value) .replace(//g, "\\u003e") .replace(/&/g, "\\u0026") .replace(/\u2028/g, "\\u2028") .replace(/\u2029/g, "\\u2029"); } export function renderI18nData(i18n: ResolvedI18n, lang: string): string { const active = i18n.langs.includes(lang) ? lang : i18n.default; return `window.__wireI18n=${safeJson({ lang: active, langs: i18n.langs, default: i18n.default, messages: i18n.messages[active] ?? {}, fallbackMessages: active === i18n.default ? {} : (i18n.messages[i18n.default] ?? {}), direction: i18n.direction[active] ?? "ltr", directions: i18n.direction, labels: i18n.labels, cookie: i18n.cookie, })};`; } export const I18N_RUNTIME = String.raw` (function () { function lookup(messages, key) { var node = messages; var parts = String(key || "").split("."); for (var i = 0; i < parts.length; i++) { if (parts[i] === "__proto__" || parts[i] === "prototype" || parts[i] === "constructor") return undefined; if (!node || typeof node !== "object" || !Object.prototype.hasOwnProperty.call(node, parts[i])) return undefined; node = node[parts[i]]; } return typeof node === "string" ? node : undefined; } function interpolate(message, params) { return String(message).replace(/\{([A-Za-z0-9_.-]+)\}/g, function (_, name) { return params && Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : "{" + name + "}"; }); } function state() { return window.__wireI18n || {}; } function t(key, params) { var current = state(); return interpolate(lookup(current.messages, key) || lookup(current.fallbackMessages, key) || key, params); } function set(lang) { var current = state(); if (current.langs && current.langs.indexOf(lang) === -1) return false; var cookie = current.cookie || {}; var secure = cookie.secure || location.protocol === "https:"; document.cookie = encodeURIComponent(cookie.name || "${LANG_COOKIE}") + "=" + encodeURIComponent(lang) + ";path=" + (cookie.path || "/") + ";max-age=" + (cookie.maxAge || 31536000) + ";samesite=" + (cookie.sameSite || "Lax") + (secure ? ";secure" : ""); document.documentElement.lang = lang; document.documentElement.dir = (current.directions && current.directions[lang]) || "ltr"; window.dispatchEvent(new CustomEvent("wrnexus:language-change", { detail: { locale: lang } })); location.reload(); return true; } function bind(root) { var current = state(); var currentLang = current.lang || document.documentElement.lang; document.documentElement.lang = currentLang || current.default || "en"; if (current.direction) document.documentElement.dir = current.direction; var scope = root || document; var elements = []; if (scope.nodeType === 1) elements.push(scope); scope.querySelectorAll("*").forEach(function (node) { elements.push(node); }); elements.forEach(function (node) { Array.from(node.attributes || []).forEach(function (attribute) { if (attribute.name.indexOf("t:") !== 0) return; var target = attribute.name.slice(2); if (!target) return; var translated = t(attribute.value); node.setAttribute(target, translated === attribute.value ? "" : translated); node.removeAttribute(attribute.name); }); }); scope.querySelectorAll("[data-t]").forEach(function (node) { var key = node.getAttribute("data-t"); if (key) node.textContent = t(key); }); function updateResponsiveLabels() { var short = window.matchMedia && window.matchMedia("(max-width: 640px)").matches; scope.querySelectorAll("option[data-wire-lang-option]").forEach(function (option) { option.textContent = option.getAttribute(short ? "data-label-short" : "data-label-long") || option.value; }); } updateResponsiveLabels(); if (!window.__wireI18nResponsiveBound && window.matchMedia) { window.__wireI18nResponsiveBound = true; window.matchMedia("(max-width: 640px)").addEventListener("change", function () { bind(document); }); } scope.querySelectorAll("[data-wrn-preferences]").forEach(function (switcher) { switcher.setAttribute("data-current-language", currentLang); var label = switcher.querySelector(".wire-preferences__current-language"); if (label) label.textContent = String(currentLang || "en").toUpperCase(); }); scope.querySelectorAll("[data-wire-lang-set]").forEach(function (node) { node.setAttribute("aria-pressed", String(node.getAttribute("data-wire-lang-set") === currentLang)); if (node.__wireLangBound) return; node.__wireLangBound = 1; node.addEventListener("click", function () { set(node.getAttribute("data-wire-lang-set")); }); }); scope.querySelectorAll("select[data-wire-lang]").forEach(function (node) { node.value = currentLang; if (node.__wireLangBound) return; node.__wireLangBound = 1; node.addEventListener("change", function () { set(node.value); }); }); } window.__wireLang = { set: set, t: t, bind: bind, get lang() { return state().lang; } }; window.__wireI18n = Object.assign(state(), { t: t, set: set }); if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", function () { bind(document); }); else bind(document); })(); `.trim(); export { flattenMessages, localeFallbacks, translationCoverage, createLocaleFormatter, formatMessage, } from "./advanced.ts"; export type { LocaleFormatter } from "./advanced.ts"; export { i18nPlugin, i18nComponentsDir } from "./plugin.ts"; export type { I18nPluginOptions } from "./plugin.ts";