Add i18n support: per-locale catalogs, URL-driven locale, translated core and examples
CI / full-gate (push) Successful in 2m37s

This commit is contained in:
2026-08-03 22:37:27 +02:00
parent c30cd95ebd
commit 245d1ad5b5
93 changed files with 2480 additions and 464 deletions
+57
View File
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { type Catalog, checkCatalog, isCatalog } from "./catalog.ts";
const baseline: Catalog = { greeting: "Hello", "shifts.count": { one: "{{count}} shift", other: "{{count}} shifts" } };
const parity = (locale: string, catalog: Catalog): string[] =>
checkCatalog({ baseline, baselineLocale: "en-US", catalog, locale });
test("a complete translation reports nothing", () => {
assert.deepEqual(parity("sv-SE", { greeting: "Hej", "shifts.count": { one: "{{count}} pass", other: "{{count}} pass" } }), []);
});
test("a missing or unknown key is reported", () => {
const missing = parity("sv-SE", { "shifts.count": { one: "a", other: "b" } });
assert.equal(missing.length, 1);
assert.match(missing[0] ?? "", /missing key "greeting"/);
const extra = parity("sv-SE", { ...baseline, stray: "x" });
assert.equal(extra.length, 1);
assert.match(extra[0] ?? "", /unknown key "stray".*en-US/);
});
test("a key must stay the same kind as in the baseline", () => {
const flat = parity("sv-SE", { greeting: "Hej", "shifts.count": "pass" });
assert.equal(flat.length, 1);
assert.match(flat[0] ?? "", /"shifts.count" must be a plural message/);
const plural = parity("sv-SE", { greeting: { one: "Hej", other: "Hej" }, "shifts.count": { one: "a", other: "b" } });
assert.equal(plural.length, 1);
assert.match(plural[0] ?? "", /"greeting" must be a string/);
});
test("a plural message must cover exactly its own locale's categories", () => {
const short = parity("cs-CZ", { greeting: "Ahoj", "shifts.count": { one: "a", other: "b" } });
assert.equal(short.length, 1);
assert.match(short[0] ?? "", /"shifts\.count".*cs-CZ.*few, many/);
const long = parity("sv-SE", { greeting: "Hej", "shifts.count": { few: "x", one: "a", other: "b" } });
assert.equal(long.length, 1);
assert.match(long[0] ?? "", /"shifts\.count".*few/);
});
test("the baseline is checked against itself, so an incomplete plural fails at home too", () => {
assert.deepEqual(checkCatalog({ baseline, baselineLocale: "en-US", catalog: baseline, locale: "en-US" }), []);
const bad: Catalog = { greeting: "Hello", "shifts.count": { one: "{{count}} shift" } };
assert.match(checkCatalog({ baseline: bad, baselineLocale: "en-US", catalog: bad, locale: "en-US" })[0] ?? "", /other/);
});
test("isCatalog accepts strings and plural objects, rejects anything else", () => {
assert.equal(isCatalog({ a: "x", b: { other: "y" } }), true);
assert.equal(isCatalog({ a: 1 }), false);
assert.equal(isCatalog({ a: { other: 1 } }), false);
assert.equal(isCatalog({ a: {} }), false); // an empty plural message says nothing
assert.equal(isCatalog({ a: { bogus: "x" } }), false); // not a plural category
assert.equal(isCatalog(null), false);
assert.equal(isCatalog([]), false);
});
+77
View File
@@ -0,0 +1,77 @@
// What a translation catalog is, and the boot-time parity rules that keep every locale
// in step with its en-US baseline. Pure: `load.ts` reads the files, this decides whether they are
// sound. A plural message carries exactly the categories its own locale needs (Intl.PluralRules),
// so a translator can't ship half a plural and a Czech catalog isn't held to English's two forms.
export type PluralMessage = Partial<Record<Intl.LDMLPluralRule, string>>;
export type Message = PluralMessage | string;
export type Catalog = Record<string, Message>;
// The baseline every catalog set is checked against, and the locale served when a request matches
// nothing. A core catalog for it must exist — the host refuses to boot otherwise.
export const DEFAULT_LOCALE = "en-US";
const CATEGORIES: ReadonlySet<string> = new Set(["few", "many", "one", "other", "two", "zero"]);
export function isPluralMessage(value: Message): value is PluralMessage {
return typeof value !== "string";
}
// Shape guard for an imported catalog module — a mounted plugin's file is untyped at runtime.
export function isCatalog(value: unknown): value is Catalog {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
return Object.values(value).every((message) => {
if (typeof message === "string") return true;
if (typeof message !== "object" || message === null || Array.isArray(message)) return false;
const forms = Object.entries(message);
return forms.length > 0 && forms.every(([category, text]) => CATEGORIES.has(category) && typeof text === "string");
});
}
export interface ParityInput {
baseline: Catalog;
baselineLocale: string;
catalog: Catalog;
locale: string;
}
// Every problem with `catalog` relative to `baseline`, as ready-to-print lines. Empty ⇒ sound.
// Run the baseline against itself too: that is what validates its own plural completeness.
export function checkCatalog({ baseline, baselineLocale, catalog, locale }: ParityInput): string[] {
const problems: string[] = [];
const categories = pluralCategories(locale);
for (const [key, expected] of Object.entries(baseline)) {
const actual = catalog[key];
if (actual === undefined) {
problems.push(`missing key "${key}"`);
continue;
}
if (isPluralMessage(expected) !== isPluralMessage(actual)) {
problems.push(`"${key}" must be a ${isPluralMessage(expected) ? "plural message" : "string"}, like ${baselineLocale}`);
continue;
}
if (!isPluralMessage(actual)) continue;
const forms = new Set(Object.keys(actual));
const missing = categories.filter((category) => !forms.has(category));
const selected = new Set<string>(categories);
const unknown = [...forms].filter((category) => !selected.has(category)).sort();
if (missing.length) problems.push(`"${key}" is missing the ${locale} plural forms: ${missing.join(", ")}`);
if (unknown.length) problems.push(`"${key}" has plural forms ${locale} never selects: ${unknown.join(", ")}`);
}
for (const key of Object.keys(catalog)) {
if (!(key in baseline)) problems.push(`unknown key "${key}" — add it to ${baselineLocale} first`);
}
return problems;
}
// The plural categories a locale actually selects, sorted; unknown tags fall back to English's.
export function pluralCategories(locale: string): Intl.LDMLPluralRule[] {
try {
return [...new Intl.PluralRules(locale).resolvedOptions().pluralCategories].sort();
} catch {
return ["one", "other"];
}
}
+17
View File
@@ -0,0 +1,17 @@
// The shipped en-US catalog, ready to use without loading anything from disk. This is what the
// host falls back to wherever the loaded catalogs haven't been wired — a context built ad hoc, a
// view model built outside a request, an app created without `i18n` — so an unwired path renders
// real English rather than bare keys. server.ts replaces it with the discovered catalogs at boot.
import { DEFAULT_LOCALE } from "./catalog.ts";
import enUS from "./locales/en-US.ts";
import { createI18n, type I18n } from "./runtime.ts";
import { createTranslator, type Translate } from "./translate.ts";
export const ENGLISH: Translate = createTranslator({ catalogs: [enUS], locale: DEFAULT_LOCALE });
export const ENGLISH_I18N: I18n = createI18n({
available: [DEFAULT_LOCALE],
core: new Map([[DEFAULT_LOCALE, enUS]]),
plugins: new Map(),
});
+106
View File
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { loadI18n } from "./load.ts";
const catalog = (body: string): string => `const messages = ${body};\nexport default messages;\n`;
// A throwaway host tree: <root>/locales/*.ts and <root>/plugins/<id>/i18n/*.ts.
async function fixture(files: Record<string, string>): Promise<{ localesDir: string; pluginsDir: string }> {
const root = await mkdtemp(join(tmpdir(), "i18n-"));
for (const [path, body] of Object.entries(files)) {
const file = join(root, path);
await mkdir(join(file, ".."), { recursive: true });
await writeFile(file, body);
}
return { localesDir: join(root, "locales"), pluginsDir: join(root, "plugins") };
}
test("the shipped core catalogs load and agree key for key", async () => {
const loaded = await loadI18n(); // no args ⇒ the real src/i18n/locales + plugins/
assert.ok(loaded.available.includes("en-US"));
assert.ok(loaded.available.includes("sv-SE"));
assert.deepEqual([...loaded.available].sort(), loaded.available); // sorted, so "sv" resolves deterministically
assert.ok(Object.keys(loaded.core.get("en-US") ?? {}).length > 20);
});
test("a plugin's catalogs load under its id and may cover fewer locales than the host", async () => {
const { localesDir, pluginsDir } = await fixture({
"locales/en-US.ts": catalog(`{ hello: "Hello" }`),
"locales/sv-SE.ts": catalog(`{ hello: "Hej" }`),
"plugins/shop/i18n/en-US.ts": catalog(`{ "shop.title": "Shop" }`),
});
const loaded = await loadI18n({ localesDir, pluginIds: ["shop"], pluginsDir });
assert.deepEqual(loaded.available, ["en-US", "sv-SE"]);
assert.deepEqual(loaded.plugins.get("shop")?.get("en-US"), { "shop.title": "Shop" });
assert.equal(loaded.plugins.get("shop")?.has("sv-SE"), false);
});
test("a locale that disagrees with the en-US baseline stops the boot", async () => {
const { localesDir, pluginsDir } = await fixture({
"locales/en-US.ts": catalog(`{ hello: "Hello", bye: "Bye" }`),
"locales/sv-SE.ts": catalog(`{ hello: "Hej", hej: "Hej" }`),
});
await assert.rejects(loadI18n({ localesDir, pluginsDir }), (err: Error) => {
assert.match(err.message, /sv-SE/);
assert.match(err.message, /missing key "bye"/);
assert.match(err.message, /unknown key "hej"/);
return true;
});
});
test("the en-US baseline itself must exist", async () => {
const { localesDir, pluginsDir } = await fixture({ "locales/sv-SE.ts": catalog(`{ hello: "Hej" }`) });
await assert.rejects(loadI18n({ localesDir, pluginsDir }), /en-US\.ts/);
});
test("a file in locales/ that is not a locale is an error, never silently skipped", async () => {
const { localesDir, pluginsDir } = await fixture({
"locales/en-US.ts": catalog(`{ hello: "Hello" }`),
"locales/swedish.ts": catalog(`{ hello: "Hej" }`),
});
await assert.rejects(loadI18n({ localesDir, pluginsDir }), /swedish\.ts/);
});
test("a catalog that is not a catalog is an error", async () => {
const { localesDir, pluginsDir } = await fixture({
"locales/en-US.ts": catalog(`{ hello: 42 }`),
});
await assert.rejects(loadI18n({ localesDir, pluginsDir }), /en-US/);
});
test("a plugin locale the host does not have is an error", async () => {
const { localesDir, pluginsDir } = await fixture({
"locales/en-US.ts": catalog(`{ hello: "Hello" }`),
"plugins/shop/i18n/en-US.ts": catalog(`{ "shop.title": "Shop" }`),
"plugins/shop/i18n/fr-FR.ts": catalog(`{ "shop.title": "Boutique" }`),
});
await assert.rejects(loadI18n({ localesDir, pluginIds: ["shop"], pluginsDir }), /fr-FR/);
});
test("a plugin translation is checked against the plugin's own en-US", async () => {
const { localesDir, pluginsDir } = await fixture({
"locales/en-US.ts": catalog(`{ hello: "Hello" }`),
"locales/sv-SE.ts": catalog(`{ hello: "Hej" }`),
"plugins/shop/i18n/en-US.ts": catalog(`{ "shop.title": "Shop" }`),
"plugins/shop/i18n/sv-SE.ts": catalog(`{ "shop.name": "Butik" }`),
});
await assert.rejects(loadI18n({ localesDir, pluginIds: ["shop"], pluginsDir }), /shop.*sv-SE|sv-SE.*shop/s);
});
test("a plugin with translations but no en-US baseline is an error", async () => {
const { localesDir, pluginsDir } = await fixture({
"locales/en-US.ts": catalog(`{ hello: "Hello" }`),
"locales/sv-SE.ts": catalog(`{ hello: "Hej" }`),
"plugins/shop/i18n/sv-SE.ts": catalog(`{ "shop.title": "Butik" }`),
});
await assert.rejects(loadI18n({ localesDir, pluginIds: ["shop"], pluginsDir }), /shop/);
});
test("a plugin without an i18n folder is fine", async () => {
const { localesDir, pluginsDir } = await fixture({ "locales/en-US.ts": catalog(`{ hello: "Hello" }`) });
const loaded = await loadI18n({ localesDir, pluginIds: ["plain"], pluginsDir });
assert.equal(loaded.plugins.size, 0);
});
+98
View File
@@ -0,0 +1,98 @@
// Catalog discovery: import src/i18n/locales/<tag>.ts and plugins/<id>/i18n/<tag>.ts, then
// check every one against its set's en-US baseline. The imperative shell over catalog.ts's pure
// rules — the same contract as plugin discovery: one boot-stopping Error listing every problem,
// so a half-translated deploy is caught at startup rather than as a stray English word in production.
//
// Installed locales are whatever the core folder holds; a plugin may translate fewer of them (its
// strings then render in en-US on that page) but never one the host does not have.
import { existsSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { checkCatalog, DEFAULT_LOCALE, isCatalog, type Catalog } from "./catalog.ts";
import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
export const LOCALES_DIR = join(dirname(fileURLToPath(import.meta.url)), "locales");
// A catalog file is named for the full locale it holds — sv-SE.ts, never sv.ts. Anything else in
// the folder is a mistake worth stopping for.
const LOCALE_FILE = /^([a-z]{2,3}-[A-Z]{2})\.ts$/;
export interface LoadI18nOptions {
localesDir?: string;
pluginIds?: string[]; // discovered plugins; their i18n/ folders are loaded under their id
pluginsDir?: string;
}
export interface LoadedI18n {
available: string[]; // installed locales, sorted — the switcher's list, and "sv" resolution order
core: Map<string, Catalog>;
plugins: Map<string, Map<string, Catalog>>; // plugin id → locale → catalog
}
export async function loadI18n(options: LoadI18nOptions = {}): Promise<LoadedI18n> {
const localesDir = options.localesDir ?? LOCALES_DIR;
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
const errors: string[] = [];
const core = await readSet(localesDir, "core", errors);
if (!core.has(DEFAULT_LOCALE)) errors.push(`core: no ${DEFAULT_LOCALE}.ts — it is the baseline every other locale is checked against`);
checkSet(core, "core", errors);
const available = [...core.keys()].sort();
const plugins = new Map<string, Map<string, Catalog>>();
for (const id of options.pluginIds ?? []) {
const dir = join(pluginsDir, id, "i18n");
if (!existsSync(dir)) continue;
const set = await readSet(dir, `plugins/${id}`, errors);
if (set.size === 0) continue;
if (!set.has(DEFAULT_LOCALE)) errors.push(`plugins/${id}: no ${DEFAULT_LOCALE}.ts — a plugin's own baseline`);
for (const locale of set.keys()) {
if (!available.includes(locale)) errors.push(`plugins/${id}: ${locale} is not installed — add src/i18n/locales/${locale}.ts first`);
}
checkSet(set, `plugins/${id}`, errors);
plugins.set(id, set);
}
if (errors.length) throw new Error(`Translation catalogs failed to load:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
return { available, core, plugins };
}
// Import every catalog in one folder. A stray file, a failed import or a value that is not a
// catalog is collected as an error — never skipped, or the locale would just go quietly missing.
async function readSet(dir: string, label: string, errors: string[]): Promise<Map<string, Catalog>> {
const set = new Map<string, Catalog>();
if (!existsSync(dir)) return set;
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
if (entry.isDirectory() || entry.name.startsWith(".")) continue;
const locale = LOCALE_FILE.exec(entry.name)?.[1];
if (locale === undefined) {
errors.push(`${label}: "${entry.name}" is not a locale catalog — name it <language>-<REGION>.ts (e.g. sv-SE.ts)`);
continue;
}
let mod: { default?: unknown };
try {
mod = (await import(pathToFileURL(join(dir, entry.name)).href)) as { default?: unknown };
} catch (err) {
errors.push(`${label}: ${entry.name} failed to import — ${err instanceof Error ? err.message : String(err)}`);
continue;
}
if (!isCatalog(mod.default)) {
errors.push(`${label}: ${entry.name} must default-export an object of strings (or plural forms)`);
continue;
}
set.set(locale, mod.default);
}
return set;
}
function checkSet(set: Map<string, Catalog>, label: string, errors: string[]): void {
const baseline = set.get(DEFAULT_LOCALE);
if (baseline === undefined) return; // already reported; nothing to compare against
for (const [locale, catalog] of set) {
for (const problem of checkCatalog({ baseline, baselineLocale: DEFAULT_LOCALE, catalog, locale })) {
errors.push(`${label} ${locale}: ${problem}`);
}
}
}
+74
View File
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { localeHref, localeLabel, matchLocale, parseAcceptLanguage, resolveLocale, textDirection } from "./locale.ts";
const available = ["en-US", "sv-FI", "sv-SE"];
test("parseAcceptLanguage orders tags by q, dropping wildcards and junk", () => {
assert.deepEqual(parseAcceptLanguage("sv-SE,sv;q=0.9,en-US;q=0.8"), ["sv-SE", "sv", "en-US"]);
assert.deepEqual(parseAcceptLanguage("en;q=0.2, sv;q=0.9, de"), ["de", "sv", "en"]); // no q ⇒ 1.0
assert.deepEqual(parseAcceptLanguage("*, sv;q=0.5"), ["sv"]);
assert.deepEqual(parseAcceptLanguage(""), []);
assert.deepEqual(parseAcceptLanguage(undefined), []);
});
test("matchLocale takes an exact tag, case-insensitively", () => {
assert.equal(matchLocale("sv-SE", available), "sv-SE");
assert.equal(matchLocale("SV-se", available), "sv-SE");
});
test("matchLocale never substitutes another region", () => {
assert.equal(matchLocale("sv-NO", available), null); // sv-SE exists, but the request asked for Norway
assert.equal(matchLocale("de-DE", available), null);
});
test("matchLocale resolves a lone language to the first matching regional catalog", () => {
assert.equal(matchLocale("sv", available), "sv-FI"); // alphabetically first of sv-FI / sv-SE
assert.equal(matchLocale("sv", ["en-US", "sv-SE"]), "sv-SE");
assert.equal(matchLocale("sv", ["sv-SE", "sv-FI"]), "sv-FI"); // input order must not matter
assert.equal(matchLocale("en", available), "en-US");
});
test("matchLocale rejects malformed input instead of guessing", () => {
for (const bad of ["", "!!", "sv_SE", "e", "../../etc", undefined, null]) {
assert.equal(matchLocale(bad, available), null, `expected null for ${JSON.stringify(bad)}`);
}
});
test("resolveLocale: ?locale wins over Accept-Language", () => {
const got = resolveLocale({ acceptLanguage: "en-US", available, param: "sv-SE" });
assert.deepEqual(got, { explicit: true, locale: "sv-SE" });
});
test("resolveLocale: an unmatched ?locale falls through to Accept-Language", () => {
const got = resolveLocale({ acceptLanguage: "de-DE;q=0.9, sv;q=0.8", available, param: "es-ES" });
assert.deepEqual(got, { explicit: false, locale: "sv-FI" });
});
test("resolveLocale: nothing matches ⇒ en-US, and no request carried a locale", () => {
assert.deepEqual(resolveLocale({ available, param: null }), { explicit: false, locale: "en-US" });
assert.deepEqual(resolveLocale({ acceptLanguage: "de-DE", available, param: "" }), { explicit: false, locale: "en-US" });
});
test("localeHref carries the locale on host-relative links only", () => {
assert.equal(localeHref("/admin/users", "sv-SE"), "/admin/users?locale=sv-SE");
assert.equal(localeHref("/admin/users?q=a", "sv-SE"), "/admin/users?q=a&locale=sv-SE");
assert.equal(localeHref("/admin/users?locale=en-US", "sv-SE"), "/admin/users?locale=sv-SE"); // replaced, never doubled
assert.equal(localeHref("/docs#top", "sv-SE"), "/docs?locale=sv-SE#top");
assert.equal(localeHref("/admin/users", null), "/admin/users"); // no explicit locale ⇒ untouched
assert.equal(localeHref("https://example.com/x", "sv-SE"), "https://example.com/x"); // off-site
assert.equal(localeHref("//example.com/x", "sv-SE"), "//example.com/x"); // protocol-relative is off-site too
assert.equal(localeHref("", "sv-SE"), "");
});
test("textDirection reads the script direction, defaulting to ltr", () => {
assert.equal(textDirection("en-US"), "ltr");
assert.equal(textDirection("sv-SE"), "ltr");
assert.equal(textDirection("ar-EG"), "rtl");
assert.equal(textDirection("not a locale"), "ltr");
});
test("localeLabel names a locale in its own language", () => {
assert.match(localeLabel("sv-SE"), /svenska/i);
assert.equal(localeLabel("not a locale"), "not a locale"); // fail soft: the tag itself
});
+118
View File
@@ -0,0 +1,118 @@
// Which language a request is served in, and how a chosen one travels.
//
// Precedence: `?locale=sv-SE` → Accept-Language (by q) → en-US. Matching is exact on a full tag —
// asking for sv-FI when only sv-SE is installed lands on en-US rather than a neighbouring region —
// but a lone language ("sv", as browsers send) resolves to the first regional catalog for it.
// There is no locale cookie: the URL is the only place a choice is stored, so a link is shareable
// and a page is what its address says it is. `localeHref` is how the choice survives a click.
import { DEFAULT_LOCALE } from "./catalog.ts";
// Accept-Language tags, best first. Wildcards and malformed entries are dropped, not guessed at.
export function parseAcceptLanguage(header: string | undefined): string[] {
if (!header) return [];
return header
.split(",")
.map((part, index) => {
const [tag = "", ...params] = part.trim().split(";");
const q = params.map((p) => /^\s*q=([0-9.]+)\s*$/.exec(p)).find((m) => m !== null);
return { index, q: q ? Number(q[1]) : 1, tag: tag.trim() };
})
.filter((entry) => /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i.test(entry.tag) && Number.isFinite(entry.q))
.sort((a, b) => b.q - a.q || a.index - b.index)
.map((entry) => entry.tag);
}
// The installed locale a request for `requested` should be served in, or null when none fits.
export function matchLocale(requested: string | null | undefined, available: string[]): string | null {
const canonical = canonicalize(requested);
if (canonical === null) return null;
const exact = available.find((tag) => tag.toLowerCase() === canonical.toLowerCase());
if (exact !== undefined) return exact;
if (canonical.includes("-")) return null; // a region was asked for; another region is a different locale
const language = `${canonical.toLowerCase()}-`;
return [...available].sort().find((tag) => tag.toLowerCase().startsWith(language)) ?? null;
}
export interface ResolveInput {
acceptLanguage?: string | undefined;
available: string[];
param?: string | null | undefined; // the ?locale query value
}
export interface ResolvedLocale {
explicit: boolean; // the URL asked for this locale — the host then carries it on the links it renders
locale: string;
}
export function resolveLocale({ acceptLanguage, available, param }: ResolveInput): ResolvedLocale {
const asked = matchLocale(param, available);
if (asked !== null) return { explicit: true, locale: asked };
for (const tag of parseAcceptLanguage(acceptLanguage)) {
const matched = matchLocale(tag, available);
if (matched !== null) return { explicit: false, locale: matched };
}
return { explicit: false, locale: DEFAULT_LOCALE };
}
// Carry `locale` on a host-relative link. Off-site and protocol-relative URLs are left alone — the
// locale is ours to state, not theirs. `locale` null (the visitor never asked for one) ⇒ unchanged.
export function localeHref(href: string, locale: string | null): string {
if (locale === null || href === "" || !href.startsWith("/") || href.startsWith("//")) return href;
const url = new URL(href, "http://localhost");
url.searchParams.set("locale", locale);
return `${url.pathname}${url.search}${url.hash}`;
}
// Both are asked for on every render (the <html> tag, the language picker) but depend only on the
// tag, so each locale pays the ICU lookup once per process.
const directions = new Map<string, "ltr" | "rtl">();
const labels = new Map<string, string>();
interface TextInfoLocale {
getTextInfo?: () => { direction?: string };
textInfo?: { direction?: string };
}
// The document direction for <html dir>. Derived from the locale's script, so an RTL catalog flips
// the document the day it is added.
export function textDirection(locale: string): "ltr" | "rtl" {
const cached = directions.get(locale);
if (cached !== undefined) return cached;
const direction = readDirection(locale);
directions.set(locale, direction);
return direction;
}
function readDirection(locale: string): "ltr" | "rtl" {
try {
const info = new Intl.Locale(locale) as Intl.Locale & TextInfoLocale;
const direction = info.getTextInfo?.().direction ?? info.textInfo?.direction;
return direction === "rtl" ? "rtl" : "ltr";
} catch {
return "ltr";
}
}
// A locale named in its own language ("svenska (Sverige)") — what a language picker should show.
export function localeLabel(locale: string): string {
const cached = labels.get(locale);
if (cached !== undefined) return cached;
let label: string;
try {
label = new Intl.DisplayNames([locale], { type: "language" }).of(locale) ?? locale;
} catch {
label = locale;
}
labels.set(locale, label);
return label;
}
function canonicalize(tag: string | null | undefined): string | null {
if (typeof tag !== "string" || tag === "") return null;
try {
return Intl.getCanonicalLocales(tag)[0] ?? null;
} catch {
return null;
}
}
+171
View File
@@ -0,0 +1,171 @@
// The core catalog: every string the host itself renders, and the baseline every other locale
// is checked against at boot (its keys and its plural/string kinds are the contract). Add a key
// here first, then to each sv-SE.ts et al — a locale that drifts stops the boot.
//
// Values are raw text; views escape them. A value carrying markup is rendered with <%- %> and must
// never interpolate untrusted data (see README → Translating).
const messages = {
"auth.continue": "Continue",
// Kratos labels its own form fields; these translate the ones the built-in identity schema uses,
// keyed on the input name. A deployment's extra traits keep Kratos' label until a plugin covers them.
"auth.field.email": "Email",
"auth.field.identifier": "Email",
"auth.field.password": "Password",
"auth.field.traits.email": "Email",
"auth.forgotPassword": "Forgot password?",
"auth.login.altLabel": "Create one",
"auth.login.altText": "Don't have an account?",
"auth.login.sub": "Welcome back. Enter your details to continue.",
"auth.login.title": "Sign in",
"auth.recovery.altLabel": "Sign in",
"auth.recovery.altText": "Remembered it?",
"auth.recovery.back": "Back to sign in",
"auth.recovery.sub": "Enter your email and we'll send you a recovery code.",
"auth.recovery.title": "Reset password",
"auth.registration.altLabel": "Sign in",
"auth.registration.altText": "Already have an account?",
"auth.registration.sub": "Get started — it only takes a minute.",
"auth.registration.title": "Create account",
"auth.settings.sub": "Update your account details.",
"auth.settings.title": "Account settings",
"auth.sso.divider": "or",
"auth.sso.label": "Single sign-on options",
"auth.verification.back": "Back to sign in",
"auth.verification.sub": "Enter the code we sent you.",
"auth.verification.title": "Verify your email",
"brand.sub": "Console",
"consent.allow": "Allow",
"consent.deny": "Deny",
"consent.notYou": "Not you?",
"consent.scope.email": "Your email address",
"consent.scope.offline_access": "Stay signed in (offline access)",
"consent.scope.openid": "Verify your identity",
"consent.scope.profile": "Your basic profile (name)",
"consent.signedInAs": "Signed in as",
"consent.sub": "{{client}} wants access to your account.",
"consent.title": "Authorize {{client}}",
"dashboard.starter.browse": "Browse the example plugin",
"dashboard.starter.intro":
"This is the built-in <code>/dashboard</code> — the gated home shown to a signed-in user. It's a placeholder so a fresh clone has something here; it holds no real data.",
"dashboard.starter.reference":
"See the plugin contract in <code>docs/plugin-contract.md</code> (the landing-pages section) and the bundled <code>plugins/scheduling/</code> reference.",
"dashboard.starter.replace":
"Replace it from a plugin: export a <code>dashboard</code> handler from your plugin's manifest and it owns this page, rendered against your own views with the native app shell (the same menu you see now) via <code>ctx.chrome</code>.",
"dashboard.starter.title": "Starter dashboard",
"dashboard.title": "Dashboard",
"error.403.body": "You don't have permission to view that (403).",
"error.403.docTitle": "Forbidden",
"error.403.title": "Access denied",
"error.404.body": "We couldn't find that page (404).",
"error.404.docTitle": "Not found",
"error.404.title": "Page not found",
"error.500.body": "An unexpected error occurred on our end (500).",
"error.500.docTitle": "Server error",
"error.500.title": "Something went wrong",
"error.503.body": "We can't reach the identity service right now (503). Please try again in a moment.",
"error.503.docTitle": "Sign-in unavailable",
"error.503.title": "Sign-in is temporarily unavailable",
"error.backHome": "Back home",
"error.backToSignIn": "Back to sign in",
"error.flow.body": "We couldn't complete that sign-in step. It may have expired or been opened twice — please try again.",
"error.flow.docTitle": "Sign-in error",
"error.flow.title": "Something went wrong",
"error.reference": "Reference: {{id}}",
"error.tryAgain": "Try again",
"field.optional": "Optional",
"filter.applied": "Applied",
"filter.appliedFilters": "Applied filters",
"filter.apply": "Apply filters",
"filter.clearAll": "Clear all",
"filter.dateRange": "Date range",
"filter.from": "From",
"filter.label": "Filter",
"filter.remove": "Remove {{label}} filter",
"filter.reset": "Reset",
"filter.search": "Search",
"filter.to": "To",
"filter.toSeparator": "to",
// Kratos writes the auth flow's own text and returns it with a stable numeric id. A key here
// replaces that text; anything unmapped renders Kratos' English as-is (README → Translating).
// Ids not in this list are deliberate: 1070002 is Kratos' generic identity-trait label — it is
// "Email" on the login form and "First name" on a registration form with that trait, so it can
// only be translated per field (auth.field.* above), never per id.
"kratos.1010022": "Sign in with password",
"kratos.1040001": "Sign up",
"kratos.1060003":
"An email containing a recovery code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.",
"kratos.1070008": "Resend code",
"kratos.1070009": "Continue",
"kratos.1070010": "Recovery code",
"kratos.1070011": "Verification code",
"kratos.1080003":
"An email containing a verification code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.",
"kratos.4000002": "This field is required.",
"kratos.4000006": "The credentials are invalid. Check for typos in your email address or password.",
"kratos.4000007": "An account with that email address already exists.",
"kratos.4060006": "That recovery code is invalid or has already been used. Please try again.",
"kratos.4070006": "That verification code is invalid or has already been used. Please try again.",
"landing.dashboard": "Go to your dashboard",
"landing.lead":
"{{brand}} is a self-hostable foundation for admin and operational UIs — sign-in, a config-driven menu, and a server-rendered, zero-JS design system. You add the domain-specific screens by dropping in plugin folders.",
"landing.register": "Create account",
"landing.signIn": "Log in",
"landing.title": "Operational web apps, without the boilerplate.",
"locale.label": "Language",
"nav.dashboard": "Dashboard",
"oauth.consentExpired": "This authorization request has expired. Please start again from the application you were signing in to.",
"oauth.loginExpired": "This sign-in request has expired. Please start again from the application you were signing in to.",
"oauth.logoutExpired": "This logout request has expired. Please start again from the application you were signing out of.",
"pagination.go": "Go",
"pagination.label": "Pagination",
"pagination.next": "Next page",
"pagination.of": "of",
"pagination.previous": "Previous page",
"pagination.rows": "Rows",
"shell.breadcrumb": "Breadcrumb",
"shell.closeMenu": "Close menu",
"shell.guest": "Guest",
"shell.mainNav": "Main navigation",
"shell.openMenu": "Open menu",
"shell.preferences": "Preferences",
"shell.profile": "Profile",
"shell.settings": "Settings",
"shell.sidebar": "Primary",
"shell.signedInAs": "Signed in as {{name}}",
"shell.signIn": "Sign in",
"shell.signOut": "Sign out",
"shell.skipToContent": "Skip to content",
"shell.toggleSection": "Toggle {{label}}",
"table.actions": "Actions",
"table.empty": "Nothing here yet.",
"table.row": "row",
"table.rowActions": "Row actions for {{name}}",
"table.select": "Select {{name}}",
"table.selectAll": "Select all rows",
"theme.auto": "Auto",
"theme.dark": "Dark",
"theme.label": "Color theme",
"theme.light": "Light",
};
// The shape every other core locale is written against: `const messages: CoreMessages = { … }` in
// sv-SE.ts et al, so a missing or misspelled key is a type error before the boot check ever runs.
export type CoreMessages = typeof messages;
export default messages;
+155
View File
@@ -0,0 +1,155 @@
import type { CoreMessages } from "./en-US.ts";
const messages: CoreMessages = {
"auth.continue": "Fortsätt",
"auth.field.email": "E-postadress",
"auth.field.identifier": "E-postadress",
"auth.field.password": "Lösenord",
"auth.field.traits.email": "E-postadress",
"auth.forgotPassword": "Glömt lösenordet?",
"auth.login.altLabel": "Skapa ett",
"auth.login.altText": "Har du inget konto?",
"auth.login.sub": "Välkommen tillbaka. Fyll i dina uppgifter för att fortsätta.",
"auth.login.title": "Logga in",
"auth.recovery.altLabel": "Logga in",
"auth.recovery.altText": "Kom du på det?",
"auth.recovery.back": "Tillbaka till inloggningen",
"auth.recovery.sub": "Ange din e-postadress så skickar vi en återställningskod.",
"auth.recovery.title": "Återställ lösenord",
"auth.registration.altLabel": "Logga in",
"auth.registration.altText": "Har du redan ett konto?",
"auth.registration.sub": "Kom igång — det tar bara en minut.",
"auth.registration.title": "Skapa konto",
"auth.settings.sub": "Uppdatera dina kontouppgifter.",
"auth.settings.title": "Kontoinställningar",
"auth.sso.divider": "eller",
"auth.sso.label": "Alternativ för enkel inloggning",
"auth.verification.back": "Tillbaka till inloggningen",
"auth.verification.sub": "Ange koden vi skickade till dig.",
"auth.verification.title": "Verifiera din e-postadress",
"brand.sub": "Konsol",
"consent.allow": "Tillåt",
"consent.deny": "Neka",
"consent.notYou": "Inte du?",
"consent.scope.email": "Din e-postadress",
"consent.scope.offline_access": "Håll dig inloggad (offlineåtkomst)",
"consent.scope.openid": "Verifiera din identitet",
"consent.scope.profile": "Din grundläggande profil (namn)",
"consent.signedInAs": "Inloggad som",
"consent.sub": "{{client}} vill få åtkomst till ditt konto.",
"consent.title": "Godkänn {{client}}",
"dashboard.starter.browse": "Utforska exempelpluginet",
"dashboard.starter.intro":
"Detta är den inbyggda <code>/dashboard</code> — den inloggade startsidan. Den är en platshållare så att en färsk klon har något här; den innehåller inga riktiga data.",
"dashboard.starter.reference":
"Se plugin-kontraktet i <code>docs/plugin-contract.md</code> (avsnittet om startsidorna) och referensen <code>plugins/scheduling/</code>.",
"dashboard.starter.replace":
"Ersätt den från ett plugin: exportera en <code>dashboard</code>-hanterare från pluginets manifest så äger det den här sidan, renderad mot dina egna vyer med appens eget skal (samma meny du ser nu) via <code>ctx.chrome</code>.",
"dashboard.starter.title": "Startpanel",
"dashboard.title": "Panel",
"error.403.body": "Du har inte behörighet att se det här (403).",
"error.403.docTitle": "Åtkomst nekad",
"error.403.title": "Åtkomst nekad",
"error.404.body": "Vi hittade inte sidan (404).",
"error.404.docTitle": "Sidan finns inte",
"error.404.title": "Sidan hittades inte",
"error.500.body": "Ett oväntat fel uppstod hos oss (500).",
"error.500.docTitle": "Serverfel",
"error.500.title": "Något gick fel",
"error.503.body": "Vi når inte identitetstjänsten just nu (503). Försök igen om en liten stund.",
"error.503.docTitle": "Inloggning otillgänglig",
"error.503.title": "Inloggningen är tillfälligt otillgänglig",
"error.backHome": "Tillbaka till startsidan",
"error.backToSignIn": "Tillbaka till inloggningen",
"error.flow.body": "Vi kunde inte slutföra det inloggningssteget. Det kan ha gått ut eller öppnats två gånger — försök igen.",
"error.flow.docTitle": "Inloggningsfel",
"error.flow.title": "Något gick fel",
"error.reference": "Referens: {{id}}",
"error.tryAgain": "Försök igen",
"field.optional": "Frivilligt",
"filter.applied": "Aktiva",
"filter.appliedFilters": "Aktiva filter",
"filter.apply": "Använd filter",
"filter.clearAll": "Rensa alla",
"filter.dateRange": "Datumintervall",
"filter.from": "Från",
"filter.label": "Filter",
"filter.remove": "Ta bort filtret {{label}}",
"filter.reset": "Återställ",
"filter.search": "Sök",
"filter.to": "Till",
"filter.toSeparator": "till",
"kratos.1010022": "Logga in med lösenord",
"kratos.1040001": "Skapa konto",
"kratos.1060003":
"Ett mejl med en återställningskod har skickats till adressen du angav. Har du inte fått något mejl, kontrollera stavningen och att du använder adressen du registrerade dig med.",
"kratos.1070008": "Skicka koden igen",
"kratos.1070009": "Fortsätt",
"kratos.1070010": "Återställningskod",
"kratos.1070011": "Verifieringskod",
"kratos.1080003":
"Ett mejl med en verifieringskod har skickats till adressen du angav. Har du inte fått något mejl, kontrollera stavningen och att du använder adressen du registrerade dig med.",
"kratos.4000002": "Fältet är obligatoriskt.",
"kratos.4000006": "Uppgifterna stämmer inte. Kontrollera att e-postadressen och lösenordet är rätt stavade.",
"kratos.4000007": "Det finns redan ett konto med den e-postadressen.",
"kratos.4060006": "Återställningskoden är ogiltig eller redan använd. Försök igen.",
"kratos.4070006": "Verifieringskoden är ogiltig eller redan använd. Försök igen.",
"landing.dashboard": "Gå till din panel",
"landing.lead":
"{{brand}} är en självhostad grund för administrativa och operativa gränssnitt — inloggning, en konfigurationsstyrd meny och ett serverrenderat designsystem utan JavaScript. Du lägger till de verksamhetsnära skärmarna genom att släppa in plugin-mappar.",
"landing.register": "Skapa konto",
"landing.signIn": "Logga in",
"landing.title": "Operativa webbappar, utan all pannplåt.",
"locale.label": "Språk",
"nav.dashboard": "Panel",
"oauth.consentExpired": "Den här behörighetsbegäran har gått ut. Börja om från appen du skulle logga in i.",
"oauth.loginExpired": "Den här inloggningsbegäran har gått ut. Börja om från appen du skulle logga in i.",
"oauth.logoutExpired": "Den här utloggningsbegäran har gått ut. Börja om från appen du skulle logga ut från.",
"pagination.go": "Visa",
"pagination.label": "Sidnavigering",
"pagination.next": "Nästa sida",
"pagination.of": "av",
"pagination.previous": "Föregående sida",
"pagination.rows": "Rader",
"shell.breadcrumb": "Sidsökväg",
"shell.closeMenu": "Stäng menyn",
"shell.guest": "Gäst",
"shell.mainNav": "Huvudmeny",
"shell.openMenu": "Öppna menyn",
"shell.preferences": "Inställningar",
"shell.profile": "Profil",
"shell.settings": "Inställningar",
"shell.sidebar": "Primär",
"shell.signedInAs": "Inloggad som {{name}}",
"shell.signIn": "Logga in",
"shell.signOut": "Logga ut",
"shell.skipToContent": "Hoppa till innehållet",
"shell.toggleSection": "Visa eller dölj {{label}}",
"table.actions": "Åtgärder",
"table.empty": "Inget här ännu.",
"table.row": "raden",
"table.rowActions": "Radåtgärder för {{name}}",
"table.select": "Markera {{name}}",
"table.selectAll": "Markera alla rader",
"theme.auto": "Auto",
"theme.dark": "Mörkt",
"theme.label": "Färgtema",
"theme.light": "Ljust",
};
export default messages;
+40
View File
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { Catalog } from "./catalog.ts";
import { createI18n } from "./runtime.ts";
const core = new Map<string, Catalog>([
["en-US", { "shell.signOut": "Sign out", "shop.title": "Core" }],
["sv-SE", { "shell.signOut": "Logga ut", "shop.title": "Kärna" }],
]);
const plugins = new Map<string, Map<string, Catalog>>([
["shop", new Map<string, Catalog>([["en-US", { "shop.new": "New order", "shop.title": "Shop" }], ["sv-SE", { "shop.new": "Ny order", "shop.title": "Butik" }]])],
["thin", new Map<string, Catalog>([["en-US", { "thin.title": "Thin" }]])],
]);
const i18n = createI18n({ available: ["en-US", "sv-SE"], core, plugins });
test("resolve applies the request precedence over the installed locales", () => {
assert.deepEqual(i18n.resolve({ param: "sv-SE" }), { explicit: true, locale: "sv-SE" });
assert.deepEqual(i18n.resolve({ acceptLanguage: "sv,en;q=0.5" }), { explicit: false, locale: "sv-SE" });
assert.deepEqual(i18n.resolve({}), { explicit: false, locale: "en-US" });
});
test("a plugin's own translation wins over the core one", () => {
assert.equal(i18n.translator("sv-SE", "shop")("shop.title"), "Butik");
assert.equal(i18n.translator("sv-SE")("shop.title"), "Kärna");
});
test("a plugin key untranslated in this locale falls back to the plugin's en-US, not to core", () => {
assert.equal(i18n.translator("sv-SE", "thin")("thin.title"), "Thin");
assert.equal(i18n.translator("sv-SE", "thin")("shell.signOut"), "Logga ut"); // core still speaks Swedish
});
test("an unknown plugin or locale still translates what it can", () => {
assert.equal(i18n.translator("sv-SE", "nope")("shell.signOut"), "Logga ut");
assert.equal(i18n.translator("de-DE")("shell.signOut"), "Sign out"); // uninstalled locale ⇒ the baseline
});
test("translators are memoised per locale and plugin", () => {
assert.equal(i18n.translator("sv-SE", "shop"), i18n.translator("sv-SE", "shop"));
assert.notEqual(i18n.translator("sv-SE", "shop"), i18n.translator("sv-SE"));
});
Binary file not shown.
+71
View File
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { Catalog } from "./catalog.ts";
import { createTranslator } from "./translate.ts";
const core: Catalog = {
"greeting": "Hello, {{name}}!",
"shell.signOut": "Sign out",
"shifts.count": { one: "{{count}} shift", other: "{{count}} shifts" },
};
const coreSv: Catalog = {
"greeting": "Hej, {{name}}!",
"shell.signOut": "Logga ut",
"shifts.count": { one: "{{count}} pass", other: "{{count}} pass" },
};
test("a key resolves from the first catalog that has it", () => {
const t = createTranslator({ catalogs: [coreSv, core], locale: "sv-SE" });
assert.equal(t("shell.signOut"), "Logga ut");
});
test("a key missing from the active locale falls back down the chain", () => {
const t = createTranslator({ catalogs: [{ "shell.signOut": "Logga ut" }, core], locale: "sv-SE" });
assert.equal(t("greeting", { name: "Li" }), "Hello, Li!");
});
test("a plugin catalog wins over the core one", () => {
const plugin: Catalog = { "shell.signOut": "Leave" };
const t = createTranslator({ catalogs: [plugin, core], locale: "en-US" });
assert.equal(t("shell.signOut"), "Leave");
});
test("a key missing everywhere renders as itself", () => {
const t = createTranslator({ catalogs: [core], locale: "en-US" });
assert.equal(t("nope.at.all"), "nope.at.all");
assert.equal(t("Shifts"), "Shifts"); // the nav-label contract: a plain label is its own fallback
});
test("{{vars}} interpolate; an unsupplied one stays visible", () => {
const t = createTranslator({ catalogs: [{ both: "{{a}} and {{b}}", n: "n={{n}}" }], locale: "en-US" });
assert.equal(t("both", { a: "x", b: "y" }), "x and y");
assert.equal(t("both", { a: "x" }), "x and {{b}}");
assert.equal(t("n", { n: 3 }), "n=3");
});
test("t returns raw text — escaping is the view's job", () => {
const t = createTranslator({ catalogs: [{ hi: "Hi {{name}}" }], locale: "en-US" });
assert.equal(t("hi", { name: "<b>ok</b>" }), "Hi <b>ok</b>");
});
test("plural messages select on count via Intl.PluralRules", () => {
const t = createTranslator({ catalogs: [core], locale: "en-US" });
assert.equal(t("shifts.count", { count: 1 }), "1 shift");
assert.equal(t("shifts.count", { count: 0 }), "0 shifts");
assert.equal(t("shifts.count", { count: 7 }), "7 shifts");
});
test("plural selection follows the active locale's own categories", () => {
const cs: Catalog = { files: { few: "{{count}} soubory", many: "{{count}} souboru", one: "{{count}} soubor", other: "{{count}} souborů" } };
const t = createTranslator({ catalogs: [cs], locale: "cs-CZ" });
assert.equal(t("files", { count: 1 }), "1 soubor");
assert.equal(t("files", { count: 3 }), "3 soubory");
assert.equal(t("files", { count: 10 }), "10 souborů");
});
test("a plural message without a count, or without the selected category, falls back to other", () => {
const t = createTranslator({ catalogs: [core], locale: "en-US" });
assert.equal(t("shifts.count"), "{{count}} shifts");
const partial = createTranslator({ catalogs: [{ x: { other: "many" } }], locale: "en-US" });
assert.equal(partial("x", { count: 1 }), "many");
});
+62
View File
@@ -0,0 +1,62 @@
// The translator: a key + vars → the string to render. Pure and synchronous — views call it
// as `t("shell.signOut")` and handlers as `ctx.t(...)`.
//
// Two rules the rest of the app leans on:
// · the lookup walks a catalog chain (plugin locale → plugin en-US → core locale → core en-US) and,
// when nothing has the key, returns the key itself. That is what makes a plain nav label like
// "Shifts" its own fallback — a manifest needs no catalog to keep working.
// · the result is raw text. Views escape with <%= %> exactly as they do for any other value, so a
// translation is never double-escaped, and a message that carries markup is rendered with <%- %>.
import { isPluralMessage, type Catalog, type PluralMessage } from "./catalog.ts";
export type TranslateVars = Record<string, number | string>;
export type Translate = (key: string, vars?: TranslateVars) => string;
export interface TranslatorOptions {
catalogs: Catalog[]; // lookup order, most specific first
locale: string;
}
const PLACEHOLDER = /\{\{(\w+)\}\}/g;
const pluralRules = new Map<string, Intl.PluralRules>();
export function createTranslator({ catalogs, locale }: TranslatorOptions): Translate {
return (key, vars) => {
for (const catalog of catalogs) {
const message = catalog[key];
if (message === undefined) continue;
return interpolate(isPluralMessage(message) ? selectPlural(message, locale, vars?.["count"]) : message, vars);
}
return key;
};
}
// The form for `count` in this locale, falling back to `other` (and then to any form present, so a
// half-filled catalog still renders words rather than a blank).
function selectPlural(message: PluralMessage, locale: string, count: number | string | undefined): string {
const category = count === undefined ? "other" : rulesFor(locale).select(Number(count));
return message[category] ?? message.other ?? Object.values(message)[0] ?? "";
}
function rulesFor(locale: string): Intl.PluralRules {
let rules = pluralRules.get(locale);
if (!rules) {
try {
rules = new Intl.PluralRules(locale);
} catch {
rules = new Intl.PluralRules("en-US");
}
pluralRules.set(locale, rules);
}
return rules;
}
// An unsupplied {{var}} is left standing: a visible placeholder beats a silent blank.
function interpolate(text: string, vars: TranslateVars | undefined): string {
if (vars === undefined) return text;
return text.replace(PLACEHOLDER, (whole, name: string) => {
const value = vars[name];
return value === undefined ? whole : String(value);
});
}
+48
View File
@@ -0,0 +1,48 @@
// The i18n block every rendered view receives. EJS passes a template's locals down into its
// includes, so injecting this at the top level is what lets any partial — core or plugin — call
// `t(...)` and read `locale` without its caller threading them through.
import { DEFAULT_LOCALE } from "./catalog.ts";
import { ENGLISH } from "./english.ts";
import type { RequestContext } from "../http/context.ts";
import { localeHref, localeLabel, textDirection } from "./locale.ts";
import type { Translate } from "./translate.ts";
export interface LocaleChoice {
current: boolean;
href: string; // this same page in that locale
label: string; // the locale named in its own language
tag: string;
}
export interface I18nLocals {
dir: "ltr" | "rtl";
locale: string;
localeHref: (href: string) => string;
localeSwitch: LocaleChoice[];
locales: string[];
t: Translate;
}
// For a render with no request behind it — a partial exercised directly, a one-off render: English,
// left-to-right, no language picker.
export const ENGLISH_LOCALS: I18nLocals = {
dir: "ltr",
locale: DEFAULT_LOCALE,
localeHref: (href) => href,
localeSwitch: [],
locales: [DEFAULT_LOCALE],
t: ENGLISH,
};
export function i18nLocals(ctx: RequestContext): I18nLocals {
const here = `${ctx.url.pathname}${ctx.url.search}`;
return {
dir: textDirection(ctx.locale),
locale: ctx.locale,
localeHref: (href) => ctx.localeHref(href),
localeSwitch: ctx.locales.map((tag) => ({ current: tag === ctx.locale, href: localeHref(here, tag), label: localeLabel(tag), tag })),
locales: ctx.locales,
t: ctx.t,
};
}