// 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"); } }