feat: release v0.26 portable i18n and presentation adapters

This commit is contained in:
2026-09-29 13:31:42 +08:00
parent 6a95cad184
commit d1a747c36f
75 changed files with 5681 additions and 52 deletions

39
i18n/browser/README.md Normal file
View File

@@ -0,0 +1,39 @@
# 浏览器运行时
`runtime.mjs` 是可独立导入的 ES module,`runtime.d.mts` 提供类型。需要支持 ES modules、BigInt、TextEncoder 和 structuredClone 的现代浏览器;Node.js 22 用于本仓库验证。复数规则来自导出包,与 Go 共用固定 CLDR 数据,不调用浏览器自己的 Intl.PluralRules。语言选择使用包中 `locales` 的精确标识,不由浏览器再次改写别名或推断回退链。
```js
import { createLanguageController, fetchPack } from './runtime.mjs';
const urls = { en: '/locales/en.json', 'zh-Hans': '/locales/zh-Hans.json' };
const language = createLanguageController(
await fetchPack(urls.en),
locale => fetchPack(urls[locale])
);
await language.setLanguage('zh-Hans');
const text = language.current.t('ui', 'greeting', { name: 'Alex' });
```
`current.render` 额外返回实际语言和 fallback 标记。`setLanguage` 成功提交返回 true;被更新选择取代返回 false;最新加载失败抛错并保留 current。甚至“切回当前语言”也会取消旧请求的提交资格。未知语言、错误语言包、参数 schema 或启用语言集合变化都不能提交。
`fetchPack` 检查 HTTP 状态和 4 MiB 限额,支持 AbortSignal 和自定义 fetch。加载资源、鉴权、缓存策略与 URL 映射由调用者决定。它只返回 JSON 数据,`createLocalizer` / controller 负责完整资源检查。
`overlayPack(pack, documents)` 显式覆盖已导出键的完整消息并重新校验,不修改输入,不允许改变参数契约。例如替换品牌标题;覆写源消息时保留原 params。业务可在 loader 返回之前应用 overrides。
```ts
import type { Translate } from './messages.generated';
const t: Translate = language.current.t;
// t('ui', 'greeting', { name: 'Alex' })
```
应用需要在切换成功后重新取得 current.t。视图使用 textContent 或框架的文本绑定,动态参数不递归解析、不作为 HTML 执行。金额格式化、RTL 布局、表单、路由、业务提交和偏好持久化属于应用;运行时不重建页面或重新发业务请求。
完整表单示例:[examples/i18n](../../examples/i18n/README.md)。验证:
```sh
node --test i18n/browser/runtime.test.mjs
go test ./i18n -run TestBrowserConformance
# PATH 中提供 tsc 时,验证导出类型与实际 runtime 声明及错误参数诊断。
go test ./i18n -run TestTypeScriptConsumer -v
```

13
i18n/browser/assets.go Normal file
View File

@@ -0,0 +1,13 @@
// Package browseri18n embeds the optional framework-neutral browser module.
// Frontend projects can also copy/import runtime.mjs directly.
package browseri18n
import (
"embed"
"io/fs"
)
//go:embed runtime.mjs runtime.d.mts
var files embed.FS
func Files() fs.FS { return files }

View File

@@ -0,0 +1,6 @@
import { readFileSync } from "node:fs";
import assert from "node:assert/strict";
import { createLocalizer } from "./runtime.mjs";
const { pack, cases } = JSON.parse(readFileSync(0, "utf8"));
const localizer = createLocalizer(pack);
for (const sample of cases) assert.equal(localizer.t("common", sample.key, sample.params), sample.want);

View File

