173 lines
6.2 KiB
TypeScript
173 lines
6.2 KiB
TypeScript
/**
|
|
* @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.
|
|
*/
|
|
|
|
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<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 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<string, Messages>;
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
/** 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<string, unknown>)) {
|
|
node = (node as Record<string, unknown>)[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 {
|
|
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, "<")
|
|
.replace(/>/g, ">");
|
|
}
|
|
|
|
function htmlEscape(value: string): string {
|
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
/**
|
|
* 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))}"`,
|
|
);
|
|
|
|
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}>`,
|
|
);
|
|
|
|
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();
|