306 lines
18 KiB
JavaScript
306 lines
18 KiB
JavaScript
// 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"); }
|
|
}
|