@@ -0,0 +1,29 @@
export type Kind = "string" | "boolean" | "integer" | "number";
export interface Pattern { text?: string; literal?: boolean; select?: string; plural?: string; cases?: Record<string, Pattern>; }
export interface Message { params?: Record<string, Kind>; value: Pattern; }
export interface Document { version: 1; namespace: string; locale: string; messages: Record<string, Message>; }
export interface Configuration { sourceLocale: string; defaultLocale: string; locales: string[]; fallbacks?: Record<string, string[]>; }
export interface Pack {
version: 1;
locale: string;
config: Configuration;
documents: Document[];
ruleVersion: "cldr-46.1";
pluralRules: Record<string, Partial<Record<"zero" | "one" | "two" | "few" | "many", string>>>;
license?: string;
}
export interface Rendered { text: string; locale: string; requested: string; fallback: boolean; }
export interface Localizer {
readonly locale: string;
readonly locales: readonly string[];
render(namespace: string, key: string, params?: Record<string, unknown>): Rendered;
t(namespace: string, key: string, params?: Record<string, unknown>): string;
}
export class MessageError extends Error { readonly kind: string; constructor(kind: string, problem: string); }
export function createLocalizer(pack: Pack): Localizer;
export function overlayPack(pack: Pack, overrides: Document[]): Pack;
export function createLanguageController(initialPack: Pack, loadPack: (locale: string) => Promise<Pack>): {
readonly current: Localizer;
setLanguage(locale: string): Promise<boolean>;
};
export function fetchPack(url: string | URL, options?: { signal?: AbortSignal; fetch?: typeof globalThis.fetch }): Promise<Pack>;

305
i18n/browser/runtime.mjs Normal file
View File

