feedback5: merge W5AD — language packs as the first type of a generic content pack: a language is a file in the language folder (public/i18n/<code>.json with a _meta manifest, optional data/i18n/<code>.json) or a pack folder (packs/<id>/pack.json); the server lists them live at /packs/index.json, the menu shows every pack, strings fall back pack → base → declared fallback → Chinese, broken placeholders are skipped, plurals follow Intl.PluralRules (zh / en unchanged); tools i18n.mjs template / check, packs.mjs list / index, build-i18n --lang ja|ko|zh-TW; the release zips ship packs/ and packs/index.json; docs/PACKS.md, docs/I18N.md §5; §25.16 for now (renumbered next); golden: unchanged vs feedback5; vs W5AD exactly what feedback5 changed since f1df0d3 (CB5's 18 bot matches, CB2's roster 13 / bonds 7 / fields 6, CB4's roster-016 / 7 boss fields / standin-04/06/08 / diy-028/124)

No conflicts outside docs/history/0.2.0.md; the §25 intro keeps one sentence per stream (W5AD's line that also named
the hotkeys' §25.15 joined to CB4's).

Gate: non-browser suites 4981 / 4981 (test/i18n-packs 12 new, test/package 1 new), npm run golden ok,
test/golden.test.js 9 / 9, lint 0 errors, check:imports ok, typecheck ok, docs-consistency + docs-paths 31 / 31.
This commit is contained in:
sganggs committed 2026-10-07 03:27:10 +08:00
commit 66cdf493fc
30 files changed
+2670 -288

No files matched your search

+299 -49
View File
@@ -2,51 +2,157 @@
//
// The Chinese source string is the message id: `t('整备区已满')`, `t('还剩 {n} 秒', { n })`. A message without a
// translation in the current language falls back to the Chinese text, so an untranslated string is never blank.
// Chinese ('zh') is the default language; English ('en') is a switch (the owner's decision of 2026-10-05: an English
// browser still opens in Chinese until the player picks English). docs/I18N.md explains how to add and translate
// strings.
// Chinese ('zh') is the default language; every other language is a pack (shared/i18nPacks.js): a file in
// public/i18n/ (or a pack folder, shared/packs.js) that the client lists from /packs/index.json and loads on demand
// (ui/lang.js). Nothing here knows a language by name (the owner's decisions of 2026-10-05 and 2026-10-07: Chinese by
// default, a language is a file in the language folder). docs/I18N.md explains how to add and translate strings and how
// to add a language.
//
// Fallback chain per string: the chosen pack → its base (pt-BR → pt; zh-TW has none) → the packs its `_meta.fallback`
// names (e.g. English) → the Chinese msgid (i18nPacks.js computeChain). A partial pack is fine: what it lacks falls
// through. A translation that drops a placeholder of its msgid, or uses one the caller does not pass, or has a broken
// plural form, is skipped like a missing one (setI18nWarn reports it; the client warns in development).
//
// Context: the same Chinese text may need two translations ('关闭' = "Close" on a button, "Off" on a switch):
// `tc('toggle', '关闭')` looks up the key 'toggle::关闭' first, then '关闭'; Chinese shows '关闭'.
// Module-level tables are evaluated once, before a language can change: mark their strings N_('…') (a no-op that
// `tc('toggle', '关闭')` looks up the key 'toggle::关闭' first, then '关闭' (in each language of the chain); Chinese shows
// '关闭'. Module-level tables are evaluated once, before a language can change: mark their strings N_('…') (a no-op that
// tools/i18n.mjs extracts) and call t() where they are shown.
//
// Placeholders (the same in msgids and translations):
// {name} the value of params.name (params may also be an array: {0}, {1} …)
// {n|one|other} `one` when Number(params.n) === 1, else `other` — the only plural form (English needs no more)
// A value that is an array renders as a list joined with the language's separator ('、' / ', '); a value `{ dn: '…' }`
// (made by `dn()`) is a game-data name, translated through the name resolver the client installs (setNameResolver).
// A placeholder whose value is missing stays verbatim, so official templates such as '{0}博士…' survive untouched.
// {name} the value of params.name (params may also be an array: {0}, {1} …)
// {n|one|other} two plain forms: `one` when the language's plural category of Number(params.n) is 'one'
// (Intl.PluralRules; in English exactly when n is 1), else `other`
// {n|one:…|few:…|other:…} named forms for languages with more categories (zero, one, two, few, many, other; `other`
// is required): the form of the category, else `other`
// A value that is an array renders as a list joined with the language's separator (the translation of 'list::、', '、' in
// Chinese, ', ' without one); a value `{ dn: '…' }` (made by `dn()`) is a game-data name, translated through the name
// resolver the client installs (setNameResolver). A placeholder whose value is missing stays verbatim, so official
// templates such as '{0}博士…' survive untouched.
//
// Server → client messages (m.toast / m.ticker): the server builds `msg(msgid, params)` and sends
// `wireMessage(m)` = { text: <the Chinese rendering>, msgid, params } — `text` keeps older clients working, the client
// renders `translateWire(frame)` in its own language. A plain string message is its own msgid.
/** Supported languages; the first one is the default and the source language of every msgid. */
export const LANGS = Object.freeze(['zh', 'en']);
export const DEFAULT_LANG = 'zh';
import { SOURCE_LANG, canonicalLang, primaryLang, packMeta, computeChain } from './i18nPacks.js';
/** List separator per language (array params). */
const LIST_SEP = Object.freeze({ zh: '、', en: ', ' });
/** The default language, the source language of every msgid. */
export const DEFAULT_LANG = SOURCE_LANG;
/** @typedef {import('./i18nPacks.js').LangMeta} LangMeta */
/** The source language: built in, no pack, no catalog. */
const SOURCE_META = Object.freeze({
code: SOURCE_LANG, name: '中文', englishName: 'Chinese (Simplified)', authors: [], credits: '', version: '', app: '', // i18n-ignore: its own name
base: null, fallback: [], complete: true, numberUnits: ['万', '亿'], builtin: true, // i18n-ignore: the Chinese number units
});
/** @type {Map<string, LangMeta>} code → metadata: the source, the packs of the index, languages given messages */
const registry = new Map([[SOURCE_LANG, /** @type {LangMeta} */ (/** @type {unknown} */ (SOURCE_META))]]);
/** @type {Map<string, Map<string, string>>} lang → msgid → translation */
const catalogs = new Map();
let current = DEFAULT_LANG;
/** @type {Set<(lang: string, prev: string) => void>} */
const listeners = new Set();
/** @type {Set<() => void>} */
const langsListeners = new Set();
/** @type {((name: string, lang: string) => string) | null} */
let nameResolver = null;
/** @type {Map<string, string[]>} lang → its chain (cleared whenever the registry changes) */
const chains = new Map();
/** @type {Map<string, Map<string, { ok: boolean, problems: string[], extras: string[] }>>} lang → key → checkTranslation */
const checked = new Map();
/** @type {((info: { lang: string, key: string, problems: string[] }) => void) | null} */
let warnHook = null;
/** lang + key already reported (once each) */
const warned = new Set();
const report = (lang, key, problems) => {
if (!warnHook) return;
const id = `${lang}\u0000${key}`;
if (warned.has(id)) return;
warned.add(id);
try { warnHook({ lang, key, problems }); } catch { /* a broken hook never breaks a render */ }
};
const notifyLangs = () => {
chains.clear();
for (const fn of [...langsListeners]) {
try { fn(); } catch (err) { /** @type {any} */ (globalThis).console?.error('[i18n] listener failed', err); }
}
};
/**
* A supported language code from loose input ('en-US' → 'en', 'zh_CN' → 'zh'), or null.
* Register languages (the client: the language entries of /packs/index.json, shared/packs.js langMetaOf). A known code
* is merged with what is there. Listeners of onLangsChange run once.
* @param {Partial<LangMeta>[]} entries each with a `code`
* @returns {number} languages registered
*/
export function registerLangs(entries) {
let n = 0;
for (const e of Array.isArray(entries) ? entries : []) {
const code = canonicalLang(e?.code);
if (!code || code === SOURCE_LANG) continue;
const prev = registry.get(code);
registry.set(code, /** @type {LangMeta} */ ({ ...(prev || packMeta(code, null)), ...e, code }));
n++;
}
if (n) notifyLangs();
return n;
}
/**
* The known languages, the source first, then by code: what the language menu lists.
* @returns {LangMeta[]}
*/
export function getLangs() {
const rest = [...registry.values()].filter((m) => m.code !== SOURCE_LANG).sort((a, b) => (a.code < b.code ? -1 : a.code > b.code ? 1 : 0));
return [registry.get(SOURCE_LANG), ...rest];
}
/** The metadata of a known language, or null. @param {string} [lang] */
export const langInfo = (lang = current) => registry.get(String(lang)) || null;
/**
* Subscribe to changes of the known languages (the index arrived, a pack loaded).
* @param {() => void} fn
* @returns {() => void} unsubscribe
*/
export function onLangsChange(fn) {
langsListeners.add(fn);
return () => { langsListeners.delete(fn); };
}
/**
* The languages a lookup tries for `lang`, best first; the Chinese msgid comes after them (i18nPacks.js computeChain).
* Empty for the source language.
* @param {string} [lang]
* @returns {string[]}
*/
export function langChain(lang = current) {
const code = String(lang);
if (code === SOURCE_LANG) return [];
let c = chains.get(code);
if (!c) {
c = computeChain(code, (x) => registry.get(x), (x) => registry.has(x) || catalogs.has(x));
chains.set(code, c);
}
return c;
}
/**
* A known language code from loose input ('en-US' → 'en' when 'en' is known, 'zh_CN' → 'zh', 'PT-br' → 'pt-BR'), or
* null: the exact code, then the same code in any case, then its language part.
* @param {unknown} v
* @returns {string|null}
*/
export function normalizeLang(v) {
const s = typeof v === 'string' ? v.trim().toLowerCase() : '';
if (!s) return null;
for (const l of LANGS) if (s === l || s.startsWith(`${l}-`) || s.startsWith(`${l}_`)) return l;
return null;
const code = canonicalLang(v);
if (!code) return null;
if (registry.has(code)) return code;
const lower = code.toLowerCase();
for (const c of registry.keys()) if (c.toLowerCase() === lower) return c;
const p = primaryLang(code);
return registry.has(p) ? p : null;
}
/** The current language code. */
@@ -79,23 +185,30 @@ export function onLangChange(fn) {
}
/**
* Add translations for a language (merged into what is there). Keys starting with '_' are metadata and skipped; only
* non-empty string values count.
* Add translations for a language (merged into what is there; the language is registered — with the pack's `_meta`
* when the dict carries one). Keys starting with '_' are metadata and skipped; only non-empty string values count.
* @param {string} lang
* @param {Record<string, unknown> | null | undefined} dict msgid → translation
* @param {Record<string, unknown> | null | undefined} dict msgid → translation (a whole pack file)
* @returns {number} entries added
*/
export function addMessages(lang, dict) {
const l = normalizeLang(lang);
const l = canonicalLang(lang);
if (!l || !dict || typeof dict !== 'object') return 0;
let cat = catalogs.get(l);
if (!cat) { cat = new Map(); catalogs.set(l, cat); }
checked.delete(l);
let n = 0;
for (const [k, v] of Object.entries(dict)) {
if (k.startsWith('_') || typeof v !== 'string' || !v) continue;
cat.set(k, v);
n++;
}
if (l !== SOURCE_LANG && !registry.get(l)?.packId && (dict._meta || !registry.has(l))) {
// a language the pack index did not list (no index, tests): what its own `_meta` says; an index entry (made from
// the pack's manifest by the server) stays as it is
registry.set(l, packMeta(l, dict));
notifyLangs();
} else chains.clear();
return n;
}
@@ -106,14 +219,15 @@ export function addMessages(lang, dict) {
* @returns {number}
*/
export function setMessages(lang, dict) {
const l = normalizeLang(lang);
const l = canonicalLang(lang);
if (!l) return 0;
catalogs.delete(l);
checked.delete(l);
return addMessages(l, dict);
}
/**
* Whether a msgid has a translation in a language (the default language always "has" its own msgids).
* Whether a msgid has a translation in a language's own pack (the default language always "has" its own msgids).
* @param {string} msgid
* @param {string} [lang]
*/
@@ -122,6 +236,13 @@ export function hasMessage(msgid, lang = current) {
return l === DEFAULT_LANG || !!catalogs.get(l)?.has(String(msgid));
}
/**
* Install the reporter of skipped translations (a dropped or unknown placeholder, a broken plural form): called once per
* language and key. The client installs console.warn in development (ui/lang.js); null turns it off.
* @param {((info: { lang: string, key: string, problems: string[] }) => void) | null} fn
*/
export function setI18nWarn(fn) { warnHook = typeof fn === 'function' ? fn : null; warned.clear(); }
/**
* Install the translator of game-data names (`{ dn: '…' }` params and `tName`). The client maps a Chinese name to the
* current language through the localized game data; without a resolver names stay Chinese.
@@ -150,6 +271,19 @@ export const dn = (name) => ({ dn: name == null ? '' : String(name) });
const own = (o, k) => o != null && typeof o === 'object' && Object.prototype.hasOwnProperty.call(o, k);
/**
* The list separator of a language: its translation of 'list::、' through its chain; '、' in Chinese; ', ' without one.
* @param {string} lang
*/
function listSep(lang) {
if (lang === DEFAULT_LANG) return '、';
for (const l of langChain(lang)) {
const s = catalogs.get(l)?.get('list::、');
if (s) return s;
}
return ', ';
}
/**
* Render one param value.
* @param {unknown} v
@@ -159,7 +293,7 @@ const own = (o, k) => o != null && typeof o === 'object' && Object.prototype.has
*/
function renderValue(v, lang, resolve) {
if (v == null) return '';
if (Array.isArray(v)) return v.map((x) => renderValue(x, lang, resolve)).filter((s) => s !== '').join(LIST_SEP[lang] || LIST_SEP.en);
if (Array.isArray(v)) return v.map((x) => renderValue(x, lang, resolve)).filter((s) => s !== '').join(listSep(lang));
if (typeof v === 'object') {
if (own(v, 'dn')) {
const name = String(/** @type {any} */ (v).dn ?? '');
@@ -171,10 +305,72 @@ function renderValue(v, lang, resolve) {
return String(v);
}
const PLACEHOLDER = /\{([A-Za-z0-9_$]+)(?:\|([^{}|]*)\|([^{}|]*))?\}/g;
/** `{name}` or a plural `{name|form|form…}` (two or more forms); group 2 is the forms with their leading '|'. */
const PLACEHOLDER = /\{([A-Za-z0-9_$]+)((?:\|[^{}|]*){2,})?\}/g;
/** The CLDR plural categories (Intl.PluralRules). */
export const PLURAL_CATEGORIES = Object.freeze(['zero', 'one', 'two', 'few', 'many', 'other']);
const NAMED_FORM = /^(zero|one|two|few|many|other):/;
/**
* Fill a template's placeholders (pure: no global language state).
* The forms of a plural placeholder ('|second|seconds' or '|one:день|few:дня|many:дней|other:дня') by category, or null
* when malformed: two plain forms are one / other; named forms need `other` and no repeats; plain and named are not
* mixed, and three or more plain forms are refused (the order of a language's categories is not stable across engines).
* @param {string} forms the forms with their leading '|'
* @returns {Record<string, string> | null}
*/
export function parsePluralForms(forms) {
const parts = String(forms).slice(1).split('|');
const named = parts.map((p) => NAMED_FORM.exec(p));
if (named.every(Boolean)) {
/** @type {Record<string, string>} */
const out = {};
for (let i = 0; i < parts.length; i++) {
const cat = named[i][1];
if (own(out, cat)) return null;
out[cat] = parts[i].slice(named[i][0].length);
}
return own(out, 'other') ? out : null;
}
if (parts.length === 2 && !named.some(Boolean)) return { one: parts[0], other: parts[1] };
return null;
}
/** @type {Map<string, any>} lang → Intl.PluralRules (null: the runtime does not know the language) */
const pluralRules = new Map();
/**
* The plural category of a number in a language (Intl.PluralRules; a language the runtime does not know counts one
* for exactly 1, other for the rest — the English rule).
* @param {string} lang
* @param {number} n
* @returns {string}
*/
export function pluralCategory(lang, n) {
if (!Number.isFinite(n)) return 'other';
let pr = pluralRules.get(lang);
if (pr === undefined) {
pr = null;
try {
const PR = /** @type {any} */ (globalThis).Intl?.PluralRules;
const tag = PR && [lang, primaryLang(lang)].find((x) => PR.supportedLocalesOf([x]).length);
if (tag) pr = new PR(tag);
} catch { /* no Intl data for it */ }
pluralRules.set(lang, pr);
}
if (pr) return pr.select(n);
return n === 1 ? 'one' : 'other';
}
/** The form of a plural placeholder for a value (null when the forms are malformed). */
function pluralForm(forms, v, lang) {
const f = parsePluralForms(forms);
if (!f) return null;
const cat = pluralCategory(lang, Number(v));
return own(f, cat) ? f[cat] : f.other;
}
/**
* Fill a template's placeholders (pure: no global language state but the list separator and plural rules of `lang`).
* @param {unknown} template
* @param {Record<string, unknown> | unknown[] | null | undefined} [params]
* @param {{ lang?: string, resolveName?: ((name: string, lang: string) => string) | null }} [opts]
@@ -185,14 +381,79 @@ export function format(template, params, opts = {}) {
if (!params || typeof params !== 'object' || !s.includes('{')) return s;
const lang = normalizeLang(opts.lang) || DEFAULT_LANG;
const resolve = opts.resolveName || null;
return s.replace(PLACEHOLDER, (m, key, one, other) => {
return s.replace(PLACEHOLDER, (m, key, forms) => {
if (!own(params, key)) return m;
const v = /** @type {any} */ (params)[key];
if (one !== undefined) return Number(v) === 1 ? one : other;
if (forms !== undefined) return pluralForm(forms, v, lang) ?? m;
return renderValue(v, lang, resolve);
});
}
/**
* Whether a translation fits its msgid: it keeps every plain placeholder of the msgid as a plain placeholder, and its
* plural forms parse. `extras`: the placeholder names it uses that the msgid lacks — allowed when the caller passes them
* (a plural selector beside a markup value: '{n} {count|unit|units}'), which t() checks per call and
* `node tools/i18n.mjs check` against the call sites.
* @param {string} msgid the msgid (for a tc() key, the part after '::')
* @param {string} text the translation
* @returns {{ ok: boolean, problems: string[], extras: string[] }}
*/
export function checkTranslation(msgid, text) {
const names = new Set();
const plain = new Set();
for (const m of String(msgid).matchAll(PLACEHOLDER)) {
names.add(m[1]);
if (m[2] === undefined) plain.add(m[1]);
}
const kept = new Set();
const extras = [];
const problems = [];
for (const m of String(text).matchAll(PLACEHOLDER)) {
if (m[2] === undefined) kept.add(m[1]);
else if (!parsePluralForms(m[2])) problems.push(`malformed plural ${m[0]}`);
if (!names.has(m[1]) && !extras.includes(m[1])) extras.push(m[1]);
}
for (const k of plain) if (!kept.has(k)) problems.push(`drops {${k}}`);
return { ok: problems.length === 0, problems, extras };
}
/** The checkTranslation of a catalog entry (cached per language). */
function checkedEntry(lang, key, text) {
let m = checked.get(lang);
if (!m) { m = new Map(); checked.set(lang, m); }
let c = m.get(key);
if (!c) {
const sep = key.indexOf('::');
c = checkTranslation(sep >= 0 ? key.slice(sep + 2) : key, text);
m.set(key, c);
}
return c;
}
/**
* The translation of the first key (in order) that a language of the current chain has and that fits: in each
* language the keys in order (a tc() context key, then its plain msgid), then the next language. Null: show the msgid.
* @param {string[]} keys
* @param {Record<string, unknown> | unknown[] | null | undefined} params
* @returns {string|null}
*/
function lookup(keys, params) {
for (const l of langChain(current)) {
const cat = catalogs.get(l);
if (!cat) continue;
for (const key of keys) {
const tr = cat.get(key);
if (!tr) continue;
const c = checkedEntry(l, key, tr);
if (!c.ok) { report(l, key, c.problems); continue; }
const unknown = c.extras.filter((k) => !own(params, k));
if (unknown.length) { report(l, key, unknown.map((k) => `uses {${k}}, which the caller does not pass`)); continue; }
return tr;
}
}
return null;
}
/**
* Translate a message into the current language (gettext style: the msgid is the Chinese text).
* @param {unknown} msgid
@@ -201,11 +462,7 @@ export function format(template, params, opts = {}) {
*/
export function t(msgid, params) {
const id = msgid == null ? '' : String(msgid);
let text = id;
if (current !== DEFAULT_LANG) {
const tr = catalogs.get(current)?.get(id);
if (tr) text = tr;
}
const text = current === DEFAULT_LANG ? id : lookup([id], params) ?? id;
return params ? format(text, params, { lang: current, resolveName: nameResolver }) : text;
}
@@ -218,11 +475,8 @@ export function t(msgid, params) {
*/
export function tc(context, msgid, params) {
const id = msgid == null ? '' : String(msgid);
if (current !== DEFAULT_LANG) {
const tr = catalogs.get(current)?.get(`${context}::${id}`);
if (tr) return params ? format(tr, params, { lang: current, resolveName: nameResolver }) : tr;
}
return t(id, params);
const text = current === DEFAULT_LANG ? id : lookup([`${context}::${id}`, id], params) ?? id;
return params ? format(text, params, { lang: current, resolveName: nameResolver }) : text;
}
/** A tParts() param kept as it is: an object that is not a `{ dn }` name (a vnode), or an array holding one. */
@@ -231,7 +485,7 @@ const isMarkup = (v) => !!v && typeof v === 'object' && !own(v, 'dn') && (!Array
/**
* A message as pieces for a renderer, for a sentence with markup inside (「第 <b>14</b> 回合」): the translation split at
* its placeholders; a param that is markup (a vnode, or an array holding vnodes) is kept as it is, the others render as
* in t() ({ dn } names, lists, {n|one|other}). Adjacent text joins, empty text is dropped, so the Chinese pieces are the
* in t() ({ dn } names, lists, plural forms). Adjacent text joins, empty text is dropped, so the Chinese pieces are the
* text around the markup exactly as written before:
* html`<span>${tParts('第 {r} 回合 · 最终攻势', { r: html`<b class="num">${n}</b>` })}</span>`
* @param {unknown} msgid
@@ -240,11 +494,7 @@ const isMarkup = (v) => !!v && typeof v === 'object' && !own(v, 'dn') && (!Array
*/
export function tParts(msgid, params) {
const id = msgid == null ? '' : String(msgid);
let text = id;
if (current !== DEFAULT_LANG) {
const tr = catalogs.get(current)?.get(id);
if (tr) text = tr;
}
const text = current === DEFAULT_LANG ? id : lookup([id], params) ?? id;
/** @type {unknown[]} */
const out = [];
const push = (s) => {
@@ -255,11 +505,11 @@ export function tParts(msgid, params) {
let last = 0;
if (params && typeof params === 'object') {
for (const m of text.matchAll(PLACEHOLDER)) {
const [whole, key, one, other] = m;
const [whole, key, forms] = m;
if (!own(params, key)) continue;
push(text.slice(last, m.index));
const v = /** @type {any} */ (params)[key];
if (one !== undefined) push(Number(v) === 1 ? one : other);
if (forms !== undefined) push(pluralForm(forms, v, current) ?? whole);
else if (isMarkup(v)) out.push(v);
else push(renderValue(v, current, nameResolver));
last = m.index + whole.length;
+33 -22
View File
@@ -10,6 +10,11 @@
// the 2-character hash (textHash2) of each Chinese string a leaf replaces, in walk order (object keys in their order,
// array entries by index, `_h` itself skipped). A record that is a plain string (config `seasonName`) has { _s, _h }. A leaf whose Chinese source no longer hashes the same — data/*.json was
// rebuilt after the overlay — is not applied: the player sees the current Chinese text rather than a stale translation.
//
// Language packs (shared/i18nPacks.js): a language's game texts may come from several overlays — the pack's own, its
// base's, a fallback's (English) — applied per text: `applyFileOverlay(json, overlay, onto)` checks each leaf against
// the Chinese `json` and writes it over `onto` (what the overlays of lower priority made), so applying them from the
// last of the chain to the first leaves, for every text, the best overlay that has it (public/js/data.js).
export const OVERLAY_VERSION = 1;
@@ -106,77 +111,83 @@ export function buildRecordOverlay(leaves) {
/**
* Apply a record overlay to a Chinese record: a copy that shares every untouched branch (copy-on-write), with each leaf
* whose Chinese source still matches its hash replaced. The input record is never modified.
* whose Chinese source still matches its hash replaced. The input record is never modified. `onto` (default: the
* record itself) is what the replaced leaves are written over — the record as overlays of lower priority left it — while
* the hashes are always checked against the Chinese `base`.
* @param {any} base
* @param {any} ov
* @param {any} [onto]
* @returns {{ value: any, applied: number, stale: number }}
*/
export function applyRecordOverlay(base, ov) {
export function applyRecordOverlay(base, ov, onto = base) {
if (isObj(ov) && typeof ov._s === 'string') {
const ok = typeof base === 'string' && textHash2(base) === ov._h;
return { value: ok ? ov._s : base, applied: ok ? 1 : 0, stale: ok ? 0 : 1 };
return { value: ok ? ov._s : onto, applied: ok ? 1 : 0, stale: ok ? 0 : 1 };
}
if (!isObj(ov) || base == null || typeof base !== 'object') return { value: base, applied: 0, stale: 0 };
if (!isObj(ov) || base == null || typeof base !== 'object') return { value: onto, applied: 0, stale: 0 };
const hashes = typeof ov._h === 'string' ? ov._h : '';
let i = 0;
let applied = 0;
let stale = 0;
const rec = (b, o, depth) => {
// b: the Chinese at this path (hash check); t: the target at this path (same shape: overlays replace strings only)
const rec = (b, t, o, depth) => {
if (typeof o === 'string') {
const want = hashes.slice(i * 2, i * 2 + 2);
i++;
if (typeof b === 'string' && want && textHash2(b) === want) { applied++; return o; }
stale++;
return b;
return t;
}
if (Array.isArray(o)) {
if (!Array.isArray(b)) { walkOverlay(o, () => { i++; stale++; }); return b; }
let out = b;
if (!Array.isArray(b) || !Array.isArray(t)) { walkOverlay(o, () => { i++; stale++; }); return t; }
let out = t;
for (let k = 0; k < o.length; k++) {
if (o[k] == null) continue;
const nv = rec(b[k], o[k], depth + 1);
if (nv !== b[k]) { if (out === b) out = b.slice(); out[k] = nv; }
const nv = rec(b[k], t[k], o[k], depth + 1);
if (nv !== t[k]) { if (out === t) out = t.slice(); out[k] = nv; }
}
return out;
}
if (isObj(o)) {
if (b == null || typeof b !== 'object') { walkOverlay(o, () => { i++; stale++; }); return b; }
if (b == null || typeof b !== 'object' || t == null || typeof t !== 'object') { walkOverlay(o, () => { i++; stale++; }); return t; }
// an array record's overlay is keyed by index (see buildRecordOverlay)
const arr = Array.isArray(b);
let out = b;
let out = t;
for (const k of Object.keys(o)) {
if (depth === 0 && k === '_h') continue;
const at = arr ? (/^\d+$/.test(k) ? Number(k) : -1) : k;
const cur = at === -1 ? undefined : b[at];
const nv = rec(cur, o[k], depth + 1);
if (at !== -1 && nv !== cur) { if (out === b) out = arr ? b.slice() : { ...b }; out[at] = nv; }
const tcur = at === -1 ? undefined : t[at];
const nv = rec(cur, tcur, o[k], depth + 1);
if (at !== -1 && nv !== tcur) { if (out === t) out = Array.isArray(t) ? t.slice() : { ...t }; out[at] = nv; }
}
return out;
}
return b;
return t;
};
const value = rec(base, ov, 0);
const value = rec(base, onto, ov, 0);
return { value, applied, stale };
}
/**
* Apply a file's overlay to a whole data file (the `{ id: record }` maps of data/*.json; other shapes are returned
* unchanged). Untouched records are shared with the input.
* unchanged). Untouched records are shared with the input. `onto`: see applyRecordOverlay (a whole file).
* @param {any} json the Chinese file
* @param {Record<string, any> | null | undefined} fileOverlay `files[<name>]` of the overlay
* @param {any} [onto] the file as overlays of lower priority left it (default: the Chinese file)
* @returns {{ value: any, applied: number, stale: number }}
*/
export function applyFileOverlay(json, fileOverlay) {
if (!isObj(json) || !isObj(fileOverlay)) return { value: json, applied: 0, stale: 0 };
let out = json;
export function applyFileOverlay(json, fileOverlay, onto = json) {
if (!isObj(json) || !isObj(fileOverlay) || !isObj(onto)) return { value: onto, applied: 0, stale: 0 };
let out = onto;
let applied = 0;
let stale = 0;
for (const [id, ov] of Object.entries(fileOverlay)) {
if (!Object.prototype.hasOwnProperty.call(json, id)) continue;
const r = applyRecordOverlay(json[id], ov);
const r = applyRecordOverlay(json[id], ov, onto[id]);
applied += r.applied;
stale += r.stale;
if (r.value !== json[id]) { if (out === json) out = { ...json }; out[id] = r.value; }
if (r.value !== onto[id]) { if (out === onto) out = { ...onto }; out[id] = r.value; }
}
return { value: out, applied, stale };
}
+171
View File
@@ -0,0 +1,171 @@
// shared/i18nPacks.js — the language pack type ("lang") of the content packs (shared/packs.js, docs/PACKS.md;
// translating: docs/I18N.md "Adding a language"): language codes, the language fields of a pack manifest, the fallback
// chain t() walks, and the script family the title screen lays out by. No Node builtin: the browser, the server and the
// tools share it.
//
// A language pack is files in the language folders, nothing else (the owner's decision of 2026-10-07):
//
// public/i18n/<code>.json the UI strings: { "_meta": { …manifest… }, "<Chinese msgid>": "<translation>", … }
// data/i18n/<code>.json optional: the game texts (the overlay of shared/i18nData.js, tools/build-i18n.mjs)
//
// — or the same two files in a pack folder, packs/<id>/ with a pack.json manifest (shared/packs.js), the layout every
// pack type shares. <code> is a BCP 47 tag in its usual case: a 2–3 letter language, then an optional script (4
// letters), region (2 letters or 3 digits) and variants — en, ja, ko, zh-TW, pt-BR, sr-Latn. Chinese ('zh') is the
// built-in source language (the msgids themselves) and never a pack. English is a pack like any other; it is only the
// complete one.
//
// The language fields of a manifest (`_meta` of a single-file pack, pack.json of a folder pack; all optional):
// lang the language code (a single file: its file name decides; a different value is reported)
// base a pack whose strings fill the gaps first (default: the language of a regional code when there is a
// pack for it — pt-BR → pt; never the Chinese source, so zh-TW has none); null switches it off
// fallback packs tried after the base, before the Chinese msgid (["en"]: untranslated strings show English)
// complete true: `node tools/i18n.mjs check` requires every msgid (English declares it)
// numberUnits two units for numbers counted in 10⁴ / 10⁸ steps (Chinese 万 / 亿; e.g. a Japanese pack ["万", "億"]);
// without them large numbers use K / M / B (ui/gameLogic/format.js fmtNum)
// The common fields (id, version, app, name, englishName, authors, credits, license) are shared/packs.js's.
/** The source language: msgids are its text, it has no pack. */
export const SOURCE_LANG = 'zh';
/**
* A language code in its canonical case ('EN' → 'en', 'pt_br' → 'pt-BR', 'zh-hant-tw' → 'zh-Hant-TW'), or null when
* the input is not one.
* @param {unknown} v
* @returns {string|null}
*/
export function canonicalLang(v) {
if (typeof v !== 'string') return null;
const parts = v.trim().replace(/_/g, '-').split('-');
if (!/^[A-Za-z]{2,3}$/.test(parts[0] || '')) return null;
const out = [parts[0].toLowerCase()];
for (let i = 1; i < parts.length; i++) {
const p = parts[i];
if (!/^[A-Za-z0-9]{2,8}$/.test(p)) return null;
if (i === 1 && /^[A-Za-z]{4}$/.test(p)) out.push(p[0].toUpperCase() + p.slice(1).toLowerCase()); // script
else if (/^[A-Za-z]{2}$/.test(p) || /^\d{3}$/.test(p)) out.push(p.toUpperCase()); // region
else out.push(p.toLowerCase()); // variant
}
return out.join('-');
}
/** Whether a string is a language code in its canonical case (a pack's file name must be one). @param {unknown} v */
export const isLangCode = (v) => typeof v === 'string' && canonicalLang(v) === v;
/** The language part of a code ('pt-BR' → 'pt'). @param {string} code */
export const primaryLang = (code) => String(code).split('-')[0];
/**
* A language's name through Intl.DisplayNames (`code` in the language `inLang`), or the code when unknown.
* @param {string} code
* @param {string} [inLang] default: the language itself (日本語 for ja)
*/
export function languageName(code, inLang = code) {
try {
const DN = /** @type {any} */ (globalThis).Intl?.DisplayNames;
if (DN) {
const n = new DN([inLang], { type: 'language', fallback: 'none' }).of(code);
if (typeof n === 'string' && n && n !== code) return n;
}
} catch { /* an unknown code or no ICU data */ }
return code;
}
const str = (v, max = 200) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : '');
/** Canonical language codes of a string or a list, the source and repeats left out. @param {unknown} v */
export function langList(v) {
const list = Array.isArray(v) ? v : typeof v === 'string' ? [v] : [];
const out = [];
for (const x of list) {
const c = canonicalLang(x);
if (c && c !== SOURCE_LANG && !out.includes(c)) out.push(c);
}
return out;
}
/**
* @typedef {{ lang: string, base: string|null|undefined, fallback: string[], complete: boolean, numberUnits: string[]|null }} LangFields
*/
/**
* The language fields of a manifest (see the header), normalized. `base` stays undefined unless the manifest sets it
* (a code or null): computeChain() then takes the language of a regional code.
* @param {string} code the pack's language
* @param {any} m the manifest (`_meta` / pack.json), may be empty
* @returns {LangFields}
*/
export function langFields(code, m) {
const o = m && typeof m === 'object' ? m : {};
let base;
if (o.base === null) base = null;
else if (typeof o.base === 'string') base = canonicalLang(o.base);
if (base === SOURCE_LANG || base === code) base = null;
const units = Array.isArray(o.numberUnits) && o.numberUnits.length === 2 && o.numberUnits.every((u) => str(u, 8)) ? o.numberUnits.map((u) => str(u, 8)) : null;
return { lang: code, base, fallback: langList(o.fallback).filter((c) => c !== code), complete: o.complete === true, numberUnits: units };
}
/**
* @typedef {LangFields & { code: string, name: string, englishName: string, authors: string[], credits?: string,
* version?: string, app?: string, compatible?: boolean, data?: boolean, strings?: number, ui?: string,
* dataUrl?: string, packId?: string, builtin?: boolean }} LangMeta
*/
/**
* What the client knows of a language (shared/i18n.js registry) from a pack's UI file alone — its `_meta`, every field
* optional: the names default to Intl.DisplayNames, no authors, no fallback.
* @param {string} code
* @param {any} json the parsed UI file (or null)
* @returns {LangMeta}
*/
export function packMeta(code, json) {
const m = json && typeof json === 'object' && json._meta && typeof json._meta === 'object' ? json._meta : {};
const authors = (Array.isArray(m.authors) ? m.authors : typeof m.authors === 'string' ? [m.authors] : []).map((a) => str(a)).filter(Boolean).slice(0, 32);
return {
...langFields(code, m),
code,
name: str(m.name, 60) || languageName(code),
englishName: str(m.englishName, 60) || languageName(code, 'en'),
authors,
};
}
/** How many translations a UI file holds (non-empty string values, metadata keys left out). @param {any} json */
export function countStrings(json) {
let n = 0;
if (json && typeof json === 'object') for (const [k, v] of Object.entries(json)) if (!k.startsWith('_') && typeof v === 'string' && v) n++;
return n;
}
/**
* The languages t() tries for `code`, best first (the Chinese msgid comes after all of them): the pack itself, its base
* — `base`, else the language of a regional code (pt-BR → pt) — with the base's own chain, then each `fallback` with its
* chain. Only languages `known` accepts; never the source; no repeats (cycles end).
* @param {string} code
* @param {(code: string) => Partial<LangFields> | null | undefined} metaOf
* @param {(code: string) => boolean} known
* @returns {string[]}
*/
export function computeChain(code, metaOf, known) {
const out = [];
const visit = (c, depth) => {
if (!c || c === SOURCE_LANG || out.includes(c) || depth > 8 || !known(c)) return;
out.push(c);
const m = metaOf(c) || {};
const base = m.base !== undefined ? m.base : (c.includes('-') ? primaryLang(c) : null);
if (base) visit(base, depth + 1);
for (const f of m.fallback || []) visit(f, depth + 1);
};
visit(code, 0);
return out;
}
/** Characters of the wide scripts: CJK ideographs and punctuation, kana, Hangul, full-width forms. */
const WIDE_SCRIPT = /[ᄀ-ᇿ⺀-鿿가-힯豈-﫿＀-￯]/;
/**
* The script family of a text: 'cjk' (ideographs, kana, Hangul) or 'alphabetic' (Latin, Cyrillic, Greek …). The title
* screen and the start button lay out by it (screens/title.js, ui/lang.js applyDocument), so a pack needs no flag.
* @param {unknown} text
* @returns {'cjk' | 'alphabetic'}
*/
export const scriptOf = (text) => (WIDE_SCRIPT.test(String(text ?? '')) ? 'cjk' : 'alphabetic');
+285
View File
@@ -0,0 +1,285 @@
// shared/packs.js — content packs (docs/PACKS.md): one manifest format, one registry, one index for every kind of
// add-on content (the owner's decision of 2026-10-07: the language pack is the first type of a general mechanism). A
// pack is a manifest plus its files. Type "lang" (language packs, shared/i18nPacks.js) is the first and, in 0.2.0, the
// only type that loads; "assets" (art / audio / font replacements, client side) and "data" (data patches the server
// applies at start) are planned: their manifests already parse, a server lists them as not supported yet, and each will
// plug in as its entry in PACK_TYPES plus its loader (docs/PACKS.md "Adding a pack type"). No Node builtin: the
// browser, the server (server/packs.js) and the tools share it.
//
// Two layouts, read by the same registry (server/packs.js) and listed alike:
// packs/<id>/pack.json + its files a folder pack — any type; the manifest names its files (`files`)
// public/i18n/<code>.json a single-file language pack — its manifest is the file's `_meta` block
// (+ data/i18n/<code>.json, its game texts, when present)
//
// Manifest (pack.json, or `_meta` of a single-file language pack; every field but type / lang optional):
// id a folder pack: its folder name; a single-file language pack: its language code (a manifest that names
// another id is reported; the place decides). Letters, digits, '.', '_', '-', ≤ 64; unique across types
// type "lang" (a single-file language pack may leave it out); planned: "assets", "data"
// version the pack's own version (e.g. "1.2.0")
// app the app versions it is made for, a range: ">=0.2.0", ">=0.2.0 <0.3.0", "0.2.x", "^0.2.0", "~0.2.1", "*"
// (a pre-release such as 0.2.0-dev counts as its release). Outside it a language pack still loads —
// what it lacks falls back — and is flagged `compatible: false`
// name the pack's name in its own language (a language pack: the language's own name, shown in the menu)
// englishName its English name
// authors a string or a list; credits free text; license an SPDX id or free text
// files a folder pack: role → path inside the folder (type "lang": { "ui": "ui.json", "data": "data.json" })
// … the type's own fields (type "lang": lang, base, fallback, complete, numberUnits — shared/i18nPacks.js)
//
// Index (GET /packs/index.json — the server's live list; `node tools/packs.mjs index` writes packs/index.json for a
// static host): { version: 1, app: "<the server's app version>", packs: [entry …] } — the packs of supported types,
// sorted by type then id. An entry: id, type, name, englishName, version?, app?, compatible, authors?, credits?,
// license?, files (role → URL on this server) and the type's fields; a language entry: lang, base?, fallback,
// complete?, numberUnits?, strings (how many translations its UI file holds).
import { APP_VERSION } from './constants.js';
import { SOURCE_LANG, canonicalLang, isLangCode, languageName, langFields } from './i18nPacks.js';
export const PACK_INDEX_VERSION = 1;
/** A folder pack's manifest file. */
export const PACK_MANIFEST = 'pack.json';
/** The index file next to the pack folders (packs/index.json, URL /packs/index.json). */
export const PACK_INDEX_FILE = 'index.json';
/** Where pack folders are served (server/http/static.js): /packs/<id>/<file>. */
export const PACKS_URL = '/packs/';
/**
* @typedef {{ required?: boolean, ext: string[] }} FileRole a role of a folder pack's `files`
* @typedef {{ status: 'supported' | 'planned', summary: string, live?: boolean, files?: Record<string, FileRole> }} PackType
* live: a server picks up its changes without a restart (lang: the client loads packs on demand; data will not —
* the simulation's data is fixed at start). files: the roles a folder pack of the type names, with the extensions a
* server may serve for them (nothing else in a pack folder is ever served)
*/
/** @type {Readonly<Record<string, PackType>>} the pack types (docs/PACKS.md "Adding a pack type") */
export const PACK_TYPES = Object.freeze({
lang: Object.freeze({
status: 'supported', live: true, summary: 'a language: UI strings and, optionally, game texts',
files: Object.freeze({ ui: Object.freeze({ required: true, ext: ['.json'] }), data: Object.freeze({ ext: ['.json'] }) }),
}),
assets: Object.freeze({ status: 'planned', summary: 'art, audio and font replacements, client side' }),
data: Object.freeze({ status: 'planned', summary: 'data patches the server applies at start' }),
});
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
const str = (v, max = 200) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : '');
/** A pack id: letters, digits, '.', '_', '-' (not first), at most 64. @param {unknown} v */
export const isPackId = (v) => typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(v);
/**
* A path inside a pack folder ('ui.json', 'img/a.png'): relative, '/'-separated, no '.' / '..' / hidden / empty segment,
* plain characters only.
* @param {unknown} rel
*/
export const isPackPath = (rel) => typeof rel === 'string' && rel.length <= 200 && /^[A-Za-z0-9._ -]+(?:\/[A-Za-z0-9._ -]+)*$/.test(rel)
&& rel.split('/').every((s) => s.trim() === s && s && !s.startsWith('.'));
const extOf = (rel) => { const m = /\.[A-Za-z0-9]+$/.exec(rel); return m ? m[0].toLowerCase() : ''; };
// ---- app version ranges -------------------------------------------------------------------------------------------
/** [major, minor, patch] of a version ('0.2.0-dev' → [0, 2, 0]: a pre-release counts as its release), or null. */
const versionCore = (v) => {
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v ?? '').trim());
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
};
const cmp = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
/** One comparator of a range as [op, version] bounds, or null when it is not one. */
function bounds(c) {
if (c === '*' || c === 'x') return [];
const m = /^(\^|~|>=|<=|>|<|=)?v?(\d+)(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?$/.exec(c);
if (!m) return null;
const [, op = '', a, b, d] = m;
const wild = (s) => s === undefined || /^[xX*]$/.test(s);
const M = Number(a);
const N = wild(b) ? 0 : Number(b);
const P = wild(d) ? 0 : Number(d);
const partial = wild(b) ? 1 : wild(d) ? 2 : 3; // how many parts were given
const lo = [M, N, P];
if (op === '^') return [['>=', lo], ['<', M > 0 ? [M + 1, 0, 0] : partial >= 2 ? [0, N + 1, 0] : [1, 0, 0]]];
if (op === '~') return [['>=', lo], ['<', partial >= 2 ? [M, N + 1, 0] : [M + 1, 0, 0]]];
if (op === '' || op === '=') {
if (partial === 3) return [['=', lo]];
return [['>=', lo], ['<', partial === 2 ? [M, N + 1, 0] : [M + 1, 0, 0]]];
}
return [[op, lo]];
}
/** Whether `range` is a version range this file understands (an empty one is). @param {unknown} range */
export function isVersionRange(range) {
const r = String(range ?? '').trim();
if (!r) return true;
return r.split('||').every((alt) => alt.trim().split(/\s+/).every((c) => bounds(c) !== null));
}
/**
* Whether an app version is in a pack's `app` range (see the header). An empty range, an unreadable range or version
* counts as a match: the range is advice, the pack's loader decides what an incompatible pack means.
* @param {unknown} range
* @param {unknown} [version] default: this build's APP_VERSION
*/
export function appVersionMatches(range, version = APP_VERSION) {
const v = versionCore(version);
const r = String(range ?? '').trim();
if (!v || !r || !isVersionRange(r)) return true;
return r.split('||').some((alt) => alt.trim().split(/\s+/).every((c) => bounds(c).every(([op, b]) => {
const d = cmp(v, b);
return op === '>=' ? d >= 0 : op === '>' ? d > 0 : op === '<=' ? d <= 0 : op === '<' ? d < 0 : d === 0;
})));
}
// ---- manifests ----------------------------------------------------------------------------------------------------
/**
* @typedef {{ id: string, type: string, name: string, englishName: string, version: string, app: string,
* compatible: boolean, authors: string[], credits: string, license: string, files: Record<string, string>,
* lang?: string, base?: string|null, fallback?: string[], complete?: boolean, numberUnits?: string[]|null }} PackManifest
*/
/**
* Read a manifest: the common fields, the type's fields, a folder pack's file roles. `problems` keep the pack from
* loading (manifest null); `warnings` do not.
* @param {unknown} raw pack.json, or the `_meta` of a single-file language pack (undefined: none)
* @param {{ id: string, type?: string, lang?: string, folder?: boolean, app?: string }} ctx
* id: the folder name / the file's code; type: the type of the layout (a single-file language pack: 'lang');
* lang: a single file's language code; folder: a folder pack (its `files` are read); app: the app version
* @returns {{ manifest: PackManifest | null, problems: string[], warnings: string[] }}
*/
export function normalizeManifest(raw, ctx) {
const problems = [];
const warnings = [];
if (raw !== undefined && !isObj(raw)) problems.push('the manifest is not a JSON object');
const m = isObj(raw) ? /** @type {any} */ (raw) : {};
const type = str(m.type, 20) || ctx.type || '';
const kind = Object.prototype.hasOwnProperty.call(PACK_TYPES, type) ? PACK_TYPES[type] : null;
if (!type) problems.push('no "type"');
else if (!kind) problems.push(`unknown type "${type}" (known: ${Object.keys(PACK_TYPES).join(', ')})`);
if (ctx.type && type !== ctx.type) problems.push(`"type": "${type}" where a ${ctx.type} pack belongs`);
const id = ctx.id;
if (!isPackId(id)) problems.push(`"${id}" is not a pack id (letters, digits, . _ -)`);
if (m.id !== undefined && m.id !== id) warnings.push(`"id": "${m.id}" is not "${id}" (the folder or file name decides)`);
const range = str(m.app, 60);
if (range && !isVersionRange(range)) warnings.push(`"app": "${range}" is not a version range (">=0.2.0", "0.2.x" …)`);
const app = ctx.app ?? APP_VERSION;
const compatible = appVersionMatches(range, app);
if (!compatible) warnings.push(`made for app ${range}; this is ${app}`);
const authors = (Array.isArray(m.authors) ? m.authors : typeof m.authors === 'string' ? [m.authors] : []).map((a) => str(a)).filter(Boolean).slice(0, 32);
/** @type {PackManifest} */
const out = {
id, type, name: str(m.name, 60), englishName: str(m.englishName, 60), version: str(m.version, 40), app: range, compatible,
authors, credits: str(m.credits, 500), license: str(m.license, 100), files: {},
};
if (type === 'lang') {
const code = ctx.lang || canonicalLang(m.lang);
if (ctx.lang && m.lang !== undefined && canonicalLang(m.lang) !== ctx.lang) warnings.push(`"lang": "${m.lang}" is not its file's code ${ctx.lang}`);
if (!code) problems.push(m.lang === undefined ? 'no "lang" (the language code)' : `"lang": "${m.lang}" is not a language code`);
else if (code === SOURCE_LANG) problems.push('"zh" is the source language (the msgids), never a pack');
else {
if (m.lang !== undefined && !isLangCode(m.lang) && !ctx.lang) warnings.push(`"lang": "${m.lang}" read as ${code}`);
Object.assign(out, langFields(code, m));
out.name ||= languageName(code);
out.englishName ||= languageName(code, 'en');
}
}
out.name ||= id;
out.englishName ||= out.name;
if (ctx.folder && kind && kind.files) {
const given = isObj(m.files) ? m.files : {};
for (const [role, spec] of Object.entries(kind.files)) {
const rel = given[role];
if (rel === undefined || rel === null || rel === '') { if (spec.required) problems.push(`no "files.${role}"`); continue; }
if (!isPackPath(rel)) problems.push(`"files.${role}": "${rel}" is not a path inside the pack folder`);
else if (!spec.ext.includes(extOf(rel))) problems.push(`"files.${role}" must be a ${spec.ext.join(' / ')} file`);
else out.files[role] = rel;
}
for (const role of Object.keys(given)) if (!kind.files[role]) warnings.push(`"files.${role}" is not a role of a ${type} pack (ignored)`);
}
return { manifest: problems.length ? null : out, problems, warnings };
}
// ---- the index ----------------------------------------------------------------------------------------------------
/**
* The index entry of a pack (what a client may know): its manifest without the folder paths, `files` as URLs, plus the
* type's extras (a language pack: `strings`).
* @param {PackManifest} manifest
* @param {{ files: Record<string, string>, strings?: number }} extra files: role → URL
*/
export function packIndexEntry(manifest, extra) {
/** @type {Record<string, unknown>} */
const e = { id: manifest.id, type: manifest.type, name: manifest.name, englishName: manifest.englishName };
if (manifest.version) e.version = manifest.version;
if (manifest.app) e.app = manifest.app;
e.compatible = manifest.compatible;
if (manifest.authors.length) e.authors = manifest.authors;
if (manifest.credits) e.credits = manifest.credits;
if (manifest.license) e.license = manifest.license;
if (manifest.type === 'lang') {
e.lang = manifest.lang;
if (manifest.base !== undefined) e.base = manifest.base;
e.fallback = manifest.fallback || [];
if (manifest.complete) e.complete = true;
if (manifest.numberUnits) e.numberUnits = manifest.numberUnits;
e.strings = extra.strings ?? 0;
}
e.files = { ...extra.files };
return e;
}
/**
* The index body from the entries of supported packs (packIndexEntry), sorted by type then id.
* @param {Record<string, unknown>[]} entries
* @param {{ app?: string }} [opts]
*/
export function buildPackIndex(entries, { app = APP_VERSION } = {}) {
const key = (e) => `${e.type}\u0000${e.id}`;
return { version: PACK_INDEX_VERSION, app, packs: [...entries].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0)) };
}
/** A URL a client may fetch from an index: a path on this server ('/…', never '//host' or a scheme). */
const isLocalUrl = (u) => typeof u === 'string' && /^\/(?!\/)[^\s\\]*$/.test(u) && u.length <= 300;
/**
* Read a received index (the client): the entries of supported types whose fields check out; anything else is dropped.
* Optionally only one type.
* @param {unknown} json
* @param {string} [type]
* @returns {Record<string, any>[]}
*/
export function readPackIndex(json, type) {
const list = isObj(json) && Array.isArray(/** @type {any} */ (json).packs) ? /** @type {any} */ (json).packs : [];
const out = [];
for (const e of list) {
if (!isObj(e) || !isPackId(e.id) || PACK_TYPES[e.type]?.status !== 'supported' || (type && e.type !== type)) continue;
const files = {};
for (const [role, url] of Object.entries(isObj(e.files) ? e.files : {})) if (isLocalUrl(url)) files[role] = url;
if (e.type === 'lang' && (!isLangCode(e.lang) || e.lang === SOURCE_LANG || !files.ui)) continue;
out.push({ ...e, files });
}
return out;
}
/**
* The language a client registers for a language entry of the index (shared/i18n.js registerLangs).
* @param {Record<string, any>} e a 'lang' entry of readPackIndex
* @returns {import('./i18nPacks.js').LangMeta}
*/
export function langMetaOf(e) {
const f = langFields(e.lang, e);
return {
...f,
code: e.lang,
name: str(e.name, 60) || languageName(e.lang),
englishName: str(e.englishName, 60) || languageName(e.lang, 'en'),
authors: Array.isArray(e.authors) ? e.authors.map((a) => str(a)).filter(Boolean).slice(0, 32) : [],
version: str(e.version, 40),
app: str(e.app, 60),
compatible: e.compatible !== false,
strings: Number.isFinite(e.strings) ? e.strings : undefined,
data: !!e.files?.data,
ui: e.files?.ui,
dataUrl: e.files?.data,
packId: e.id,
};
}