feedback5 WAD: language packs are the first type of content packs — a language is a file in a language folder (public/i18n/<code>.json, optional data/i18n/<code>.json) or a pack folder (packs/<id>/pack.json); the server lists them live at /packs/index.json (server/packs.js), the menu shows every pack, strings fall back pack → base → declared fallback (English) → Chinese, broken placeholders are skipped, plurals follow Intl.PluralRules (English unchanged), English carries a _meta manifest like any pack; tools: i18n.mjs template / check <code>, packs.mjs list / index, build-i18n --lang ja|ko|zh-TW (the official JP / KR / TW clients); the release zip ships packs/ and a generated packs/index.json; golden unchanged

This commit is contained in:
sganggs committed 2026-10-07 02:38:22 +08:00
1 parent f1df0d359d
commit 25b2a87b26
22 files changed
+2222 -241

No files matched your search

+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');