@@ -0,0 +1,305 @@
// Framework-neutral runtime for go-omni message resource version 1.
// No globals, DOM changes, persistence, HTML insertion, or business requests.
const owns = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
const kinds = new Set(["string", "boolean", "integer", "number"]);
const pluralCases = new Set(["zero", "one", "two", "few", "many", "other"]);
const integer = /^-?(0|[1-9][0-9]*)$/;
const paramName = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
const encoder = new TextEncoder();
const contractKey = Symbol("messageContract");
const bytes = text => encoder.encode(text).length;
const object = v => v !== null && typeof v === "object" && !Array.isArray(v);
const id = v => typeof v === "string" && v.length > 0 && bytes(v) <= 256 && v.trim() === v && !/[\u0000-\u001f\u007f-\u009f]/u.test(v);
const fail = (kind, problem) => { throw new MessageError(kind, problem); };
const fields = (value, allowed) => {
if (!object(value) || Object.keys(value).some(key => !allowed.includes(key))) fail("resource", "invalid resource fields");
};
const localeTag = value => {
// Tags are canonicalized by the Go exporter. Keep their identity verbatim:
// host Intl versions can remap aliases (e.g. cmn) or reject private-use tags.
if (typeof value !== "string" || bytes(value) > 128 || value === "und" ||
!/^(?:[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*|[xX](?:-[A-Za-z0-9]{1,8})+)$/.test(value)) fail("locale", "invalid exported locale identifier");
return value;
};
export class MessageError extends Error {
constructor(kind, problem) { super(`i18n: ${problem}`); this.name = "MessageError"; this.kind = kind; }
}
function scalar(kind, value) {
if (kind === "string" && typeof value === "string" && bytes(value) <= 65536) return value;
if (kind === "boolean" && typeof value === "boolean") return String(value);
if (kind === "number" && typeof value === "number" && Number.isFinite(value)) return String(value);
if (kind === "integer") {
let n;
if (typeof value === "bigint") n = value;
else if (typeof value === "number" && Number.isSafeInteger(value)) n = BigInt(value);
else if (typeof value === "string" && value.length <= 20 && integer.test(value)) n = BigInt(value);
else fail("parameter", "expected an exact integer");
if (n >= -(1n << 63n) && n < (1n << 63n)) return String(n);
}
fail("parameter", "wrong argument type, range or size");
}
function compile(pattern, schema, used, budget, depth = 0) {
fields(pattern, ["text", "literal", "select", "plural", "cases"]);
const text = pattern.text ?? "";
if (typeof text !== "string" || (pattern.literal !== undefined && typeof pattern.literal !== "boolean")) fail("pattern", "invalid text or literal flag");
budget.nodes++; budget.bytes += bytes(text);
if (depth > 24 || budget.nodes > 100000 || budget.bytes > 4194304 || bytes(text) > 65536) fail("resource", "pattern exceeds limits");
const select = pattern.select ?? "", plural = pattern.plural ?? "";
if (typeof select !== "string" || typeof plural !== "string") fail("pattern", "invalid branch parameter");
if (select || plural) {
if ((select && plural) || text || pattern.literal || !object(pattern.cases)) fail("pattern", "invalid branch");
const entries = Object.entries(pattern.cases), key = select || plural;
if (!owns(pattern.cases, "other") || entries.length > 128) fail("pattern", "branch requires other and at most 128 cases");
const kind = schema.get(key);
if (plural ? kind !== "integer" && kind !== "number" : kind !== "string" && kind !== "boolean") fail("parameter", "branch parameter type mismatch");
used.add(key);
const cases = new Map();
for (const [name, child] of entries) {
if (!id(name)) fail("pattern", "invalid case name");
if (plural && !pluralCases.has(name)) {
if (!name.startsWith("=") || !integer.test(name.slice(1)) || scalar("integer", name.slice(1)) !== name.slice(1)) fail("pattern", "invalid plural case");
}
cases.set(name, compile(child, schema, used, budget, depth + 1));
}
return { select, plural, cases };
}
if (pattern.cases !== undefined) fail("pattern", "cases require a branch");
if (pattern.literal) return { parts: [{ text }] };
const parts = [];
let literal = "";
const flush = () => { if (literal) parts.push({ text: literal }); literal = ""; };
for (let i = 0; i < text.length;) {
const ch = text[i];
if (ch !== "{" && ch !== "}") { literal += ch; i++; continue; }
if (text[i + 1] === ch) { literal += ch; i += 2; continue; }
if (ch === "}") fail("pattern", "unescaped closing brace");
const end = text.indexOf("}", i + 1);
if (end < 0) fail("pattern", "unclosed placeholder");
const key = text.slice(i + 1, end);
if (!schema.has(key)) fail("parameter", "undeclared placeholder");
used.add(key); flush(); parts.push({ param: key }); i = end + 1;
}
flush(); return { parts };
}
function pluralOperands(text) {
const [mantissa, exponent = "0"] = text.replace(/^-/, "").split("e");
let [whole, fraction = ""] = mantissa.split(".");
const digits = whole + fraction, point = whole.length + Number(exponent);
if (point <= 0) { whole = "0"; fraction = "0".repeat(-point) + digits; }
else if (point >= digits.length) { whole = digits + "0".repeat(point - digits.length); fraction = ""; }
else { whole = digits.slice(0, point); fraction = digits.slice(point); }
const tail = fraction.replace(/0+$/, ""), i = BigInt(whole || "0"), f = BigInt(fraction || "0");
return { n: i, i, v: BigInt(fraction.length), w: BigInt(tail.length), f, t: BigInt(tail || "0"), c: 0n, e: 0n, fractional: f !== 0n };
}
function compilePluralRules(rules) {
if (!object(rules) || Object.keys(rules).some(k => !["zero", "one", "two", "few", "many"].includes(k))) fail("resource", "invalid plural rules");
const compiled = new Map();
for (const [category, text] of Object.entries(rules)) {
if (typeof text !== "string" || text.length > 2048) fail("resource", "invalid plural rule size");
compiled.set(category, text.split(" or ").map(or => or.split(" and ").map(and => {
const match = /^([nivwftec])(?: % ([1-9][0-9]*))? (!=|=) ([0-9]+(?:\.\.[0-9]+)?(?:,\s*[0-9]+(?:\.\.[0-9]+)?)*)$/.exec(and.trim());
if (!match) fail("resource", "invalid CLDR plural condition");
const ranges = match[4].split(",").map(raw => { const [lo, hi = lo] = raw.trim().split(".."); const range = [BigInt(lo), BigInt(hi)]; if (range[0] > range[1]) fail("resource", "reversed plural range"); return range; });
return { operand: match[1], mod: match[2] ? BigInt(match[2]) : undefined, negate: match[3] === "!=", ranges };
})));
}
return text => {
const values = pluralOperands(text);
for (const category of ["zero", "one", "two", "few", "many"]) {
const rule = compiled.get(category);
if (rule?.some(group => group.every(condition => {
let value = values[condition.operand]; if (condition.mod) value %= condition.mod;
let found = (condition.operand !== "n" || !values.fractional) && condition.ranges.some(([lo, hi]) => value >= lo && value <= hi);
return condition.negate ? !found : found;
}))) return category;
}
return "other";
};
}
function render(pattern, locale, values, pluralRules) {
if (pattern.cases) {
let key = values.get(pattern.select);
if (pattern.plural) {
const value = values.get(pattern.plural);
key = `=${value}`;
if (!pattern.cases.has(key)) key = pluralRules.get(locale)(value);
}
return render(pattern.cases.get(key) ?? pattern.cases.get("other"), locale, values, pluralRules);
}
let text = "", size = 0;
for (const part of pattern.parts) {
const value = part.param === undefined ? part.text : values.get(part.param);
size += bytes(value); if (size > 1048576) fail("resource", "rendered message exceeds 1 MiB");
text += value;
}
return text;
}
// Compiles a detached pack for one locale. Arguments are never recursively
// interpreted as messages or HTML. Applications insert output as text.
export function createLocalizer(pack) {
fields(pack, ["version", "locale", "config", "documents", "ruleVersion", "pluralRules", "license"]);
if (pack.version !== 1 || !Array.isArray(pack.documents) || pack.documents.length > 1024) fail("resource", "unsupported pack version or size");
const c = pack.config;
fields(c, ["sourceLocale", "defaultLocale", "locales", "fallbacks"]);
const source = localeTag(c.sourceLocale), defaultLocale = localeTag(c.defaultLocale), locale = localeTag(pack.locale);
if (!Array.isArray(c.locales) || c.locales.length < 1 || c.locales.length > 64) fail("locale", "invalid enabled locales");
const locales = c.locales.map(localeTag), enabled = new Set(locales);
if (enabled.size !== locales.length || ![defaultLocale, locale].every(l => enabled.has(l))) fail("locale", "duplicate or disabled locale");
const fallback = new Map();
if (c.fallbacks !== undefined && !object(c.fallbacks)) fail("locale", "invalid fallback configuration");
for (const [raw, list] of Object.entries(c.fallbacks ?? {})) {
const tag = localeTag(raw);
if (!enabled.has(tag) || fallback.has(tag) || !Array.isArray(list) || list.length > 64) fail("locale", "invalid fallback configuration");
const targets = list.map(localeTag);
if (new Set(targets).size !== targets.length || targets.some(l => !enabled.has(l))) fail("locale", "invalid fallback targets");
fallback.set(tag, targets);
}
const state = new Map();
const visit = tag => { if (state.get(tag) === 1) fail("locale", "fallback cycle"); if (state.get(tag) === 2) return; state.set(tag, 1); for (const to of fallback.get(tag) ?? []) visit(to); state.set(tag, 2); };
for (const l of locales) visit(l);
const chain = [], seen = new Set();
const add = l => { if (seen.has(l)) return; seen.add(l); chain.push(l); for (const to of fallback.get(l) ?? []) add(to); };
add(locale); add(defaultLocale); add(source);
if (pack.ruleVersion !== "cldr-46.1" || !object(pack.pluralRules) || Object.keys(pack.pluralRules).length > 65) fail("resource", "unsupported plural rule version");
const pluralRules = new Map();
for (const tag of chain) {
if (!owns(pack.pluralRules, tag)) fail("resource", "missing plural rules for fallback language");
pluralRules.set(tag, compilePluralRules(pack.pluralRules[tag]));
}
const namespaces = new Map(), documents = [], all = new Map();
let count = 0;
for (const doc of pack.documents) {
fields(doc, ["version", "namespace", "locale", "messages"]);
if (doc.version !== 1 || !id(doc.namespace) || !object(doc.messages)) fail("resource", "invalid document");
const tag = localeTag(doc.locale);
if (!enabled.has(tag) && tag !== source) fail("locale", "pack contains a disabled locale");
if (!all.has(tag)) all.set(tag, new Map());
const byNamespace = all.get(tag);
if (!byNamespace.has(doc.namespace)) byNamespace.set(doc.namespace, new Set());
for (const key of Object.keys(doc.messages)) {
if (!id(key) || byNamespace.get(doc.namespace).has(key)) fail("resource", "invalid or duplicate message");
byNamespace.get(doc.namespace).add(key);
if (++count > 10000) fail("resource", "too many messages");
}
documents.push({ namespace: doc.namespace, tag, messages: doc.messages });
}
for (const doc of documents.filter(d => d.tag === source)) {
if (!namespaces.has(doc.namespace)) namespaces.set(doc.namespace, new Map());
const messages = namespaces.get(doc.namespace);
for (const [key, message] of Object.entries(doc.messages)) {
fields(message, ["params", "value"]);
if (!object(message.value) || message.params !== undefined && !object(message.params)) fail("resource", "message requires value and valid params");
const schema = new Map(Object.entries(message.params ?? {}));
if (schema.size > 64 || [...schema].some(([k, v]) => !paramName.test(k) || bytes(k) > 128 || !kinds.has(v))) fail("parameter", "invalid parameter schema");
messages.set(key, { schema, patterns: new Map() });
}
}
if (namespaces.size > 128) fail("resource", "too many namespaces");
const budget = { nodes: 0, bytes: 0 };
for (const doc of documents) {
for (const [key, message] of Object.entries(doc.messages)) {
fields(message, ["params", "value"]);
const entry = namespaces.get(doc.namespace)?.get(key);
if (!entry) fail("missing", "translation has no source message");
if (message.params !== undefined && (!object(message.params) || Object.keys(message.params).length !== entry.schema.size || Object.entries(message.params).some(([k, v]) => entry.schema.get(k) !== v))) fail("parameter", "translation schema differs from source");
const used = new Set();
entry.patterns.set(doc.tag, compile(message.value, entry.schema, used, budget));
if ([...entry.schema.keys()].some(k => !used.has(k))) fail("parameter", "declared parameter is unused");
}
}
const translate = (namespace, key, params = {}) => {
const entry = namespaces.get(namespace)?.get(key);
if (!entry) fail("missing", "unknown message");
if (!object(params)) fail("parameter", "expected parameter object");
const values = new Map();
for (const [name, kind] of entry.schema) {
if (!owns(params, name)) fail("parameter", "missing argument");
values.set(name, scalar(kind, params[name]));
}
if (Object.keys(params).some(k => !entry.schema.has(k))) fail("parameter", "undeclared argument");
for (const tag of chain) {
const pattern = entry.patterns.get(tag);
if (pattern) return { text: render(pattern, tag, values, pluralRules), locale: tag, requested: locale, fallback: tag !== locale };
}
fail("missing", "no message in fallback chain");
};
const contract = JSON.stringify([pack.ruleVersion, source, defaultLocale, locales, [...fallback].sort(), [...namespaces].sort().map(([ns, messages]) => [ns, [...messages].sort().map(([key, entry]) => [key, [...entry.schema].sort()])])]);
return Object.freeze({ [contractKey]: contract, locale, locales: Object.freeze([...locales]), render: translate, t: (ns, key, params) => translate(ns, key, params).text });
}
// Only a fully loaded and validated pack can replace the current language.
// Last selection wins. A failed latest request keeps the committed language;
// older requests cannot commit after it, even when they later succeed.
export function createLanguageController(initialPack, loadPack) {
if (typeof loadPack !== "function") fail("resource", "loader required");
let current = createLocalizer(initialPack), sequence = 0;
const enabled = [...current.locales];
return Object.freeze({
get current() { return current; },
async setLanguage(raw) {
const ticket = ++sequence, locale = localeTag(raw);
if (!enabled.includes(locale)) fail("locale", "locale is not enabled");
if (locale === current.locale) return true;
let pack;
try { pack = await loadPack(locale); }
catch (error) { if (ticket !== sequence) return false; throw error; }
if (ticket !== sequence) return false;
const next = createLocalizer(pack);
if (next.locale !== locale || next[contractKey] !== current[contractKey]) fail("locale", "loaded pack does not match requested language/configuration");
current = next;
return true;
}
});
}
// Explicit whole-message overrides. Originals stay untouched; unknown keys and
// changed parameter schemas are rejected by validation before the pack is used.
export function overlayPack(pack, overrides) {
createLocalizer(pack);
if (!Array.isArray(overrides) || overrides.length > 1024) fail("resource", "invalid overrides");
const result = structuredClone(pack), seen = new Set();
for (const doc of overrides) {
fields(doc, ["version", "namespace", "locale", "messages"]);
if (doc.version !== 1 || !object(doc.messages)) fail("resource", "invalid override document");
const tag = localeTag(doc.locale);
const target = result.documents.find(d => d.namespace === doc.namespace && localeTag(d.locale) === tag);
if (!target) fail("resource", "override must target an exported namespace and locale");
for (const [key, message] of Object.entries(doc.messages)) {
const identity = JSON.stringify([tag, doc.namespace, key]);
if (seen.has(identity) || !owns(target.messages, key)) fail("resource", "unknown or duplicate override key");
seen.add(identity);
Object.defineProperty(target.messages, key, { value: structuredClone(message), enumerable: true, writable: true, configurable: true });
}
}
const checked = createLocalizer(result), original = createLocalizer(pack);
if (checked[contractKey] !== original[contractKey]) fail("parameter", "overrides cannot change the message contract");
return result;
}
// Optional bounded fetch loader. The caller supplies the URL mapping; locale is
// never treated as an arbitrary path or endpoint by this runtime.
export async function fetchPack(url, { signal, fetch: fetcher = globalThis.fetch } = {}) {
const response = await fetcher(url, { signal, credentials: "same-origin" });
if (!response.ok) fail("load", "language pack request failed");
if (!response.body) fail("load", "language pack response has no body");
const reader = response.body.getReader(), chunks = []; let size = 0;
try {
for (;;) {
const { done, value } = await reader.read(); if (done) break;
size += value.byteLength;
if (size > 4194304) { await reader.cancel(); fail("resource", "language pack exceeds 4 MiB"); }
chunks.push(value);
}
} finally { reader.releaseLock(); }
const raw = new Uint8Array(size); let offset = 0;
for (const chunk of chunks) { raw.set(chunk, offset); offset += chunk.length; }
try { return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(raw)); }
catch { fail("load", "invalid language pack JSON"); }
}

