first commit
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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"). */
|
||||
export function formatNumber(
|
||||
value: number,
|
||||
lang: string,
|
||||
options?: Intl.NumberFormatOptions,
|
||||
): string {
|
||||
return new Intl.NumberFormat(lang || undefined, options).format(value);
|
||||
}
|
||||
|
||||
/** Format a currency amount (e.g. 9.99, "USD" → "$9.99"). */
|
||||
export function formatCurrency(value: number, currency: string, lang: string): string {
|
||||
return new Intl.NumberFormat(lang || undefined, { style: "currency", currency }).format(value);
|
||||
}
|
||||
|
||||
/** Format a date/timestamp for a locale. */
|
||||
export function formatDate(
|
||||
value: Date | number | string,
|
||||
lang: string,
|
||||
options: Intl.DateTimeFormatOptions = { dateStyle: "medium" },
|
||||
): string {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return new Intl.DateTimeFormat(lang || undefined, options).format(date);
|
||||
}
|
||||
|
||||
/** Relative time, e.g. -3 days → "3 days ago" (localized). */
|
||||
export function formatRelativeTime(
|
||||
value: number,
|
||||
unit: Intl.RelativeTimeFormatUnit,
|
||||
lang: string,
|
||||
): string {
|
||||
return new Intl.RelativeTimeFormat(lang || undefined, { numeric: "auto" }).format(value, unit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function plural(
|
||||
count: number,
|
||||
forms: Partial<Record<Intl.LDMLPluralRule, string>>,
|
||||
lang: string,
|
||||
): string {
|
||||
const rule = new Intl.PluralRules(lang || undefined).select(count);
|
||||
const template = forms[rule] ?? forms.other ?? "";
|
||||
return template.replace(/#/g, String(count));
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* @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();
|
||||
Reference in New Issue
Block a user