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:
1 parent
f1df0d359d
commit
25b2a87b26
22 files changed
+2222
-241
No files matched your search
+85
-40
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
// tools/build-i18n.mjs — LOCALIZED GAME DATA: data/i18n/en.json from the official EN client data.
|
||||
// tools/build-i18n.mjs — LOCALIZED GAME DATA: data/i18n/en.json from the official EN client data (and, with --lang, the
|
||||
// game texts of a language pack from another official client: ja, ko, zh-TW — docs/I18N.md "Adding a language").
|
||||
//
|
||||
// The game texts of data/*.json (operators, skills, talents, modules, traits, enemies, bonds, items, effects, bands,
|
||||
// 特质, 机变 cards, modes, stages, tokens …) are the official zh_CN texts (tools/build-data.mjs). This tool writes their
|
||||
@@ -21,17 +22,27 @@
|
||||
// Rich-text tags and placeholders of the official EN texts are the same as in zh_CN, so the client's rich-text
|
||||
// formatting (ui/richText.js) works unchanged; `desc` / `text` (plain) are the stripped `descRaw` / `textRaw`.
|
||||
//
|
||||
// Usage: node tools/build-i18n.mjs [--refresh | --offline] [--source assets|yostar] [--cache <dir>] [--cache-en <dir>]
|
||||
// [--data <dir>] [--out <file>] [--report <file>] [--check] [--quiet]
|
||||
// --source EN tables: 'assets' (default) = ArknightsAssets/ArknightsGamedata en (current, has the 盟约 seasons
|
||||
// act1autochess / act2autochess), 'yostar' = Kengxxiao/ArknightsGameData_YoStar en_US (archived in
|
||||
// 2025-11: no 盟约 season, only the 2025 test event act1vautochess)
|
||||
// --refresh re-download the EN tables; --offline never download (fail when a table is missing)
|
||||
// Usage: node tools/build-i18n.mjs [--lang <code>] [--refresh | --offline] [--source assets|yostar] [--cache <dir>]
|
||||
// [--cache-en <dir>] [--data <dir>] [--out <file>] [--report <file>] [--check] [--quiet]
|
||||
// --lang the target language (default en): one with an official client in LANG_SOURCES — en, ja (the JP
|
||||
// client), ko (KR), zh-TW (TW); all three carry both 盟约 seasons (verified 2026-10-07, data 51.x). The
|
||||
// output goes to data/i18n/<code>.json, the game texts of the language pack <code> (it needs the pack's
|
||||
// UI file public/i18n/<code>.json to show in the menu). A language with no official client gets its game
|
||||
// texts another way (docs/I18N.md): the pack's UI file fills the data texts equal to one of its msgids,
|
||||
// the rest falls back per text (the pack's chain, then Chinese).
|
||||
// --source tables of the language: 'assets' (default) = ArknightsAssets/ArknightsGamedata (en / jp / kr / tw;
|
||||
// current, has the 盟约 seasons act1autochess / act2autochess); for en also 'yostar' =
|
||||
// Kengxxiao/ArknightsGameData_YoStar en_US (archived in 2025-11: no 盟约 season, only the 2025 test event
|
||||
// act1vautochess)
|
||||
// --refresh re-download the target tables; --offline never download (fail when a table is missing)
|
||||
// --cache zh_CN cache (default <repo>/.cache/gamedata, shared with build-data; missing tables are downloaded)
|
||||
// --cache-en EN cache (default <repo>/.cache/gamedata-en for 'assets', .cache/gamedata-en-yostar for 'yostar')
|
||||
// --data the data/*.json to localize (default <repo>/data); --out default <repo>/data/i18n/en.json
|
||||
// --report default <repo>/.cache/build-i18n-report.json (coverage per kind, untranslated samples)
|
||||
// --cache-en target cache (default <repo>/.cache/gamedata-en for en 'assets', .cache/gamedata-en-yostar for 'yostar',
|
||||
// .cache/gamedata-<code> for the others)
|
||||
// --data the data/*.json to localize (default <repo>/data); --out default <repo>/data/i18n/<code>.json
|
||||
// --report default <repo>/.cache/build-i18n-report.json (en; build-i18n-report.<code>.json for the others): coverage
|
||||
// per kind, untranslated samples
|
||||
// --check build in memory and exit 1 when the output differs from --out (nothing written)
|
||||
// In the code below "en" names the target side of the walk, whatever the language (the matching is the same).
|
||||
// Determinism: the output depends only on the inputs (stable key order, no timestamps).
|
||||
|
||||
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
|
||||
@@ -41,7 +52,16 @@ import { fileURLToPath } from 'node:url';
|
||||
import { buildRecordOverlay, applyFileOverlay, OVERLAY_VERSION } from '../shared/i18nData.js';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const USAGE = 'usage: node tools/build-i18n.mjs [--refresh | --offline] [--source assets|yostar] [--cache <dir>] [--cache-en <dir>] [--data <dir>] [--out <file>] [--report <file>] [--check] [--quiet]';
|
||||
const USAGE = 'usage: node tools/build-i18n.mjs [--lang en|ja|ko|zh-TW] [--refresh | --offline] [--source assets|yostar] [--cache <dir>] [--cache-en <dir>] [--data <dir>] [--out <file>] [--report <file>] [--check] [--quiet]';
|
||||
|
||||
const ASSETS_HOME = 'https://github.com/ArknightsAssets/ArknightsGamedata';
|
||||
/** The official client tables of a language other than English (ArknightsAssets/ArknightsGamedata <server>/gamedata). */
|
||||
const assetsSource = (server, code) => Object.freeze({
|
||||
label: `ArknightsAssets/ArknightsGamedata (${server})`,
|
||||
home: ASSETS_HOME,
|
||||
url: `https://raw.githubusercontent.com/ArknightsAssets/ArknightsGamedata/master/${server}/gamedata/`,
|
||||
cache: `.cache/gamedata-${code}`,
|
||||
});
|
||||
|
||||
export const EN_SOURCES = Object.freeze({
|
||||
assets: {
|
||||
@@ -59,6 +79,21 @@ export const EN_SOURCES = Object.freeze({
|
||||
});
|
||||
const ZH_URL = 'https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData/master/zh_CN/gamedata/';
|
||||
|
||||
/**
|
||||
* The official clients by target language, each with its table sources. `untranslated`: how a target text that is not
|
||||
* a translation shows — 'cjk' (it still has Chinese characters: en, ko) or 'same' (it is the Chinese text itself: ja and
|
||||
* zh-TW write with Han characters). `composite`: the remake-made stage names are assembled (English wording only).
|
||||
*/
|
||||
export const LANG_SOURCES = Object.freeze({
|
||||
en: Object.freeze({ sources: EN_SOURCES, untranslated: 'cjk', composite: true }),
|
||||
ja: Object.freeze({ sources: Object.freeze({ assets: assetsSource('jp', 'ja') }), untranslated: 'same', composite: false }),
|
||||
ko: Object.freeze({ sources: Object.freeze({ assets: assetsSource('kr', 'ko') }), untranslated: 'cjk', composite: false }),
|
||||
'zh-TW': Object.freeze({ sources: Object.freeze({ assets: assetsSource('tw', 'zh-TW') }), untranslated: 'same', composite: false }),
|
||||
});
|
||||
|
||||
/** The test "this target text is no translation of that Chinese one" of a language (LANG_SOURCES `untranslated`). */
|
||||
const untranslatedTest = (lang) => (LANG_SOURCES[lang]?.untranslated === 'same' ? (zh, t) => t === zh : (zh, t) => hasCjk(t));
|
||||
|
||||
/** Official tables walked side by side (zh_CN ↔ EN), with the sub-trees that hold texts the remake uses. */
|
||||
const TABLES = [
|
||||
{ rel: 'excel/activity_table.json', pick: (j) => ({ act2: j?.activity?.AUTOCHESS_SEASON?.act2autochess, act1: j?.activity?.AUTOCHESS_SEASON?.act1autochess, ac: j?.autoChessData, info: { act2autochess: j?.basicInfo?.act2autochess, act1autochess: j?.basicInfo?.act1autochess } }) },
|
||||
@@ -151,12 +186,11 @@ const hasPlaceholder = (s) => typeof s === 'string' && /\{-?[A-Za-z_@][^{}:]*(?:
|
||||
|
||||
function parseArgs(argv) {
|
||||
const opts = {
|
||||
refresh: false, offline: false, quiet: false, check: false, source: 'assets',
|
||||
cache: join(ROOT, '.cache', 'gamedata'), cacheEn: null, data: join(ROOT, 'data'),
|
||||
out: join(ROOT, 'data', 'i18n', 'en.json'), report: join(ROOT, '.cache', 'build-i18n-report.json'),
|
||||
refresh: false, offline: false, quiet: false, check: false, source: 'assets', lang: 'en',
|
||||
cache: join(ROOT, '.cache', 'gamedata'), cacheEn: null, data: join(ROOT, 'data'), out: null, report: null,
|
||||
};
|
||||
const flags = { '--refresh': 'refresh', '--offline': 'offline', '--quiet': 'quiet', '--check': 'check' };
|
||||
const vals = { '--source': 'source', '--cache': 'cache', '--cache-en': 'cacheEn', '--data': 'data', '--out': 'out', '--report': 'report' };
|
||||
const vals = { '--source': 'source', '--lang': 'lang', '--cache': 'cache', '--cache-en': 'cacheEn', '--data': 'data', '--out': 'out', '--report': 'report' };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const eq = argv[i].indexOf('=');
|
||||
const [name, inline] = eq > 0 ? [argv[i].slice(0, eq), argv[i].slice(eq + 1)] : [argv[i], null];
|
||||
@@ -165,14 +199,18 @@ function parseArgs(argv) {
|
||||
if (vals[name]) {
|
||||
const v = inline ?? argv[++i];
|
||||
if (!v || v.startsWith('--')) throw new Error(`${name} needs a value\n${USAGE}`);
|
||||
opts[vals[name]] = name === '--source' ? v : resolve(v);
|
||||
opts[vals[name]] = name === '--source' || name === '--lang' ? v : resolve(v);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`unknown option ${argv[i]}\n${USAGE}`);
|
||||
}
|
||||
if (opts.refresh && opts.offline) throw new Error(`--refresh and --offline are mutually exclusive\n${USAGE}`);
|
||||
if (!EN_SOURCES[opts.source]) throw new Error(`--source must be one of ${Object.keys(EN_SOURCES).join(', ')}`);
|
||||
if (!opts.cacheEn) opts.cacheEn = join(ROOT, EN_SOURCES[opts.source].cache);
|
||||
const lang = LANG_SOURCES[opts.lang];
|
||||
if (!lang) throw new Error(`--lang must be one of ${Object.keys(LANG_SOURCES).join(', ')} (a language with an official client; docs/I18N.md says how a pack without one gets its game texts)`);
|
||||
if (!lang.sources[opts.source]) throw new Error(`--source for ${opts.lang} must be one of ${Object.keys(lang.sources).join(', ')}`);
|
||||
if (!opts.cacheEn) opts.cacheEn = join(ROOT, lang.sources[opts.source].cache);
|
||||
if (!opts.out) opts.out = join(ROOT, 'data', 'i18n', `${opts.lang}.json`);
|
||||
if (!opts.report) opts.report = join(ROOT, '.cache', opts.lang === 'en' ? 'build-i18n-report.json' : `build-i18n-report.${opts.lang}.json`);
|
||||
return opts;
|
||||
}
|
||||
|
||||
@@ -234,7 +272,9 @@ const isDict = (o) => {
|
||||
* index (shape key → [{ zh, en, scope }]) for texts with placeholders.
|
||||
*/
|
||||
class PairIndex {
|
||||
constructor() {
|
||||
/** @param {(zh: string, target: string) => boolean} [untranslated] a target text that is no translation (skipped) */
|
||||
constructor(untranslated = (zh, t) => hasCjk(t)) {
|
||||
this.untranslated = untranslated;
|
||||
/** @type {Map<string, Map<string, { n: number, scopes: Set<string> }>>} */
|
||||
this.exact = new Map();
|
||||
/** @type {Map<string, Map<string, { n: number, scopes: Set<string> }>>} */
|
||||
@@ -256,7 +296,7 @@ class PairIndex {
|
||||
add(zhRaw, enRaw, scope) {
|
||||
const zh = unescapeNewlines(zhRaw);
|
||||
const en = unescapeNewlines(enRaw);
|
||||
if (!en.trim() || hasCjk(en)) return;
|
||||
if (!en.trim() || this.untranslated(zh, en)) return;
|
||||
this.pairs++;
|
||||
PairIndex.add(this.exact, zh, en, scope);
|
||||
// build-data trims some texts (a tip ends with '\\n' in the table, not in data/config.json)
|
||||
@@ -394,9 +434,10 @@ class Translator {
|
||||
* @param {PairIndex} index
|
||||
* @param {{ name: string, map: Map<string, string> }[]} fallbacks in order of preference
|
||||
*/
|
||||
constructor(index, fallbacks) {
|
||||
constructor(index, fallbacks, { composites = true } = {}) {
|
||||
this.index = index;
|
||||
this.fallbacks = fallbacks;
|
||||
this.composites = composites;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -414,7 +455,7 @@ class Translator {
|
||||
}
|
||||
const tpl = this.template(zh, ctx);
|
||||
if (tpl) return tpl;
|
||||
const comp = this.composite(zh, ctx);
|
||||
const comp = this.composites ? this.composite(zh, ctx) : null;
|
||||
if (comp) return comp;
|
||||
for (const { name, map } of this.fallbacks) {
|
||||
const fb = map.get(zh);
|
||||
@@ -570,21 +611,22 @@ class Stats {
|
||||
|
||||
// ===== main ==========================================================================================================
|
||||
|
||||
const dictMap = (d) => new Map(Object.entries(d || {}).filter(([k, v]) => !k.startsWith('_') && typeof v === 'string' && v && !hasCjk(v)));
|
||||
const dictMap = (d, untranslated = (zh, t) => hasCjk(t)) => new Map(Object.entries(d || {}).filter(([k, v]) => !k.startsWith('_') && typeof v === 'string' && v && !untranslated(k, v)));
|
||||
|
||||
/**
|
||||
* Build the overlay. Exported for tests (they pass in-memory tables).
|
||||
* @param {{ zh: object[], en: object[], data: Record<string, any>, fallback?: { remake?: object, pr70?: object, ui?: object }, source?: object }} input
|
||||
* zh / en: the picked sub-trees of TABLES, in order
|
||||
*/
|
||||
export function buildOverlay({ zh, en, data, fallback = {}, source = null }) {
|
||||
const index = new PairIndex();
|
||||
export function buildOverlay({ zh, en, data, fallback = {}, source = null, lang = 'en' }) {
|
||||
const untranslated = untranslatedTest(lang);
|
||||
const index = new PairIndex(untranslated);
|
||||
for (let i = 0; i < zh.length; i++) index.walk(zh[i], en[i], []);
|
||||
const tr = new Translator(index, [
|
||||
{ name: 'remake', map: dictMap(fallback.remake) },
|
||||
{ name: 'pr70', map: dictMap(fallback.pr70) },
|
||||
{ name: 'ui', map: dictMap(fallback.ui) },
|
||||
]);
|
||||
{ name: 'remake', map: dictMap(fallback.remake, untranslated) },
|
||||
{ name: 'pr70', map: dictMap(fallback.pr70, untranslated) },
|
||||
{ name: 'ui', map: dictMap(fallback.ui, untranslated) },
|
||||
], { composites: LANG_SOURCES[lang]?.composite ?? false });
|
||||
const stats = new Stats();
|
||||
const samples = {};
|
||||
const files = {};
|
||||
@@ -602,7 +644,7 @@ export function buildOverlay({ zh, en, data, fallback = {}, source = null }) {
|
||||
// record-level names (and the stand-ins' operator names): what server messages name
|
||||
const top = l.path.length === 1
|
||||
|| (file === 'backups' && ((id === 'units' && l.path.length === 2) || (id === 'diy' && l.path[0] === 'operators' && l.path.length === 3)));
|
||||
if (!top || !NAME_KEYS.has(l.key) || hasCjk(l.en)) continue;
|
||||
if (!top || !NAME_KEYS.has(l.key) || untranslated(l.zh, l.en)) continue;
|
||||
const prio = NAME_PRIORITY.indexOf(file);
|
||||
const list = nameVotes.get(l.zh) || [];
|
||||
list.push({ en: l.en, prio: prio < 0 ? 99 : prio });
|
||||
@@ -628,11 +670,13 @@ export function buildOverlay({ zh, en, data, fallback = {}, source = null }) {
|
||||
}
|
||||
const overlay = {
|
||||
version: OVERLAY_VERSION,
|
||||
lang: 'en',
|
||||
lang,
|
||||
meta: {
|
||||
generator: 'tools/build-i18n.mjs',
|
||||
source: source || null,
|
||||
fallback: 'tools/i18n/fallback-remake.json (the remake), tools/i18n/fallback-pr70.json (PR #70 by @YuriRestia), public/i18n/en.json',
|
||||
fallback: lang === 'en'
|
||||
? 'tools/i18n/fallback-remake.json (the remake), tools/i18n/fallback-pr70.json (PR #70 by @YuriRestia), public/i18n/en.json'
|
||||
: `public/i18n/${lang}.json (the pack's UI strings)`,
|
||||
coverage: Object.fromEntries(Object.entries(coverage).map(([k, v]) => [k, { texts: v.texts, translated: v.translated, official: v.exact + v.template + v.composite, pct: v.pct }])),
|
||||
totals: { texts: total, translated, official, pct: total ? Math.round((translated / total) * 1000) / 10 : 100 },
|
||||
},
|
||||
@@ -648,8 +692,8 @@ async function main() {
|
||||
let opts;
|
||||
try { opts = parseArgs(process.argv.slice(2)); } catch (e) { console.error(`build-i18n: ${e.message}`); process.exit(2); }
|
||||
const log = (...a) => { if (!opts.quiet) console.log(...a); };
|
||||
const src = EN_SOURCES[opts.source];
|
||||
log(`build-i18n: EN tables from ${src.label}`);
|
||||
const src = LANG_SOURCES[opts.lang].sources[opts.source];
|
||||
log(`build-i18n: ${opts.lang} tables from ${src.label}`);
|
||||
const zh = [];
|
||||
const en = [];
|
||||
for (const t of TABLES) {
|
||||
@@ -674,12 +718,13 @@ async function main() {
|
||||
if (existsSync(abs)) data[f] = JSON.parse(await readFile(abs, 'utf8'));
|
||||
}
|
||||
const readDict = async (abs) => (existsSync(abs) ? JSON.parse(await readFile(abs, 'utf8')) : {});
|
||||
const fallback = {
|
||||
// the remake's own strings and PR #70's are English; another language falls back to its pack's UI strings only
|
||||
const fallback = opts.lang === 'en' ? {
|
||||
remake: await readDict(join(ROOT, 'tools', 'i18n', 'fallback-remake.json')),
|
||||
pr70: await readDict(join(ROOT, 'tools', 'i18n', 'fallback-pr70.json')),
|
||||
ui: await readDict(join(ROOT, 'public', 'i18n', 'en.json')),
|
||||
};
|
||||
const { overlay, report } = buildOverlay({ zh, en, data, fallback, source: { id: opts.source, label: src.label, home: src.home, season: seasonEn } });
|
||||
} : { ui: await readDict(join(ROOT, 'public', 'i18n', `${opts.lang}.json`)) };
|
||||
const { overlay, report } = buildOverlay({ zh, en, data, fallback, lang: opts.lang, source: { id: opts.source, label: src.label, home: src.home, season: seasonEn } });
|
||||
|
||||
// self-check: applying the overlay keeps every record's shape (strings replace strings only)
|
||||
let applied = 0;
|
||||
@@ -692,13 +737,13 @@ async function main() {
|
||||
const rep = { ...report, source: overlay.meta.source, enDataVersion: version, applied, bytes: Buffer.byteLength(text) };
|
||||
await mkdir(dirname(opts.report), { recursive: true });
|
||||
await writeFile(opts.report, `${JSON.stringify(rep, null, 1)}\n`);
|
||||
log(` season in the EN build: ${seasonEn}${version ? ` · ${version.replace(/\s+/g, ' ')}` : ''}`);
|
||||
log(` ${report.pairs} zh→en source pairs; coverage per kind (translated / texts):`);
|
||||
log(` season in the ${opts.lang} build: ${seasonEn}${version ? ` · ${version.replace(/\s+/g, ' ')}` : ''}`);
|
||||
log(` ${report.pairs} zh→${opts.lang} source pairs; coverage per kind (translated / texts):`);
|
||||
for (const [kind, c] of Object.entries(report.coverage)) {
|
||||
const fb = [c.remake && `remake ${c.remake}`, c.pr70 && `PR #70 ${c.pr70}`, c.ui && `UI ${c.ui}`].filter(Boolean).join(' · ');
|
||||
log(` ${kind.padEnd(10)} ${String(c.translated).padStart(5)} / ${String(c.texts).padEnd(5)} ${String(c.pct).padStart(5)} % official ${c.exact + c.template + c.composite} (exact ${c.exact} · template ${c.template} · composite ${c.composite})${fb ? ` · ${fb}` : ''}${c.notes ? ` · notes skipped ${c.notes}` : ''}`);
|
||||
}
|
||||
log(` total ${report.totals.translated} / ${report.totals.texts} (${report.totals.pct} %), official ${report.totals.official}; ${Object.keys(overlay.names).length} names (${report.nameConflicts.length} with several English forms)`);
|
||||
log(` total ${report.totals.translated} / ${report.totals.texts} (${report.totals.pct} %), official ${report.totals.official}; ${Object.keys(overlay.names).length} names (${report.nameConflicts.length} with several ${opts.lang} forms)`);
|
||||
if (opts.check) {
|
||||
const cur = existsSync(opts.out) ? await readFile(opts.out, 'utf8') : '';
|
||||
if (cur !== text) { console.error(`build-i18n --check: ${relative(ROOT, opts.out)} is out of date (run node tools/build-i18n.mjs)`); process.exit(1); }
|
||||
|
||||
+221
-30
@@ -5,9 +5,19 @@
|
||||
// per file: the msgids passed to t() / tc() / tParts() / N_() / msg(), and the Chinese literals still shown untranslated
|
||||
// (string literals, template literals, html`` text and attribute values outside those calls; comments,
|
||||
// console.* and Error messages are ignored). --list prints each literal with its line.
|
||||
// node tools/i18n.mjs check [paths…] [--lang en] [--strict] [--stale]
|
||||
// msgids the code uses that public/i18n/<lang>.json lacks (exit 1 with --strict when any); --stale also lists
|
||||
// entries no scanned code uses (not an error: phase 2 wraps the rest of the UI later).
|
||||
// node tools/i18n.mjs check [<code>… | --all] [paths…] [--strict] [--stale] [--list]
|
||||
// per language pack (default en; --all: every pack the registry finds, server/packs.js): coverage of the msgids
|
||||
// the code uses, and the errors — a translation that drops a placeholder of its msgid, uses one no call site
|
||||
// passes, or has a broken plural form (shared/i18n.js checkTranslation), a value that is not a string, a manifest
|
||||
// problem. Missing strings are listed for a pack that declares `complete` (English) or with --list; --stale
|
||||
// lists entries no code uses and no complete pack has (obsolete). --strict exits 1 on an error, or when a
|
||||
// complete pack misses a msgid. A partial pack is fine: what it lacks falls back (docs/I18N.md).
|
||||
// node tools/i18n.mjs template <code> [--fill <code>]
|
||||
// write a pack skeleton public/i18n/<code>.json: a `_meta` manifest to fill in and every msgid with an empty
|
||||
// value (--fill: the values of another pack, e.g. --fill en). An existing pack keeps its translations and
|
||||
// `_meta`; only the msgids it lacks are added (run it again after an update of the game).
|
||||
// node tools/i18n.mjs index → node tools/packs.mjs index (the pack index for a static host)
|
||||
// check / template take --root <dir>: the packs of another checkout (the msgids are always this checkout's code).
|
||||
// node tools/i18n.mjs codemod <files…> [--write]
|
||||
// wrap Chinese literals: html`` text runs / attribute values → ${t('…')}, plain strings → t('…'), template
|
||||
// literals → t('…{name}…', { name }); adds the import. Module-level literals (evaluated once, before a language
|
||||
@@ -24,9 +34,13 @@
|
||||
// msgid (the client translates the text it receives), as is the first argument of msg(). Message ids: the Chinese text itself; a template literal's expressions become named params (paramName()).
|
||||
// Needs the dev dependencies (acorn, which eslint brings).
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { checkTranslation } from '../shared/i18n.js';
|
||||
import { canonicalLang, isLangCode, languageName, SOURCE_LANG } from '../shared/i18nPacks.js';
|
||||
import { APP_VERSION } from '../shared/constants.js';
|
||||
import { scanPacks, LANG_DIR } from '../server/packs.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const CJK = /[\u3400-\u9fff\uf900-\ufaff\u3000-\u303f\uff01-\uff60]/;
|
||||
@@ -319,11 +333,34 @@ function htmlSegments(tpl) {
|
||||
|
||||
// ===== scanning a file ===============================================================================================
|
||||
|
||||
/**
|
||||
* The param names a call passes (its params argument): an object literal's keys, an array literal's indexes, none
|
||||
* without the argument; null when they cannot be read (a variable, a spread, a computed key) — `check` then trusts the
|
||||
* translation's extra placeholders.
|
||||
* @param {any} node
|
||||
* @returns {string[] | null}
|
||||
*/
|
||||
export function paramNames(node) {
|
||||
if (!node) return [];
|
||||
if (node.type === 'ObjectExpression') {
|
||||
const out = [];
|
||||
for (const p of node.properties) {
|
||||
if (p.type !== 'Property' || p.computed) return null;
|
||||
if (p.key.type === 'Identifier') out.push(p.key.name);
|
||||
else if (p.key.type === 'Literal') out.push(String(p.key.value));
|
||||
else return null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (node.type === 'ArrayExpression') return node.elements.some((e) => !e || e.type === 'SpreadElement') ? null : node.elements.map((_, i) => String(i));
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a source file.
|
||||
* @param {string} src
|
||||
* @param {string} file label
|
||||
* @returns {Promise<{ msgids: { msgid: string, line: number }[], literals: any[] }>}
|
||||
* @returns {Promise<{ msgids: { msgid: string, line: number, via: string, params: string[] | null }[], literals: any[] }>}
|
||||
* literals: { kind: 'str'|'tpl'|'text'|'attr', start, end, line, msgid, params, module: boolean, reason: string|null }
|
||||
*/
|
||||
export async function scanSource(src, file = '<src>') {
|
||||
@@ -350,14 +387,16 @@ export async function scanSource(src, file = '<src>') {
|
||||
const fileIgnored = /\/\/[^\n]*i18n-ignore-file/.test(src);
|
||||
walk(ast, (node, ancestors) => {
|
||||
if (node.type === 'CallExpression' && fnName(node.callee) === 'tc') {
|
||||
const [c, a] = node.arguments;
|
||||
if (c?.type === 'Literal' && a?.type === 'Literal' && typeof a.value === 'string') msgids.push({ msgid: `${c.value}::${a.value}`, line: lineOf(a.start), via: 'tc' });
|
||||
const [c, a, p] = node.arguments;
|
||||
if (c?.type === 'Literal' && a?.type === 'Literal' && typeof a.value === 'string') msgids.push({ msgid: `${c.value}::${a.value}`, line: lineOf(a.start), via: 'tc', params: paramNames(p) });
|
||||
}
|
||||
if (node.type === 'CallExpression' && MSGID_CALLS.has(fnName(node.callee))) {
|
||||
const a = node.arguments[0];
|
||||
const via = fnName(node.callee);
|
||||
if (a && a.type === 'Literal' && typeof a.value === 'string') msgids.push({ msgid: a.value, line: lineOf(a.start), via });
|
||||
else if (a && a.type === 'TemplateLiteral' && !a.expressions.length) msgids.push({ msgid: a.quasis[0].value.cooked, line: lineOf(a.start), via });
|
||||
// N_() only marks a msgid: its params come where it is shown, unknown here
|
||||
const params = via === 'N_' ? null : paramNames(node.arguments[1]);
|
||||
if (a && a.type === 'Literal' && typeof a.value === 'string') msgids.push({ msgid: a.value, line: lineOf(a.start), via, params });
|
||||
else if (a && a.type === 'TemplateLiteral' && !a.expressions.length) msgids.push({ msgid: a.quasis[0].value.cooked, line: lineOf(a.start), via, params });
|
||||
}
|
||||
if (node.type === 'TaggedTemplateExpression' && node.tag.type === 'Identifier' && node.tag.name === 'html') {
|
||||
for (const seg of htmlSegments(node.quasi)) {
|
||||
@@ -377,7 +416,7 @@ export async function scanSource(src, file = '<src>') {
|
||||
while (k >= 0 && ((ancestors[k].type === 'ConditionalExpression' && ancestors[k].test !== arg) || ancestors[k].type === 'LogicalExpression')) { arg = ancestors[k]; k--; }
|
||||
const call = ancestors[k];
|
||||
if (call && call.type === 'CallExpression' && call.callee.type === 'MemberExpression' && SERVER_TEXT_CALLS.has(calleeName(call)) && call.arguments.includes(arg)) {
|
||||
msgids.push({ msgid: node.value, line: lineOf(node.start), via: 'server' });
|
||||
msgids.push({ msgid: node.value, line: lineOf(node.start), via: 'server', params: [] });
|
||||
return true;
|
||||
}
|
||||
const line = lineOf(node.start);
|
||||
@@ -527,26 +566,39 @@ function parseFlags(argv) {
|
||||
if (a.startsWith('--')) {
|
||||
const [k, v] = a.slice(2).split('=');
|
||||
if (v !== undefined) flags[k] = v;
|
||||
else if (['from', 'lang'].includes(k)) flags[k] = argv[++i];
|
||||
else if (['from', 'lang', 'fill', 'root'].includes(k)) flags[k] = argv[++i];
|
||||
else flags[k] = true;
|
||||
} else flags._.push(a);
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
const catalogPath = (lang) => path.join(ROOT, 'public', 'i18n', `${lang}.json`);
|
||||
const catalogPath = (lang, root = ROOT) => path.join(root, 'public', 'i18n', `${lang}.json`);
|
||||
function readCatalog(lang) {
|
||||
const p = catalogPath(lang);
|
||||
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : {};
|
||||
}
|
||||
function writeCatalog(lang, cat) {
|
||||
function writeCatalog(lang, cat, root = ROOT) {
|
||||
const meta = Object.entries(cat).filter(([k]) => k.startsWith('_'));
|
||||
const rest = Object.entries(cat).filter(([k]) => !k.startsWith('_'));
|
||||
writeFileSync(catalogPath(lang), `${JSON.stringify(Object.fromEntries([...meta, ...rest]), null, 2)}\n`);
|
||||
mkdirSync(path.dirname(catalogPath(lang, root)), { recursive: true });
|
||||
writeFileSync(catalogPath(lang, root), `${JSON.stringify(Object.fromEntries([...meta, ...rest]), null, 2)}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The msgids the code uses: msgid → { where: first "file:line", params: the param names its call sites pass, null when a
|
||||
* call site's params cannot be read (a variable, N_(), a msgid table) }.
|
||||
* @param {string[]} files
|
||||
* @returns {Promise<Map<string, { where: string, params: Set<string> | null }>>}
|
||||
*/
|
||||
async function usedMsgids(files) {
|
||||
const used = new Map(); // msgid → first "file:line"
|
||||
const used = new Map();
|
||||
const note = (msgid, where, params) => {
|
||||
const u = used.get(msgid);
|
||||
if (!u) { used.set(msgid, { where, params: params ? new Set(params) : null }); return; }
|
||||
if (!params) u.params = null;
|
||||
else if (u.params) for (const k of params) u.params.add(k);
|
||||
};
|
||||
for (const f of files) {
|
||||
const { msgids } = await scanSource(readFileSync(f, 'utf8'), rel(f));
|
||||
// server code never translates: its t() / N_() are other helpers (blackboard lookups in the sim); only msg() and
|
||||
@@ -554,13 +606,13 @@ async function usedMsgids(files) {
|
||||
const server = rel(f).startsWith('server/');
|
||||
for (const m of msgids) {
|
||||
if (server && m.via !== 'msg' && m.via !== 'server') continue;
|
||||
if (!used.has(m.msgid)) used.set(m.msgid, `${rel(f)}:${m.line}`);
|
||||
note(m.msgid, `${rel(f)}:${m.line}`, m.params);
|
||||
}
|
||||
}
|
||||
for (const [file, names] of MSGID_TABLES) {
|
||||
const abs = path.join(ROOT, file);
|
||||
if (!existsSync(abs)) continue;
|
||||
for (const m of await tableMsgids(readFileSync(abs, 'utf8'), names)) if (!used.has(m)) used.set(m, file);
|
||||
for (const m of await tableMsgids(readFileSync(abs, 'utf8'), names)) note(m, file, null);
|
||||
}
|
||||
return used;
|
||||
}
|
||||
@@ -588,18 +640,156 @@ async function cmdExtract(flags) {
|
||||
+ ` (${ready} of them already translated in public/i18n/${flags.lang || 'en'}.json, ready for the codemod)`);
|
||||
}
|
||||
|
||||
async function cmdCheck(flags) {
|
||||
const lang = flags.lang || 'en';
|
||||
const files = listFiles(flags._.length ? flags._ : DEFAULT_ROOTS, { sim: true });
|
||||
const used = await usedMsgids(files);
|
||||
const cat = readCatalog(lang);
|
||||
const missing = [...used].filter(([m]) => !(typeof cat[m] === 'string' && cat[m]));
|
||||
for (const [m, where] of missing) console.log(`missing ${where.padEnd(40)} ${m.replace(/\n/g, '⏎')}`);
|
||||
if (flags.stale) {
|
||||
for (const k of Object.keys(cat)) if (!k.startsWith('_') && !used.has(k)) console.log(`unused ${k.replace(/\n/g, '⏎')}`);
|
||||
// ===== language packs: check, template ===============================================================================
|
||||
|
||||
/** The language packs of a checkout (server/packs.js scanPacks): lang → { pack, json }, plus what was skipped. */
|
||||
function langPacks(root = ROOT) {
|
||||
const scan = scanPacks({ publicDir: path.join(root, 'public'), dataDir: path.join(root, 'data'), packsDir: path.join(root, 'packs') });
|
||||
const byLang = new Map();
|
||||
for (const p of scan.packs) {
|
||||
if (p.type !== 'lang') continue;
|
||||
byLang.set(p.manifest.lang, { pack: p, json: JSON.parse(readFileSync(p.files.ui.abs, 'utf8')) });
|
||||
}
|
||||
console.log(`${used.size} msgids used, ${used.size - missing.length} translated in public/i18n/${lang}.json, ${missing.length} missing`);
|
||||
if (flags.strict && missing.length) process.exitCode = 1;
|
||||
return { byLang, skipped: scan.skipped, warnings: scan.warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check one language pack against the msgids the code uses (see the header).
|
||||
* @param {Record<string, unknown>} json the pack's UI strings
|
||||
* @param {Map<string, { where: string, params: Set<string> | null }>} used
|
||||
* @param {Set<string>} known msgids no pack is obsolete for (used ∪ the keys of the complete packs)
|
||||
* @returns {{ total: number, translated: number, missing: [string, string][], errors: string[], obsolete: string[] }}
|
||||
*/
|
||||
export function checkPack(json, used, known) {
|
||||
const missing = [];
|
||||
const errors = [];
|
||||
const obsolete = [];
|
||||
let translated = 0;
|
||||
for (const [k, v] of Object.entries(json || {})) {
|
||||
if (k.startsWith('_')) continue;
|
||||
if (typeof v !== 'string') errors.push(`not a string: ${k}`);
|
||||
else if (!used.has(k) && !known.has(k)) obsolete.push(k);
|
||||
}
|
||||
for (const [msgid, u] of used) {
|
||||
const v = json?.[msgid];
|
||||
if (typeof v !== 'string' || !v) { missing.push([msgid, u.where]); continue; }
|
||||
const sep = msgid.indexOf('::');
|
||||
const c = checkTranslation(sep >= 0 ? msgid.slice(sep + 2) : msgid, v);
|
||||
const stray = u.params ? c.extras.filter((x) => !u.params.has(x)) : [];
|
||||
if (!c.ok || stray.length) {
|
||||
errors.push(`${[...c.problems, ...stray.map((x) => `uses {${x}}, which no call site passes`)].join('; ')}: ${msgid.replace(/\n/g, '⏎')} → ${v.replace(/\n/g, '⏎')} (${u.where})`);
|
||||
continue;
|
||||
}
|
||||
translated++;
|
||||
}
|
||||
return { total: used.size, translated, missing, errors, obsolete };
|
||||
}
|
||||
|
||||
async function cmdCheck(flags) {
|
||||
const paths = flags._.filter((a) => !isLangArg(a));
|
||||
const files = listFiles(paths.length ? paths : DEFAULT_ROOTS, { sim: true });
|
||||
const used = await usedMsgids(files);
|
||||
const packRoot = flags.root ? path.resolve(flags.root) : ROOT;
|
||||
const { byLang, skipped, warnings } = langPacks(packRoot);
|
||||
let codes = flags._.filter(isLangArg).map((a) => canonicalLang(a));
|
||||
if (flags.lang) codes.push(canonicalLang(flags.lang));
|
||||
if (flags.all) codes = [...byLang.keys()];
|
||||
if (!codes.length) codes = ['en'];
|
||||
const known = new Set(used.keys());
|
||||
for (const { pack, json } of byLang.values()) if (pack.manifest.complete) for (const k of Object.keys(json)) if (!k.startsWith('_')) known.add(k);
|
||||
let failed = false;
|
||||
for (const s of skipped) {
|
||||
if (!flags.all && !codes.some((c) => s.where.includes(`/${c}`))) continue;
|
||||
console.log(`error ${s.where}: not loaded — ${s.problems.join('; ')}`);
|
||||
failed = true;
|
||||
}
|
||||
for (const code of codes) {
|
||||
const entry = byLang.get(code);
|
||||
if (!entry) {
|
||||
if (!skipped.some((s) => s.where.includes(`/${code}`))) console.log(`error no language pack "${code}" (public/${LANG_DIR}/${code}.json or packs/<id>/ with "lang": "${code}"; node tools/i18n.mjs template ${code} makes one)`);
|
||||
failed = true;
|
||||
continue;
|
||||
}
|
||||
const { pack, json } = entry;
|
||||
const where = pack.layout === 'file' ? `public/${LANG_DIR}/${code}.json` : `${pack.where}${pack.files.ui.rel}`;
|
||||
for (const w of warnings) if (w.where === pack.where) console.log(`warning ${w.where}: ${w.warning}`);
|
||||
const r = checkPack(json, used, known);
|
||||
const complete = pack.manifest.complete;
|
||||
for (const e of r.errors) console.log(`error ${where}: ${e}`);
|
||||
if (complete || flags.list) for (const [m, w] of r.missing) console.log(`missing ${w.padEnd(40)} ${m.replace(/\n/g, '⏎')}`);
|
||||
if (flags.stale) for (const k of r.obsolete) console.log(`unused ${k.replace(/\n/g, '⏎')}`);
|
||||
const pct = r.total ? Math.floor((r.translated / r.total) * 1000) / 10 : 100;
|
||||
console.log(`${where} (${pack.manifest.name}, ${code}${complete ? ', complete' : ''}): ${r.total} msgids used, ${r.translated} translated (${pct} %), ${r.missing.length} missing`
|
||||
+ `, ${r.errors.length} errors${r.obsolete.length ? `, ${r.obsolete.length} unused` : ''}${pack.manifest.compatible ? '' : `; made for app ${pack.manifest.app}`}`);
|
||||
if (r.errors.length || (complete && r.missing.length)) failed = true;
|
||||
}
|
||||
if (flags.strict && failed) process.exitCode = 1;
|
||||
}
|
||||
|
||||
/** A positional argument of `check` that names a language (a code that is not a path of the checkout). */
|
||||
const isLangArg = (a) => !!canonicalLang(a) && !existsSync(path.resolve(ROOT, a));
|
||||
|
||||
/** The `app` range a new pack targets: this release and later ('>=0.2.0' on 0.2.0-dev). */
|
||||
const appRange = () => `>=${(/^\d+\.\d+\.\d+/.exec(APP_VERSION) || ['0.0.0'])[0]}`;
|
||||
|
||||
/**
|
||||
* A pack skeleton (or an existing pack with the msgids it lacks added, empty): the `_meta` manifest first, then every
|
||||
* msgid in the order of the complete packs (English), then the code's msgids they lack.
|
||||
* @param {string} code
|
||||
* @param {{ existing?: Record<string, unknown> | null, msgids: string[], fill?: Record<string, unknown> | null }} opts
|
||||
* @returns {{ json: Record<string, unknown>, added: number, kept: number }}
|
||||
*/
|
||||
export function packTemplate(code, { existing = null, msgids, fill = null }) {
|
||||
const meta = existing && typeof existing._meta === 'object' && existing._meta ? existing._meta : {
|
||||
type: 'lang',
|
||||
lang: code,
|
||||
name: languageName(code),
|
||||
englishName: languageName(code, 'en'),
|
||||
version: '0.1.0',
|
||||
app: appRange(),
|
||||
authors: [],
|
||||
// untranslated strings show English first; a Chinese variant (zh-TW …) falls back to the Chinese msgid instead
|
||||
fallback: code.startsWith(`${SOURCE_LANG}-`) ? [] : ['en'],
|
||||
};
|
||||
/** @type {Record<string, unknown>} */
|
||||
const json = { _meta: meta };
|
||||
for (const [k, v] of Object.entries(existing || {})) if (k.startsWith('_') && k !== '_meta') json[k] = v;
|
||||
let added = 0;
|
||||
let kept = 0;
|
||||
for (const m of msgids) {
|
||||
const have = existing?.[m];
|
||||
if (typeof have === 'string') { json[m] = have; kept++; continue; }
|
||||
const f = fill?.[m];
|
||||
json[m] = typeof f === 'string' ? f : '';
|
||||
added++;
|
||||
}
|
||||
// translations of msgids the code no longer uses stay (check --stale lists them)
|
||||
for (const [k, v] of Object.entries(existing || {})) if (!k.startsWith('_') && !Object.prototype.hasOwnProperty.call(json, k)) json[k] = v;
|
||||
return { json, added, kept };
|
||||
}
|
||||
|
||||
async function cmdTemplate(flags) {
|
||||
const code = canonicalLang(flags._[0]);
|
||||
if (!code || !isLangCode(code)) throw new Error('template needs a language code (ja, ko, zh-TW, pt-BR …)');
|
||||
if (code === SOURCE_LANG) throw new Error('"zh" is the source language (the msgids), it needs no pack');
|
||||
const used = await usedMsgids(listFiles(DEFAULT_ROOTS, { sim: true }));
|
||||
const packRoot = flags.root ? path.resolve(flags.root) : ROOT;
|
||||
const { byLang } = langPacks(packRoot);
|
||||
const order = [];
|
||||
const seen = new Set();
|
||||
for (const { pack, json } of byLang.values()) {
|
||||
if (!pack.manifest.complete) continue;
|
||||
for (const k of Object.keys(json)) if (!k.startsWith('_') && !seen.has(k)) { seen.add(k); order.push(k); }
|
||||
}
|
||||
for (const k of used.keys()) if (!seen.has(k)) { seen.add(k); order.push(k); }
|
||||
const target = byLang.get(code);
|
||||
if (target && target.pack.layout !== 'file') throw new Error(`${code} is a folder pack (${target.pack.where}); edit ${target.pack.files.ui.rel} there`);
|
||||
const fillCode = flags.fill ? canonicalLang(flags.fill) : null;
|
||||
if (flags.fill && !byLang.has(fillCode)) throw new Error(`--fill ${flags.fill}: no such language pack`);
|
||||
const { json, added, kept } = packTemplate(code, { existing: target ? target.json : null, msgids: order, fill: fillCode ? byLang.get(fillCode).json : null });
|
||||
writeCatalog(code, json, packRoot);
|
||||
console.log(`${target ? 'updated' : 'wrote'} public/${LANG_DIR}/${code}.json: ${order.length} msgids — ${kept} translations kept, ${added} added${fillCode ? ` (values from ${fillCode})` : ' (empty)'}`);
|
||||
if (!target) console.log(`next: fill in "_meta" (name, authors), translate the values, then node tools/i18n.mjs check ${code}`);
|
||||
}
|
||||
|
||||
async function cmdCodemod(flags) {
|
||||
@@ -640,9 +830,10 @@ async function cmdSeed(flags) {
|
||||
async function main() {
|
||||
const [cmd, ...rest] = process.argv.slice(2);
|
||||
const flags = parseFlags(rest);
|
||||
const cmds = { extract: cmdExtract, check: cmdCheck, codemod: cmdCodemod, seed: cmdSeed };
|
||||
const cmdIndex = async () => { const { main: packs } = await import('./packs.mjs'); process.exitCode = await packs(['index', ...rest]); };
|
||||
const cmds = { extract: cmdExtract, check: cmdCheck, codemod: cmdCodemod, seed: cmdSeed, template: cmdTemplate, index: cmdIndex };
|
||||
if (!cmds[cmd]) {
|
||||
console.log('usage: node tools/i18n.mjs extract|check|codemod|seed … (see the header of tools/i18n.mjs)');
|
||||
console.log('usage: node tools/i18n.mjs extract|check|template|codemod|seed|index … (see the header of tools/i18n.mjs)');
|
||||
process.exitCode = cmd ? 2 : 0;
|
||||
return;
|
||||
}
|
||||
|
||||
+23
-6
@@ -9,6 +9,8 @@
|
||||
//
|
||||
// Both zips hold one folder, Stronghold-Protocol/, with only what a player runs — an allowlist over `git ls-files`, so
|
||||
// untracked work, caches, logs and per-machine config never get in: server/, shared/, data/, public/ (not public/dev/),
|
||||
// packs/ (the content packs that ship with the repository, docs/PACKS.md; a pack installed on this machine and not
|
||||
// committed stays out),
|
||||
// the start scripts, the tools a player runs (setup, vendor = the postinstall, fetch-assets + tools/assets, doctor, and
|
||||
// what setup starts: tools/local-extract, crop-board-atlas), the research tables the Node server (server/sim/
|
||||
// nodeData.js fallback) and fetch-assets read, package.json / package-lock.json, LICENSE / NOTICE.md /
|
||||
@@ -26,7 +28,9 @@
|
||||
// data/assets.json and data/local-assets.json list; no shipped file carries a home-directory path (/Users/…,
|
||||
// C:\Users\…, /home/…) or this machine's account name (text and binary, art included; node_modules only for the name);
|
||||
// no shipped tracked file has an uncommitted change (--allow-dirty skips that). A build also checks that the stage holds
|
||||
// exactly the plan before zipping.
|
||||
// exactly the plan before zipping. Generated into the stage: packs/index.json, the pack index of the shipped packs
|
||||
// (tools/packs.mjs writePackIndex — what the server's GET /packs/index.json answers, for a static host), when any pack
|
||||
// ships (a language file of public/i18n/, a packs/<id>/pack.json).
|
||||
// The account name comes from the OS at run time (never written in the repository): SP_PACKAGE_SCAN_USER=0 skips it
|
||||
// (a name that is a common word), SP_PACKAGE_SCAN_NAMES=a,b adds more names to refuse.
|
||||
//
|
||||
@@ -39,6 +43,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { findSpecifiers } from './check-imports.mjs';
|
||||
import { writePackIndex } from './packs.mjs';
|
||||
|
||||
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
/** The one folder inside both zips (DEPLOY §1.1: "解压后把里面的 Stronghold-Protocol 文件夹放到…"). */
|
||||
@@ -58,7 +63,11 @@ export const PLAYER_TOOLS = ['tools/crop-board-atlas.mjs', 'tools/doctor.mjs', '
|
||||
/** Whole tool directories: fetch-assets' modules, the local-client extraction setup runs. */
|
||||
export const PLAYER_TOOL_DIRS = ['tools/assets/', 'tools/local-extract/'];
|
||||
/** Whole runtime directories (their tracked files). */
|
||||
export const RUNTIME_DIRS = ['server/', 'shared/', 'data/', 'public/'];
|
||||
export const RUNTIME_DIRS = ['server/', 'shared/', 'data/', 'public/', 'packs/'];
|
||||
/** Written into the stage, never taken from the checkout: the pack index of the shipped packs. */
|
||||
export const GENERATED_PACK_INDEX = 'packs/index.json';
|
||||
/** Whether shipped files include a content pack (then the stage gets GENERATED_PACK_INDEX). @param {string[]} files */
|
||||
export const shipsPacks = (files) => files.some((f) => /^public\/i18n\/[^/]+\.json$/.test(f) || /^packs\/[^/]+\/pack\.json$/.test(f));
|
||||
/** Never from the tracked list: the dev pages, and what only the art plan adds (or npm ci writes). */
|
||||
const NOT_TRACKED_SHIP = ['public/dev/', 'public/assets/', 'public/fonts/', 'public/vendor/'];
|
||||
/** The npm scripts a player runs: each `node <file>` of them must ship. */
|
||||
@@ -102,7 +111,7 @@ export function isJunk(rel) {
|
||||
/** Whether a tracked file belongs in the player package. */
|
||||
export function isPlayerFile(rel) {
|
||||
const p = posixRel(rel);
|
||||
if (!p || isRefused(p) || isJunk(p) || p === 'data/local-assets.json') return false;
|
||||
if (!p || isRefused(p) || isJunk(p) || p === 'data/local-assets.json' || p === GENERATED_PACK_INDEX) return false;
|
||||
if (NOT_TRACKED_SHIP.some((d) => p.startsWith(d))) return false;
|
||||
if (RUNTIME_DIRS.some((d) => p.startsWith(d)) || PLAYER_TOOL_DIRS.some((d) => p.startsWith(d))) return true;
|
||||
return ROOT_FILES.includes(p) || PLAYER_DOCS.includes(p) || RUNTIME_RESEARCH.includes(p) || PLAYER_SCRIPTS.includes(p) || PLAYER_TOOLS.includes(p);
|
||||
@@ -322,7 +331,8 @@ export function plan(root, opts = {}) {
|
||||
const art = artPlan(root, { lite });
|
||||
const pkg = readJson(path.join(root, 'package.json')) || {};
|
||||
const lock = readJson(path.join(root, 'package-lock.json')) || {};
|
||||
const files = [...keep, ...art.files];
|
||||
const generated = shipsPacks(keep) ? [GENERATED_PACK_INDEX] : [];
|
||||
const files = [...keep, ...art.files, ...generated];
|
||||
const problems = [];
|
||||
for (const f of files) if (isRefused(f) || isJunk(f)) problems.push(`refused: ${f}`);
|
||||
const byCase = new Map();
|
||||
@@ -359,7 +369,7 @@ export function plan(root, opts = {}) {
|
||||
const vendorBytes = opts.measure !== false ? sumBytes(root, listTree(root, 'public/vendor')) : 0;
|
||||
const dropBytes = Object.values(dropped).reduce((n, g) => n + g.bytes, 0);
|
||||
return {
|
||||
version: pkg.version || '0.0.0', lite, tracked: keep, art: art.files, files, dropped, orphans: art.orphans,
|
||||
version: pkg.version || '0.0.0', lite, tracked: keep, art: art.files, generated, files, dropped, orphans: art.orphans,
|
||||
localArt: art.local, problems, names: names.length, scanned,
|
||||
bytes: {
|
||||
tracked: trackedBytes, art: artBytes, modules: modulesBytes, vendor: vendorBytes,
|
||||
@@ -380,6 +390,7 @@ export function formatSummary(p) {
|
||||
if (!p.lite) {
|
||||
lines.push(`art: ${p.art.length} files, ${MB(p.bytes.art)} (data/assets.json, public/fonts${p.localArt ? ', public/assets/local + data/local-assets.json' : '; no local-client art here'})`);
|
||||
}
|
||||
if (p.generated?.length) lines.push(`generated: ${p.generated.join(', ')} (the pack index of the shipped packs, for a static host)`);
|
||||
lines.push(`production node_modules: ${MB(p.bytes.modules)} · public/vendor: ${MB(p.bytes.vendor)} (npm ci --omit=dev and its postinstall)`);
|
||||
lines.push(`uncompressed: ${MB(p.bytes.total)}; a 0.1.x whole-tree copy would add ${MB(p.bytes.leftOut)}`);
|
||||
const groups = Object.entries(p.dropped).sort((a, b) => b[1].bytes - a[1].bytes);
|
||||
@@ -421,14 +432,20 @@ export function build(root, p, { out, install = true, force = false, keepStage =
|
||||
if (!force) throw new Error(`${t} already exists (--force replaces it)`);
|
||||
fs.rmSync(t, { recursive: true, force: true });
|
||||
}
|
||||
log(`copying ${p.files.length} files → ${stage}`);
|
||||
const generated = new Set(p.generated || []);
|
||||
log(`copying ${p.files.length - generated.size} files → ${stage}`);
|
||||
for (const rel of p.files) {
|
||||
if (generated.has(rel)) continue;
|
||||
const src = path.join(root, rel);
|
||||
const to = path.join(stage, rel);
|
||||
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||
fs.copyFileSync(src, to, fs.constants.COPYFILE_FICLONE);
|
||||
fs.chmodSync(to, fs.statSync(src).mode & 0o777);
|
||||
}
|
||||
if (generated.has(GENERATED_PACK_INDEX)) {
|
||||
const index = writePackIndex(stage, path.join(stage, GENERATED_PACK_INDEX));
|
||||
log(`${GENERATED_PACK_INDEX}: ${index.packs.map((x) => `${x.type} ${x.id}`).join(', ') || 'no pack'}`);
|
||||
}
|
||||
if (install) {
|
||||
log('npm ci --omit=dev …');
|
||||
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
// tools/packs.mjs — the content packs of a checkout (docs/PACKS.md; server/packs.js is the registry the server runs,
|
||||
// shared/packs.js the format):
|
||||
//
|
||||
// node tools/packs.mjs list [--json] the packs by type (folder packs in packs/, the language packs of
|
||||
// public/i18n/), what was skipped and why, the warnings
|
||||
// node tools/packs.mjs index [--out <file>] write the pack index — what GET /packs/index.json answers — to
|
||||
// packs/index.json (git-ignored), for a static host that cannot run the
|
||||
// server's live list; tools/package.mjs writes it into the release zips
|
||||
// node tools/packs.mjs check [--strict] list, and exit 1 with --strict when a pack is skipped or warned about
|
||||
// --root <dir> another checkout or an unpacked release (default: this checkout)
|
||||
//
|
||||
// The strings of a language pack are checked by node tools/i18n.mjs check <code> (docs/I18N.md).
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { scanPacks, packIndexOf } from '../server/packs.js';
|
||||
import { PACK_TYPES, PACK_INDEX_FILE } from '../shared/packs.js';
|
||||
|
||||
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const USAGE = 'usage: node tools/packs.mjs list [--json] | index [--out <file>] | check [--strict] [--root <dir>]';
|
||||
|
||||
/** The pack folders of a checkout. @param {string} root */
|
||||
export const packDirs = (root) => ({ publicDir: path.join(root, 'public'), dataDir: path.join(root, 'data'), packsDir: path.join(root, 'packs') });
|
||||
|
||||
/**
|
||||
* Write the pack index of a checkout (default <root>/packs/index.json); returns its body.
|
||||
* @param {string} root
|
||||
* @param {string} [out]
|
||||
*/
|
||||
export function writePackIndex(root, out = path.join(root, 'packs', PACK_INDEX_FILE)) {
|
||||
const { packs } = scanPacks(packDirs(root));
|
||||
const body = packIndexOf(packs);
|
||||
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||
fs.writeFileSync(out, `${JSON.stringify(body, null, 2)}\n`);
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* The CLI (exported for tools/i18n.mjs index).
|
||||
* @param {string[]} argv
|
||||
* @returns {Promise<number>} the exit code
|
||||
*/
|
||||
export async function main(argv) {
|
||||
const [cmd, ...rest] = argv;
|
||||
let root = REPO;
|
||||
let out = null;
|
||||
const flags = new Set();
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
const a = rest[i];
|
||||
if (a === '--root' || a === '--out') {
|
||||
const v = rest[++i];
|
||||
if (!v || v.startsWith('--')) { console.error(`packs: ${a} needs a path\n${USAGE}`); return 2; }
|
||||
if (a === '--root') root = path.resolve(v); else out = path.resolve(v);
|
||||
} else if (a.startsWith('--')) flags.add(a.slice(2));
|
||||
else { console.error(`packs: unexpected ${a}\n${USAGE}`); return 2; }
|
||||
}
|
||||
if (cmd === 'index') {
|
||||
const file = out || path.join(root, 'packs', PACK_INDEX_FILE);
|
||||
const body = writePackIndex(root, file);
|
||||
console.log(`wrote ${path.relative(process.cwd(), file) || file}: ${body.packs.length} pack(s) — ${body.packs.map((p) => `${p.type} ${p.id}`).join(', ') || 'none'}`);
|
||||
return 0;
|
||||
}
|
||||
if (cmd !== 'list' && cmd !== 'check') { console.log(USAGE); return cmd ? 2 : 0; }
|
||||
const scan = scanPacks(packDirs(root));
|
||||
if (flags.has('json')) { console.log(JSON.stringify({ index: packIndexOf(scan.packs), skipped: scan.skipped, warnings: scan.warnings }, null, 2)); return 0; }
|
||||
for (const type of Object.keys(PACK_TYPES)) {
|
||||
const list = scan.packs.filter((p) => p.type === type);
|
||||
console.log(`${type} (${PACK_TYPES[type].status}: ${PACK_TYPES[type].summary}): ${list.length ? '' : 'none'}`);
|
||||
for (const p of list) {
|
||||
const m = p.manifest;
|
||||
const lang = type === 'lang' ? ` ${m.lang}${m.fallback?.length ? ` → ${m.fallback.join(' → ')}` : ''}` : '';
|
||||
const extra = [m.version && `v${m.version}`, m.app && `app ${m.app}${m.compatible ? '' : ' (not this version)'}`, p.strings !== undefined && `${p.strings} strings`, p.files.data && 'game texts'].filter(Boolean).join(', ');
|
||||
console.log(` ${p.id.padEnd(16)} ${m.name}${m.englishName !== m.name ? ` (${m.englishName})` : ''}${lang} — ${p.where}${extra ? `; ${extra}` : ''}`);
|
||||
}
|
||||
}
|
||||
for (const k of scan.skipped) console.log(`skipped ${k.where}: ${k.problems.join('; ')}`);
|
||||
for (const w of scan.warnings) console.log(`warning ${w.where}: ${w.warning}`);
|
||||
return cmd === 'check' && flags.has('strict') && (scan.skipped.length || scan.warnings.length) ? 1 : 0;
|
||||
}
|
||||
|
||||
const invoked = (() => { try { return pathToFileURL(fs.realpathSync(process.argv[1] || '')).href; } catch { return null; } })();
|
||||
if (invoked === import.meta.url) main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (e) => { console.error(`packs: ${e.message}`); process.exitCode = 2; });
|
||||
Reference in new issue
Block a user