View File

@@ -0,0 +1,50 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createLocalizer, createLanguageController, overlayPack, fetchPack } from "./runtime.mjs";
const pack = locale => ({ version: 1, locale, ruleVersion: "cldr-46.1", pluralRules: { en: { one: "i = 1 and v = 0" }, fr: { one: "i = 0,1" }, de: { one: "i = 1 and v = 0" } }, config: { sourceLocale: "en", defaultLocale: "en", locales: ["en", "fr", "de"] }, documents: [
{ version: 1, locale: "en", namespace: "ui", messages: { title: { value: { text: "English" } }, greet: { params: { name: "string" }, value: { text: "Hello {name}" } } } },
... (locale === "en" ? [] : [{ version: 1, locale, namespace: "ui", messages: { title: { value: { text: locale } } } }])
] });
test("detached resources, explicit overrides, text arguments and fallback", () => {
const data = pack("fr"), localizer = createLocalizer(data);
data.documents[0].messages.greet.value.text = "changed";
assert.equal(localizer.t("ui", "greet", { name: "<b>{name}</b>" }), "Hello <b>{name}</b>");
assert.equal(localizer.render("ui", "greet", { name: "x" }).locale, "en");
const original = pack("en");
const overridden = overlayPack(original, [{ version: 1, namespace: "ui", locale: "en", messages: { title: { value: { text: "Custom" } } } }]);
assert.equal(createLocalizer(overridden).t("ui", "title"), "Custom");
assert.equal(createLocalizer(original).t("ui", "title"), "English");
assert.throws(() => overlayPack(original, [{ version: 1, namespace: "ui", locale: "en", messages: { unknown: { value: {} } } }]));
});
test("latest selection wins and failures preserve committed state", async () => {
const pending = new Map();
const controller = createLanguageController(pack("en"), locale => new Promise((resolve, reject) => pending.set(locale, { resolve, reject })));
const french = controller.setLanguage("fr"), german = controller.setLanguage("de");
pending.get("de").resolve(pack("de")); assert.equal(await german, true);
pending.get("fr").resolve(pack("fr")); assert.equal(await french, false);
assert.equal(controller.current.locale, "de");
const failed = controller.setLanguage("fr"); pending.get("fr").reject(Error("offline"));
await assert.rejects(failed); assert.equal(controller.current.locale, "de");
const stale = controller.setLanguage("fr"); await controller.setLanguage("de");
pending.get("fr").reject(Error("stale failure")); assert.equal(await stale, false);
assert.equal(controller.current.t("ui", "title"), "de");
});
test("bad packs cannot commit or change enabled languages/schemas", async () => {
const controller = createLanguageController(pack("en"), async () => { const data = pack("fr"); delete data.documents[0].messages.greet; return data; });
await assert.rejects(controller.setLanguage("fr")); assert.equal(controller.current.locale, "en");
await assert.rejects(controller.setLanguage("zz"));
const cycle = pack("en"); cycle.config.fallbacks = { en: ["fr"], fr: ["en"] }; assert.throws(() => createLocalizer(cycle));
const duplicate = pack("en"); duplicate.documents.push(duplicate.documents[0]); assert.throws(() => createLocalizer(duplicate));
});
test("bounded fetching and validation before switching", async () => {
const data = await fetchPack("/en.json", { fetch: async () => new Response(JSON.stringify(pack("en"))) });
assert.equal(createLocalizer(data).locale, "en");
await assert.rejects(fetchPack("/bad", { fetch: async () => new Response("bad", { status: 500 }) }));
await assert.rejects(fetchPack("/bad", { fetch: async () => new Response("bad") }));
await assert.rejects(fetchPack("/huge", { fetch: async () => new Response(new Uint8Array(4194305)) }));
});

