first commit

This commit is contained in:
2026-07-12 15:55:18 +05:30
commit ee98026cc5
404 changed files with 44522 additions and 0 deletions
+52
View File
@@ -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));
}
+172
View File
@@ -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, "&amp;")
.replace(/"/g, "&quot;")
.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))}"`,
);
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();