release: WRNexusJS 0.8.0
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-02 23:18:51 +05:30
parent 87507edf59
commit 586a6db8ff
625 changed files with 243608 additions and 11210 deletions
+59 -9
View File
@@ -73,7 +73,11 @@ export interface LocaleFormatter {
list(values: string[], options?: Intl.ListFormatOptions): string;
}
export function createLocaleFormatter(locale: string, timeZone?: string): LocaleFormatter {
export function createLocaleFormatter(
locale: string,
timeZone?: string,
calendar?: string,
): LocaleFormatter {
const numberCache = new Map<string, Intl.NumberFormat>();
const dateCache = new Map<string, Intl.DateTimeFormat>();
const key = (value: unknown) => JSON.stringify(value ?? {});
@@ -93,7 +97,11 @@ export function createLocaleFormatter(locale: string, timeZone?: string): Locale
);
},
date(value, options = { dateStyle: "medium" }) {
const resolved = { ...options, ...(timeZone ? { timeZone } : {}) };
const resolved = {
...options,
...(timeZone ? { timeZone } : {}),
...(calendar ? { calendar } : {}),
};
const cacheKey = key(resolved);
let format = dateCache.get(cacheKey);
if (!format) {
@@ -108,18 +116,60 @@ export function createLocaleFormatter(locale: string, timeZone?: string): Locale
};
}
/** Lightweight plural templates: `{count, plural, one {# item} other {# items}}`. */
function complexExpression(template: string, start: number) {
const header = /^\{(\w+),\s*(plural|select),\s*/.exec(template.slice(start));
if (!header) return null;
let cursor = start + header[0].length;
const choices: Record<string, string> = {};
while (cursor < template.length) {
while (/\s/.test(template[cursor] ?? "")) cursor++;
if (template[cursor] === "}")
return { name: header[1]!, kind: header[2]!, choices, end: cursor + 1 };
const key = /^(=?[\w-]+)/.exec(template.slice(cursor));
if (!key) return null;
cursor += key[0].length;
while (/\s/.test(template[cursor] ?? "")) cursor++;
if (template[cursor] !== "{") return null;
const bodyStart = ++cursor;
let depth = 1;
while (cursor < template.length && depth) {
if (template[cursor] === "{") depth++;
else if (template[cursor] === "}") depth--;
cursor++;
}
if (depth) return null;
choices[key[0]] = template.slice(bodyStart, cursor - 1);
}
return null;
}
/** ICU-style plural/select templates with exact values and recursive interpolation. */
export function formatMessage(
template: string,
params: Record<string, string | number>,
locale: string,
): string {
const plural = /\{(\w+),\s*plural,\s*one\s*\{([^{}]*)\}\s*other\s*\{([^{}]*)\}\s*\}/g;
let result = template.replace(plural, (_match, name: string, one: string, other: string) => {
const value = Number(params[name] ?? 0);
const selected = new Intl.PluralRules(locale).select(value) === "one" ? one : other;
return selected.replace(/#/g, String(value));
});
let result = "";
for (let cursor = 0; cursor < template.length;) {
const expression = template[cursor] === "{" ? complexExpression(template, cursor) : null;
if (!expression) {
result += template[cursor++]!;
continue;
}
const raw = params[expression.name];
const selector = expression.kind === "plural" ? `=${Number(raw ?? 0)}` : String(raw ?? "other");
const category =
expression.kind === "plural"
? new Intl.PluralRules(locale).select(Number(raw ?? 0))
: selector;
const selected =
expression.choices[selector] ??
expression.choices[category] ??
expression.choices.other ??
"";
result += formatMessage(selected.replace(/#/g, String(raw ?? 0)), params, locale);
cursor = expression.end;
}
result = result.replace(/\{(\w+)\}/g, (_match, name: string) =>
name in params ? String(params[name]) : `{${name}}`,
);
+449 -93
View File
@@ -1,105 +1,373 @@
/**
* @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.
* @wrnexus/i18n — deterministic locale loading, fallback resolution, SSR
* translation markers, browser translation helpers, and UI language controls.
*/
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
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<string, unknown>;
/** Load `<dir>/<lang>.json` files into a `{ lang: messages }` map. */
export function loadLocales(dir: string): Record<string, Messages> {
const out: Record<string, Messages> = {};
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 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 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[];
/** Human-readable locale names used by package language controls. */
labels?: Record<string, string>;
/** Per-locale fallback override. Example: `{ "fr-CA": ["fr", "en"] }`. */
fallbacks?: Record<string, string[]>;
/** Locale direction overrides. Arabic/Hebrew/Persian/Urdu are RTL automatically. */
direction?: Record<string, "ltr" | "rtl">;
cookie?: I18nCookieConfig;
strict?: boolean;
}
export interface ResolvedI18n {
default: string;
langs: string[];
messages: Record<string, Messages>;
fallbacks: Record<string, string[]>;
direction: Record<string, "ltr" | "rtl">;
labels: Record<string, string>;
cookie: Required<I18nCookieConfig>;
}
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<string, Messages>, 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 };
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("-");
}
/** 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;
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<string, Messages> {
const output: Record<string, Messages> = Object.create(null) as Record<string, Messages>;
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<string, "ltr" | "rtl"> = {},
): "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<string, Messages>,
config: I18nConfig = {},
): ResolvedI18n {
const normalizedMessages: Record<string, Messages> = 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<string, string[]> = Object.create(null) as Record<string, string[]>;
const direction: Record<string, "ltr" | "rtl"> = Object.create(null) as Record<
string,
"ltr" | "rtl"
>;
const labels: Record<string, string> = Object.create(null) as Record<string, string>;
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 (node && typeof node === "object" && part in (node as Record<string, unknown>)) {
node = (node as Record<string, unknown>)[part];
} else {
return undefined;
}
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;
}
/** Interpolate `{param}` placeholders in a message. */
function interpolate(message: string, params?: Record<string, string | number>): string {
export function interpolate(message: string, params?: Record<string, string | number>): string {
if (!params) return message;
return message.replace(/\{(\w+)\}/g, (_m, name: string) =>
name in params ? String(params[name]) : `{${name}}`,
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],
)
);
}
/** Build a `t()` for a language: current → default → the key itself. */
export function makeT(i18n: ResolvedI18n, lang: string): TFunction {
const chain = translationChain(i18n, lang);
return (key, params) => {
const message =
lookup(i18n.messages[lang], key) ?? lookup(i18n.messages[i18n.default], key) ?? key;
return interpolate(message, params);
for (const locale of chain) {
const message = lookupMessage(i18n.messages[locale], key);
if (message !== undefined) return interpolate(message, params);
}
return key;
};
}
/** Resolve the active language from a cookie, Accept-Language, then default. */
/** Deeply apply tenant-specific translations without mutating the shared locale bundle. */
export function withTenantMessages(
i18n: ResolvedI18n,
overrides: Record<string, Messages>,
): ResolvedI18n {
const messages: Record<string, Messages> = Object.create(null) as Record<string, Messages>;
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 {
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;
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;
}
@@ -111,72 +379,158 @@ function attrEscape(value: string): string {
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function htmlEscape(value: string): string {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/**
* 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.
*/
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))}"`,
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)}"`;
},
);
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))}</${tag}>`,
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)
}</${tag}>`;
},
);
return out;
return output;
}
function safeJson(value: unknown): string {
return JSON.stringify(value)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
/** `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 })};`;
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,
})};`;
}
/**
* 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 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) {
document.cookie = COOKIE + "=" + encodeURIComponent(lang) + ";path=/;max-age=31536000;samesite=lax";
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 currentLang = (window.__wireI18n && window.__wireI18n.lang) || document.documentElement.lang;
(root || document).querySelectorAll("[data-wrn-preferences]").forEach(function (switcher) {
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 currentLabel = switcher.querySelector(".wire-preferences__current-language");
if (currentLabel) currentLabel.textContent = String(currentLang || "en").toUpperCase();
var label = switcher.querySelector(".wire-preferences__current-language");
if (label) label.textContent = String(currentLang || "en").toUpperCase();
});
(root || document).querySelectorAll("[data-wire-lang-set]").forEach(function (n) {
n.setAttribute("aria-pressed", String(n.getAttribute("data-wire-lang-set") === currentLang));
if (n.__wireLangBound) return; n.__wireLangBound = 1;
n.addEventListener("click", function () { set(n.getAttribute("data-wire-lang-set")); });
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")); });
});
(root || document).querySelectorAll("select[data-wire-lang]").forEach(function (n) {
if (n.__wireLangBound) return; n.__wireLangBound = 1;
n.addEventListener("change", function () { set(n.value); });
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 };
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,
@@ -185,3 +539,5 @@ export {
formatMessage,
} from "./advanced.ts";
export type { LocaleFormatter } from "./advanced.ts";
export { i18nPlugin, i18nComponentsDir } from "./plugin.ts";
export type { I18nPluginOptions } from "./plugin.ts";
+21
View File
@@ -0,0 +1,21 @@
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { definePlugin } from "@wrnexus/plugin";
export interface I18nPluginOptions {
components?: boolean;
componentDir?: string;
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
export function i18nComponentsDir(): string {
return join(packageRoot, "components");
}
export function i18nPlugin(options: I18nPluginOptions = {}) {
return definePlugin({
name: "@wrnexus/i18n",
version: "0.8.0",
componentDirs:
options.components === false ? [] : [options.componentDir ?? i18nComponentsDir()],
});
}
export default i18nPlugin;
+104
View File
@@ -0,0 +1,104 @@
import { readFileSync } from "node:fs";
import type { Messages } from "./index.ts";
export interface ExtractedTranslationKey {
key: string;
file?: string;
offset: number;
}
export function extractTranslationKeys(source: string, file?: string): ExtractedTranslationKey[] {
const found = new Map<string, ExtractedTranslationKey>();
const patterns = [
/(?:\b(?:t|\$t)|\bi18n\.t)\s*\(\s*(["'])([^"']+)\1/g,
/\bdata-i18n\s*=\s*(["'])([^"']+)\1/g,
/\{t:([A-Za-z0-9_.:-]+)\}/g,
];
for (const pattern of patterns) {
for (const match of source.matchAll(pattern)) {
const key = (match[2] ?? match[1])!.trim();
if (key && !found.has(key)) found.set(key, { key, file, offset: match.index });
}
}
return [...found.values()].sort((left, right) => left.key.localeCompare(right.key));
}
export function extractTranslationKeysFromFiles(
files: Iterable<string>,
): ExtractedTranslationKey[] {
const found = new Map<string, ExtractedTranslationKey>();
for (const file of files) {
for (const item of extractTranslationKeys(readFileSync(file, "utf8"), file)) {
found.set(`${item.file}:${item.key}`, item);
}
}
return [...found.values()].sort(
(left, right) =>
String(left.file).localeCompare(String(right.file)) || left.key.localeCompare(right.key),
);
}
export function flattenMessageKeys(messages: Messages, prefix = ""): string[] {
const keys: string[] = [];
for (const [name, value] of Object.entries(messages)) {
const key = prefix ? `${prefix}.${name}` : name;
if (value && typeof value === "object" && !Array.isArray(value)) {
keys.push(...flattenMessageKeys(value as Messages, key));
} else keys.push(key);
}
return keys.sort();
}
export function auditLocaleKeys(
messages: Record<string, Messages>,
referenceLocale: string,
): Record<string, { missing: string[]; extra: string[] }> {
const reference = new Set(flattenMessageKeys(messages[referenceLocale] ?? {}));
const result: Record<string, { missing: string[]; extra: string[] }> = {};
for (const [locale, value] of Object.entries(messages)) {
const keys = new Set(flattenMessageKeys(value));
result[locale] = {
missing: [...reference].filter((key) => !keys.has(key)).sort(),
extra: [...keys].filter((key) => !reference.has(key)).sort(),
};
}
return result;
}
const ACCENTS: Record<string, string> = {
a: "à",
e: "ë",
i: "ï",
o: "ô",
u: "ü",
A: "À",
E: "Ë",
I: "Ï",
O: "Ô",
U: "Ü",
};
export function pseudoLocalize(value: string, options: { rtl?: boolean } = {}): string {
const parts = value.split(/(\{[^{}]+\}|<[^>]+>)/g);
const transformed = parts
.map((part) =>
/^\{[^{}]+\}$|^<[^>]+>$/.test(part)
? part
: part.replace(/[aeiouAEIOU]/g, (character) => ACCENTS[character] ?? character),
)
.join("");
return options.rtl ? `\u202e[${transformed}]\u202c` : `[${transformed}~~~]`;
}
export function createPseudoLocale(messages: Messages, options: { rtl?: boolean } = {}): Messages {
const output: Messages = Object.create(null) as Messages;
for (const [key, value] of Object.entries(messages)) {
output[key] =
typeof value === "string"
? pseudoLocalize(value, options)
: value && typeof value === "object" && !Array.isArray(value)
? createPseudoLocale(value as Messages, options)
: value;
}
return output;
}