26
i18n/browser/testdata/consumer.mts vendored Normal file
View File

@@ -0,0 +1,26 @@
import {createLocalizer, createLanguageController, fetchPack, overlayPack} from './runtime.mjs';
import type {Pack} from './runtime.mjs';
import type {Translate} from './messages.generated.mjs';
declare const pack: Pack;
const messages = createLocalizer(pack);
const t: Translate = messages.t;
t('common', 'greeting', {first_name: 'Alex'});
t('common', 'count', {count: 9223372036854775807n});
t('common', 'count', {count: '9223372036854775807'});
t('common', 'literal', {});
// @ts-expect-error a message without parameters accepts only an empty object
t('common', 'literal', {extra: 1});
// @ts-expect-error missing parameter
t('common', 'greeting', {});
// @ts-expect-error invalid parameter type
t('common', 'greeting', {first_name: false});
// @ts-expect-error undeclared parameter
t('common', 'greeting', {first_name: 'Alex', secret: 'extra'});
// @ts-expect-error unknown key
t('common', 'unknown', {});
// @ts-expect-error unknown namespace
t('missing', 'literal', {});
const controller = createLanguageController(pack, locale => fetchPack('/' + locale + '.json'));
void controller.setLanguage('en');
overlayPack(pack, [{version: 1, locale: 'en', namespace: 'common', messages: {literal: {value: {text: 'Custom'}}}}]);