feat: release v0.26 portable i18n and presentation adapters
This commit is contained in:
39
i18n/browser/README.md
Normal file
39
i18n/browser/README.md
Normal 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
13
i18n/browser/assets.go
Normal 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 }
|
||||
6
i18n/browser/conformance.mjs
Normal file
6
i18n/browser/conformance.mjs
Normal 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);
|
||||
29
i18n/browser/runtime.d.mts
Normal file
29
i18n/browser/runtime.d.mts
Normal 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
305
i18n/browser/runtime.mjs
Normal 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"); }
|
||||
}
|
||||
50
i18n/browser/runtime.test.mjs
Normal file
50
i18n/browser/runtime.test.mjs
Normal 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
26
i18n/browser/testdata/consumer.mts
vendored
Normal 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'}}}}]);
|
||||
Reference in New Issue
Block a user