feedback5: merge W5I — real i18n phase 1: shared/i18n.js (Chinese msgids, params, data names), English game texts from the official EN client data (data/i18n/en.json), the 中文 / English switch on the title screen and in 设置, title / lobby / room / settings converted, server toasts and ticker lines with msgid + params, tools/i18n.mjs, docs/I18N.md (PR #70's translations by @YuriRestia reused, credited)
Conflict: server/match/player/basics.js imports (W5G's checkNotOwned + W5I's msg / dn). data/i18n/en.json rebuilt on the merged data (tools/build-i18n.mjs --offline): the 自选 data adds texts of CN-only operators and modules the EN client lacks, so coverage is 97.6 % (skills 95.9, talents 95.1); the coverage floors in test/i18n-data.test.js and docs/I18N.md say so. Golden unchanged; i18n / lobby / match / ui suites 1144/1144 after the rebuild, i18n e2e 1/1.
This commit is contained in:
commit
a611cb013c
38 files changed
+4043
-194
No files matched your search
@@ -0,0 +1,717 @@
|
||||
#!/usr/bin/env node
|
||||
// tools/build-i18n.mjs — LOCALIZED GAME DATA: data/i18n/en.json from the official EN client data.
|
||||
//
|
||||
// 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
|
||||
// official English counterparts as an overlay keyed by the remake's own ids and field paths (format: shared/i18nData.js),
|
||||
// which public/js/data.js applies when the player switches to English. tools/build-data.mjs is not touched.
|
||||
//
|
||||
// How a text is matched (no id table to keep up to date — the official tables are walked side by side):
|
||||
// 1. the zh_CN and the EN tables are walked in parallel (same keys; arrays of keyed records by their key), giving
|
||||
// pairs Chinese source → English source with the ids on their path as "scope";
|
||||
// 2. a data text equal to a Chinese source takes its English source (exact);
|
||||
// 3. a data text whose blackboard placeholders were resolved by build-data ({atk:0%} → 80%) is matched against the
|
||||
// Chinese templates of the same shape: the numbers are read back into a blackboard, the Chinese template must
|
||||
// reproduce the text exactly, and the English template is filled with the same values (template);
|
||||
// 4. a few remake-made composites (stage names) are assembled from translated parts (composite);
|
||||
// 5. what the official EN data lacks falls back to tools/i18n/fallback-remake.json (the remake's own strings),
|
||||
// tools/i18n/fallback-pr70.json (PR #70's translations by @YuriRestia, reused with credit) and the UI table
|
||||
// public/i18n/en.json (a data text that is also a UI msgid); anything else stays Chinese.
|
||||
// Ties between several English sources prefer the one whose path shares the record's ids, then the most frequent.
|
||||
// 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)
|
||||
// --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)
|
||||
// --check build in memory and exit 1 when the output differs from --out (nothing written)
|
||||
// Determinism: the output depends only on the inputs (stable key order, no timestamps).
|
||||
|
||||
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join, resolve, relative } from 'node:path';
|
||||
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]';
|
||||
|
||||
export const EN_SOURCES = Object.freeze({
|
||||
assets: {
|
||||
label: 'ArknightsAssets/ArknightsGamedata (en)',
|
||||
home: 'https://github.com/ArknightsAssets/ArknightsGamedata',
|
||||
url: 'https://raw.githubusercontent.com/ArknightsAssets/ArknightsGamedata/master/en/gamedata/',
|
||||
cache: '.cache/gamedata-en',
|
||||
},
|
||||
yostar: {
|
||||
label: 'Kengxxiao/ArknightsGameData_YoStar (en_US)',
|
||||
home: 'https://github.com/Kengxxiao/ArknightsGameData_YoStar',
|
||||
url: 'https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData_YoStar/main/en_US/gamedata/',
|
||||
cache: '.cache/gamedata-en-yostar',
|
||||
},
|
||||
});
|
||||
const ZH_URL = 'https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData/master/zh_CN/gamedata/';
|
||||
|
||||
/** 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 } }) },
|
||||
{ rel: 'excel/character_table.json' },
|
||||
{ rel: 'excel/skill_table.json' },
|
||||
{ rel: 'excel/uniequip_table.json' },
|
||||
{ rel: 'excel/battle_equip_table.json' },
|
||||
{ rel: 'excel/enemy_handbook_table.json', pick: (j) => j?.enemyData },
|
||||
{ rel: 'excel/item_table.json', pick: (j) => j?.items },
|
||||
{ rel: 'excel/token_table.json' },
|
||||
{ rel: 'levels/enemydata/enemy_database.json', pick: (j) => (Array.isArray(j?.enemies) ? kvMap(j.enemies) : j) },
|
||||
];
|
||||
|
||||
/** A Unity-style list of { Key, Value } entries as a plain map (the two dumps serialize some tables either way). */
|
||||
function kvMap(list) {
|
||||
const out = {};
|
||||
for (const e of list) if (e && (typeof e.Key === 'string' || typeof e.key === 'string')) out[e.Key ?? e.key] = e.Value ?? e.value;
|
||||
return out;
|
||||
}
|
||||
const isKvList = (v) => Array.isArray(v) && v.length > 0 && v.every((e) => e && typeof e === 'object' && typeof e.Key === 'string' && 'Value' in e && Object.keys(e).length === 2);
|
||||
|
||||
/** Scope segment of the act2autochess sub-tree (TABLES pick key): the season data/*.json is built from. */
|
||||
const SEASON_SCOPE = 'act2';
|
||||
|
||||
/** data/*.json files localized, in output order. */
|
||||
export const DATA_FILES = Object.freeze(['chess', 'backups', 'tokens', 'bonds', 'garrisons', 'items', 'bands', 'effects',
|
||||
'choices', 'enemies', 'factions', 'stages', 'bosses', 'config', 'emotes']);
|
||||
|
||||
/**
|
||||
* Research / developer notes of the data files (English with Chinese terms; no screen shows them): never translated,
|
||||
* counted apart. A leaf is a note when one of these keys is on its path, or its own key is in NOTE_LEAF_KEYS.
|
||||
*/
|
||||
const NOTE_KEYS = new Set(['spec', 'implFormula', 'assumed', 'notes', 'formula', 'helperOrder', '_why', '_doc', 'algorithm', 'titleRule']);
|
||||
const NOTE_LEAF_KEYS = new Set(['cards', 'rule', 'note_dev']);
|
||||
|
||||
/** Fields whose translation also feeds the `names` map (server ticker / toast args are data names). */
|
||||
const NAME_KEYS = new Set(['name', 'effectName']);
|
||||
/** File priority when two records give the same Chinese name different English names. */
|
||||
const NAME_PRIORITY = ['chess', 'backups', 'tokens', 'items', 'bonds', 'bands', 'effects', 'garrisons', 'enemies', 'bosses', 'stages', 'choices', 'config', 'factions', 'emotes'];
|
||||
|
||||
const CJK = /[\u3400-\u9fff\uf900-\ufaff]/;
|
||||
/** Official rich-text markup (a styled / term tag or its close). */
|
||||
const RICH = /<[@$#][^<>]*>|<\/>/;
|
||||
const hasCjk = (s) => typeof s === 'string' && CJK.test(s);
|
||||
|
||||
// ===== text helpers (kept identical to tools/build-data.mjs: the zh round trip in the report proves it) ============
|
||||
|
||||
export function unescapeNewlines(s) { return typeof s === 'string' ? s.replace(/\\n/g, '\n') : s; }
|
||||
export function stripRich(s) {
|
||||
if (typeof s !== 'string') return s ?? null;
|
||||
return unescapeNewlines(s)
|
||||
.replace(/<[@$#][^<>]*>/g, '')
|
||||
.replace(/<\/>/g, '')
|
||||
.replace(/<\/?color[^<>]*>/gi, '')
|
||||
.replace(/<\/?[bi]>/gi, '');
|
||||
}
|
||||
function cleanNum(v) {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) return v;
|
||||
if (Number.isInteger(v) || Math.abs(v) >= 1e6) return v;
|
||||
return Math.round(v * 1e6) / 1e6;
|
||||
}
|
||||
export function formatValue(v, fmt) {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) return String(v);
|
||||
if (!fmt) return String(cleanNum(v));
|
||||
const pct = fmt.endsWith('%');
|
||||
const core = pct ? fmt.slice(0, -1) : fmt;
|
||||
const decimals = core.includes('.') ? core.split('.')[1].length : 0;
|
||||
const x = pct ? v * 100 : v;
|
||||
const rounded = Math.sign(x) * Math.round(Math.abs(x) * 10 ** decimals + 1e-9) / 10 ** decimals;
|
||||
return rounded.toFixed(decimals) + (pct ? '%' : '');
|
||||
}
|
||||
const PH = /\{(-?)([^{}:]+)(?::([^{}]+))?\}/g;
|
||||
/** Resolve {key}, {-key}, {key:0%} against a blackboard (case-insensitive); unknown keys stay verbatim. */
|
||||
export function resolvePlaceholders(text, bb, bbStr = {}) {
|
||||
if (typeof text !== 'string') return text ?? null;
|
||||
const lower = new Map();
|
||||
for (const [k, v] of Object.entries(bb || {})) lower.set(k.toLowerCase(), v);
|
||||
const lowerStr = new Map();
|
||||
for (const [k, v] of Object.entries(bbStr || {})) lowerStr.set(k.toLowerCase(), v);
|
||||
return text.replace(PH, (m, neg, key, fmt) => {
|
||||
const k = key.trim().toLowerCase();
|
||||
if (lowerStr.has(k) && (!lower.has(k) || !fmt)) return lowerStr.get(k);
|
||||
if (lower.has(k)) return formatValue(neg ? -lower.get(k) : lower.get(k), fmt);
|
||||
return m;
|
||||
});
|
||||
}
|
||||
const hasPlaceholder = (s) => typeof s === 'string' && /\{-?[A-Za-z_@][^{}:]*(?::[^{}]+)?\}/.test(s);
|
||||
|
||||
// ===== CLI & IO ======================================================================================================
|
||||
|
||||
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'),
|
||||
};
|
||||
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' };
|
||||
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];
|
||||
if (name === '--help' || name === '-h') { console.log(USAGE); process.exit(0); }
|
||||
if (flags[name] && inline === null) { opts[flags[name]] = true; continue; }
|
||||
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);
|
||||
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);
|
||||
return opts;
|
||||
}
|
||||
|
||||
async function download(url, abs, log) {
|
||||
let lastErr;
|
||||
for (let attempt = 1; attempt <= 4; attempt++) {
|
||||
try {
|
||||
log(` download ${url}${attempt > 1 ? ` (attempt ${attempt})` : ''}`);
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(240_000) });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
||||
const text = await res.text();
|
||||
JSON.parse(text); // never cache a truncated file
|
||||
await mkdir(dirname(abs), { recursive: true });
|
||||
const tmp = `${abs}.tmp-${process.pid}`;
|
||||
await writeFile(tmp, text);
|
||||
await rename(tmp, abs);
|
||||
return;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
if (attempt < 4) await new Promise((r) => setTimeout(r, 600 * attempt));
|
||||
}
|
||||
}
|
||||
throw new Error(`cannot download ${url}: ${lastErr && lastErr.message}`);
|
||||
}
|
||||
|
||||
async function loadTable(dir, base, rel, { refresh, offline, log }) {
|
||||
const abs = join(dir, rel);
|
||||
if (refresh || !existsSync(abs)) {
|
||||
if (offline) throw new Error(`missing cached table ${abs} (offline mode)`);
|
||||
await download(base + rel, abs, log);
|
||||
}
|
||||
return JSON.parse(await readFile(abs, 'utf8'));
|
||||
}
|
||||
|
||||
// ===== the parallel walk: Chinese source → English source pairs ======================================================
|
||||
|
||||
const KEY_FIELDS = ['Key', 'key', 'id', 'charId', 'skillId', 'uniEquipId', 'enemyId', 'itemId', 'effectId', 'bondId', 'bandId', 'stageId', 'modeId', 'bossId', 'talentIndex'];
|
||||
|
||||
/** The field an array of records is keyed by (every element an object with a distinct string/number value), or null. */
|
||||
function arrayKeyField(arr) {
|
||||
if (!Array.isArray(arr) || arr.length < 2 || !arr.every((x) => x && typeof x === 'object' && !Array.isArray(x))) return null;
|
||||
for (const f of KEY_FIELDS) {
|
||||
const vals = arr.map((x) => x[f]);
|
||||
if (vals.every((v) => typeof v === 'string' || typeof v === 'number') && new Set(vals).size === vals.length) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Path segments that identify game objects (they contain '_' or a digit): the scope of a pair. */
|
||||
const isScopeSeg = (s) => typeof s === 'string' && /[_\d]/.test(s) && s.length <= 64;
|
||||
/** A dictionary of records (≥ 6 keys, every value an object): its keys are ids even without '_' ('longrange'). */
|
||||
const isDict = (o) => {
|
||||
const vals = Object.values(o);
|
||||
return vals.length >= 6 && vals.every((v) => v && typeof v === 'object');
|
||||
};
|
||||
|
||||
/**
|
||||
* Pair index: Chinese source text (newlines unescaped) → English variants with counts and scopes; plus a template
|
||||
* index (shape key → [{ zh, en, scope }]) for texts with placeholders.
|
||||
*/
|
||||
class PairIndex {
|
||||
constructor() {
|
||||
/** @type {Map<string, Map<string, { n: number, scopes: Set<string> }>>} */
|
||||
this.exact = new Map();
|
||||
/** @type {Map<string, Map<string, { n: number, scopes: Set<string> }>>} */
|
||||
this.plain = new Map();
|
||||
/** @type {Map<string, Map<string, { zh: string, en: string, n: number, scopes: Set<string> }>>} */
|
||||
this.templates = new Map();
|
||||
this.pairs = 0;
|
||||
}
|
||||
|
||||
static add(map, zh, en, scope) {
|
||||
let m = map.get(zh);
|
||||
if (!m) { m = new Map(); map.set(zh, m); }
|
||||
let e = m.get(en);
|
||||
if (!e) { e = { n: 0, scopes: new Set() }; m.set(en, e); }
|
||||
e.n++;
|
||||
for (const s of scope) if (e.scopes.size < 48) e.scopes.add(s);
|
||||
}
|
||||
|
||||
add(zhRaw, enRaw, scope) {
|
||||
const zh = unescapeNewlines(zhRaw);
|
||||
const en = unescapeNewlines(enRaw);
|
||||
if (!en.trim() || hasCjk(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)
|
||||
if (zh.trim() !== zh) PairIndex.add(this.exact, zh.trim(), en.trim(), scope);
|
||||
const zp = stripRich(zh);
|
||||
if (zp !== zh) PairIndex.add(this.plain, zp, stripRich(en), scope);
|
||||
if (hasPlaceholder(zh)) {
|
||||
const key = shapeKey(zh);
|
||||
let m = this.templates.get(key);
|
||||
if (!m) { m = new Map(); this.templates.set(key, m); }
|
||||
const id = `${zh}\u0000${en}`;
|
||||
let e = m.get(id);
|
||||
if (!e) { e = { zh, en, n: 0, scopes: new Set() }; m.set(id, e); }
|
||||
e.n++;
|
||||
for (const s of scope) if (e.scopes.size < 48) e.scopes.add(s);
|
||||
}
|
||||
}
|
||||
|
||||
/** Walk a zh_CN tree and its EN counterpart together. */
|
||||
walk(zh, en, scope = []) {
|
||||
if (isKvList(zh)) zh = kvMap(zh);
|
||||
if (isKvList(en)) en = kvMap(en);
|
||||
if (typeof zh === 'string') {
|
||||
if (typeof en === 'string' && hasCjk(zh)) this.add(zh, en, scope);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(zh)) {
|
||||
if (!Array.isArray(en)) return;
|
||||
const kf = arrayKeyField(zh);
|
||||
if (kf && arrayKeyField(en) === kf) {
|
||||
const byKey = new Map(en.map((x) => [x[kf], x]));
|
||||
for (const x of zh) {
|
||||
const y = byKey.get(x[kf]);
|
||||
if (y !== undefined) this.walk(x, y, isScopeSeg(String(x[kf])) ? [...scope, String(x[kf])] : scope);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (zh.length !== en.length) return; // unkeyed arrays of different length cannot be aligned safely
|
||||
for (let i = 0; i < zh.length; i++) this.walk(zh[i], en[i], scope);
|
||||
return;
|
||||
}
|
||||
if (zh && typeof zh === 'object') {
|
||||
if (!en || typeof en !== 'object' || Array.isArray(en)) return;
|
||||
const dict = isDict(zh);
|
||||
for (const k of Object.keys(zh)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(en, k)) continue;
|
||||
this.walk(zh[k], en[k], dict || isScopeSeg(k) ? [...scope, k] : scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shape of a text for template lookup: markup stripped, placeholders and numbers folded into one marker. */
|
||||
export function shapeKey(s) {
|
||||
return stripRich(s)
|
||||
.replace(/\{-?[^{}:]+(?::[^{}]+)?\}/g, '\u0001')
|
||||
.replace(/[+\-]?\d+(?:\.\d+)?%?/g, '\u0001')
|
||||
.replace(/[+\-]?\u0001%?/g, '\u0001')
|
||||
.replace(/\u0001+/g, '\u0001');
|
||||
}
|
||||
|
||||
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
/**
|
||||
* Read a resolved text back into the blackboard values of a template: { bb, bbStr } that make
|
||||
* resolvePlaceholders(template) reproduce `text` exactly, or null.
|
||||
* @param {string} template
|
||||
* @param {string} text
|
||||
*/
|
||||
export function inverseFormat(template, text) {
|
||||
const parts = [];
|
||||
let last = 0;
|
||||
let re = '^';
|
||||
for (const m of template.matchAll(PH)) {
|
||||
re += escapeRe(template.slice(last, m.index)) + '([\\s\\S]*?)';
|
||||
parts.push({ neg: m[1] === '-', key: m[2].trim(), fmt: m[3] || null });
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (!parts.length) return null;
|
||||
re += `${escapeRe(template.slice(last))}$`;
|
||||
let mm;
|
||||
try { mm = new RegExp(re).exec(text); } catch { return null; }
|
||||
if (!mm) return null;
|
||||
const bb = {};
|
||||
const bbStr = {};
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const { neg, key, fmt } = parts[i];
|
||||
const cap = mm[i + 1];
|
||||
let v;
|
||||
if (fmt) {
|
||||
const pct = fmt.endsWith('%');
|
||||
const num = pct ? cap.match(/^(-?\d+(?:\.\d+)?)%$/) : cap.match(/^(-?\d+(?:\.\d+)?)$/);
|
||||
if (!num) return null;
|
||||
v = cleanNum(Number(num[1]) / (pct ? 100 : 1));
|
||||
} else if (/^-?\d+(?:\.\d+)?(?:e[+-]?\d+)?$/i.test(cap)) {
|
||||
v = Number(cap);
|
||||
} else {
|
||||
if (neg) return null;
|
||||
bbStr[key] = cap;
|
||||
continue;
|
||||
}
|
||||
if (neg) v = -v;
|
||||
v = cleanNum(v);
|
||||
if (Object.prototype.hasOwnProperty.call(bb, key) && bb[key] !== v) return null;
|
||||
bb[key] = v;
|
||||
}
|
||||
if (resolvePlaceholders(template, bb, bbStr) !== text) return null;
|
||||
return { bb, bbStr };
|
||||
}
|
||||
|
||||
// ===== translation of one data text ==================================================================================
|
||||
|
||||
const scoreScopes = (scopes, ctx) => {
|
||||
let s = 0;
|
||||
for (const id of ctx.near) if (scopes.has(id)) s += 4;
|
||||
for (const id of ctx.record) if (scopes.has(id)) s += 1;
|
||||
return s;
|
||||
};
|
||||
|
||||
/** Best English variant of a set: scope match first, then frequency, then text order (deterministic). */
|
||||
function bestVariant(variants, ctx) {
|
||||
let best = null;
|
||||
for (const [en, e] of variants) {
|
||||
const cand = { en, score: scoreScopes(e.scopes, ctx), n: e.n };
|
||||
if (!best || cand.score > best.score || (cand.score === best.score && (cand.n > best.n || (cand.n === best.n && cand.en < best.en)))) best = cand;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translator over the pair index and the fallback dictionary.
|
||||
*/
|
||||
class Translator {
|
||||
/**
|
||||
* @param {PairIndex} index
|
||||
* @param {{ name: string, map: Map<string, string> }[]} fallbacks in order of preference
|
||||
*/
|
||||
constructor(index, fallbacks) {
|
||||
this.index = index;
|
||||
this.fallbacks = fallbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} zh the data text
|
||||
* @param {{ near: Set<string>, record: Set<string>, bb?: object|null, bbStr?: object|null }} ctx
|
||||
* @returns {{ en: string, how: string } | null}
|
||||
*/
|
||||
translate(zh, ctx) {
|
||||
const ex = this.index.exact.get(zh);
|
||||
if (ex) return { en: bestVariant(ex, ctx).en, how: 'exact' };
|
||||
// a plain text (build-data strips the markup of `desc`, `text`, card / device descriptions) against stripped sources
|
||||
if (!RICH.test(zh)) {
|
||||
const pl = this.index.plain.get(zh);
|
||||
if (pl) return { en: bestVariant(pl, ctx).en, how: 'exact' };
|
||||
}
|
||||
const tpl = this.template(zh, ctx);
|
||||
if (tpl) return tpl;
|
||||
const comp = this.composite(zh, ctx);
|
||||
if (comp) return comp;
|
||||
for (const { name, map } of this.fallbacks) {
|
||||
const fb = map.get(zh);
|
||||
if (fb) return { en: fb, how: name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
template(zh, ctx) {
|
||||
const cands = this.index.templates.get(shapeKey(zh));
|
||||
if (!cands) return null;
|
||||
let best = null;
|
||||
const plain = !RICH.test(zh);
|
||||
for (const c of cands.values()) {
|
||||
const zhT = plain ? stripRich(c.zh) : c.zh;
|
||||
const inv = inverseFormat(zhT, zh);
|
||||
if (!inv) continue;
|
||||
const enT = plain ? stripRich(c.en) : c.en;
|
||||
const bb = { ...(ctx.bb || {}), ...inv.bb };
|
||||
const bbStr = { ...(ctx.bbStr || {}), ...inv.bbStr };
|
||||
const en = resolvePlaceholders(enT, bb, bbStr);
|
||||
if (hasPlaceholder(en) && !hasPlaceholder(zh)) continue; // the English template needs a value the text lacks
|
||||
const cand = { en, score: scoreScopes(c.scopes, ctx), n: c.n };
|
||||
if (!best || cand.score > best.score || (cand.score === best.score && (cand.n > best.n || (cand.n === best.n && cand.en < best.en)))) best = cand;
|
||||
}
|
||||
return best ? { en: best.en, how: 'template' } : null;
|
||||
}
|
||||
|
||||
/** Remake-made composites: stage names "战场#05(下半) 源石流发生装置". */
|
||||
composite(zh, ctx) {
|
||||
const m = zh.match(/^战场#(\d+)(?:\((上半|下半)\))?(?:\s+(.+))?$/);
|
||||
if (!m) return null;
|
||||
let en = `Battlefield #${m[1]}`;
|
||||
if (m[2]) en += m[2] === '上半' ? ' (First Half)' : ' (Second Half)';
|
||||
if (m[3]) {
|
||||
const parts = m[3].split('/').map((p) => this.translate(p.trim(), ctx));
|
||||
if (parts.some((p) => !p)) return null;
|
||||
en += ` ${parts.map((p) => p.en).join(' / ')}`;
|
||||
}
|
||||
return { en, how: 'composite' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== walking the data files ========================================================================================
|
||||
|
||||
const ID_VALUE = /^[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+$/;
|
||||
|
||||
/** Id-like string values of an object (an `…Id` / `id` / `key` field, or a value shaped like an id), `depth` levels down. */
|
||||
function idsOf(obj, depth, out = new Set()) {
|
||||
if (!obj || typeof obj !== 'object' || depth < 0) return out;
|
||||
for (const [k, v] of Array.isArray(obj) ? obj.entries() : Object.entries(obj)) {
|
||||
if (typeof v === 'string') {
|
||||
if (v.length <= 64 && (ID_VALUE.test(v) || (typeof k === 'string' && /(?:^id|Id|^key)$/.test(k) && /^[A-Za-z][\w-]*$/.test(v)))) out.add(v);
|
||||
} else if (v && typeof v === 'object') idsOf(v, depth - 1, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Kind of a leaf for the coverage report. */
|
||||
function kindOf(file, path) {
|
||||
if (file === 'chess' || file === 'backups') {
|
||||
const keys = path.filter((k) => typeof k === 'string');
|
||||
if (keys.some((k) => k === 'talentChanges' || k === 'talents' || k === 'talentsBase')) return 'talents';
|
||||
if (keys.some((k) => k === 'modules' || k === 'module' || k === 'moduleNames')) return 'modules';
|
||||
if (keys.some((k) => k === 'skills' || k === 'skill')) return 'skills';
|
||||
if (keys.some((k) => k === 'trait' || k === 'traitBase')) return 'traits';
|
||||
return 'operators';
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
const isNotePath = (path) => path.some((k) => typeof k === 'string' && NOTE_KEYS.has(k))
|
||||
|| (typeof path[path.length - 1] === 'string' && NOTE_LEAF_KEYS.has(path[path.length - 1]));
|
||||
|
||||
/**
|
||||
* Translate every Chinese text of one record. Raw/plain pairs (descRaw/desc, textRaw/text, moduleDescRaw/moduleDesc,
|
||||
* effectDescRaw/effectDesc): the raw text is translated, the plain one is its stripped translation when it is the
|
||||
* stripped Chinese raw text (as build-data writes it).
|
||||
*/
|
||||
function translateRecord(file, id, record, tr, stats, samples) {
|
||||
const leaves = [];
|
||||
const recordIds = idsOf(record, 3);
|
||||
recordIds.add(id);
|
||||
// data/*.json is the 下半 season (act2autochess): its wording wins a tie with the 上半 one (the `act2` sub-tree)
|
||||
recordIds.add(SEASON_SCOPE);
|
||||
const visit = (node, path, nearIds) => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
const arr = Array.isArray(node);
|
||||
const near = arr ? nearIds : new Set([...nearIds, ...idsOf(node, 0)]);
|
||||
const done = new Map();
|
||||
const keys = arr ? node.map((_, i) => i) : Object.keys(node);
|
||||
const ordered = arr ? keys : [...keys.filter((k) => k.endsWith('Raw')), ...keys.filter((k) => !k.endsWith('Raw'))];
|
||||
for (const k of ordered) {
|
||||
const v = node[k];
|
||||
const p = [...path, k];
|
||||
if (typeof v !== 'string') { if (v && typeof v === 'object') visit(v, p, near); continue; }
|
||||
if (!hasCjk(v)) continue;
|
||||
const kind = kindOf(file, p);
|
||||
if (isNotePath(p)) { stats.note(kind); continue; }
|
||||
let res = null;
|
||||
const rawKey = `${k}Raw`;
|
||||
if (!arr && !k.endsWith('Raw') && typeof node[rawKey] === 'string' && done.has(rawKey) && stripRich(node[rawKey]) === v) {
|
||||
const r = done.get(rawKey);
|
||||
res = { en: stripRich(r.en), how: r.how };
|
||||
}
|
||||
if (!res) {
|
||||
const bb = arr ? null : node.bb && typeof node.bb === 'object' ? { duration: node.duration, ...node.bb } : (Number.isFinite(node.duration) ? { duration: node.duration } : null);
|
||||
res = tr.translate(v, { near, record: recordIds, bb, bbStr: arr ? null : node.bbStr || null });
|
||||
}
|
||||
if (res) {
|
||||
done.set(k, res);
|
||||
leaves.push({ path: p, zh: v, en: res.en, kind, key: arr ? path[path.length - 1] : k });
|
||||
stats.hit(kind, res.how);
|
||||
} else {
|
||||
stats.miss(kind, v);
|
||||
const list = samples[kind] || (samples[kind] = []);
|
||||
if (list.length < 25) list.push(`${file}.${id}.${p.join('.')}: ${v.slice(0, 80).replace(/\n/g, '⏎')}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(record, [], new Set([id]));
|
||||
return leaves;
|
||||
}
|
||||
|
||||
/** A record that is a plain string (config `seasonName`). */
|
||||
function translateScalar(file, id, text, tr, stats, samples) {
|
||||
if (!hasCjk(text) || NOTE_KEYS.has(id)) return [];
|
||||
const kind = kindOf(file, [id]);
|
||||
const res = tr.translate(text, { near: new Set([id]), record: new Set([id, SEASON_SCOPE]), bb: null, bbStr: null });
|
||||
if (!res) {
|
||||
stats.miss(kind, text);
|
||||
(samples[kind] || (samples[kind] = [])).push(`${file}.${id}: ${text.slice(0, 80)}`);
|
||||
return [];
|
||||
}
|
||||
stats.hit(kind, res.how);
|
||||
return [{ path: [], zh: text, en: res.en, kind, key: id }];
|
||||
}
|
||||
|
||||
class Stats {
|
||||
constructor() { this.kinds = {}; this.missing = new Map(); }
|
||||
row(kind) { return this.kinds[kind] || (this.kinds[kind] = { texts: 0, exact: 0, template: 0, composite: 0, remake: 0, pr70: 0, ui: 0, missing: 0, notes: 0 }); }
|
||||
hit(kind, how) { const r = this.row(kind); r.texts++; r[how]++; }
|
||||
miss(kind, text) {
|
||||
const r = this.row(kind);
|
||||
r.texts++;
|
||||
r.missing++;
|
||||
const m = this.missing.get(text) || { kind, n: 0 };
|
||||
m.n++;
|
||||
this.missing.set(text, m);
|
||||
}
|
||||
note(kind) { this.row(kind).notes++; }
|
||||
}
|
||||
|
||||
// ===== main ==========================================================================================================
|
||||
|
||||
const dictMap = (d) => new Map(Object.entries(d || {}).filter(([k, v]) => !k.startsWith('_') && typeof v === 'string' && v && !hasCjk(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();
|
||||
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) },
|
||||
]);
|
||||
const stats = new Stats();
|
||||
const samples = {};
|
||||
const files = {};
|
||||
const nameVotes = new Map(); // zh → [{ en, prio }]
|
||||
for (const file of DATA_FILES) {
|
||||
const json = data[file];
|
||||
if (!json || typeof json !== 'object' || Array.isArray(json)) continue;
|
||||
const out = {};
|
||||
for (const [id, record] of Object.entries(json)) {
|
||||
if (!record || (typeof record !== 'object' && typeof record !== 'string')) continue;
|
||||
const leaves = typeof record === 'string' ? translateScalar(file, id, record, tr, stats, samples) : translateRecord(file, id, record, tr, stats, samples);
|
||||
const ov = buildRecordOverlay(leaves.map(({ path, zh: z, en: e }) => ({ path, zh: z, en: e })));
|
||||
if (ov) out[id] = ov;
|
||||
for (const l of leaves) {
|
||||
// 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;
|
||||
const prio = NAME_PRIORITY.indexOf(file);
|
||||
const list = nameVotes.get(l.zh) || [];
|
||||
list.push({ en: l.en, prio: prio < 0 ? 99 : prio });
|
||||
nameVotes.set(l.zh, list);
|
||||
}
|
||||
}
|
||||
if (Object.keys(out).length) files[file] = out;
|
||||
}
|
||||
const names = {};
|
||||
const nameConflicts = [];
|
||||
for (const zhName of [...nameVotes.keys()].sort()) {
|
||||
const votes = nameVotes.get(zhName).sort((a, b) => a.prio - b.prio || (a.en < b.en ? -1 : a.en > b.en ? 1 : 0));
|
||||
names[zhName] = votes[0].en;
|
||||
const distinct = [...new Set(votes.map((v) => v.en))];
|
||||
if (distinct.length > 1) nameConflicts.push(`${zhName} → ${distinct.join(' | ')}`);
|
||||
}
|
||||
const coverage = {};
|
||||
let total = 0, translated = 0, official = 0;
|
||||
for (const [kind, r] of Object.entries(stats.kinds).sort()) {
|
||||
const ok = r.exact + r.template + r.composite + r.remake + r.pr70 + r.ui;
|
||||
coverage[kind] = { ...r, translated: ok, pct: r.texts ? Math.round((ok / r.texts) * 1000) / 10 : 100 };
|
||||
total += r.texts; translated += ok; official += r.exact + r.template + r.composite;
|
||||
}
|
||||
const overlay = {
|
||||
version: OVERLAY_VERSION,
|
||||
lang: 'en',
|
||||
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',
|
||||
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 },
|
||||
},
|
||||
names,
|
||||
files,
|
||||
};
|
||||
const missing = [...stats.missing].sort((a, b) => (a[1].kind < b[1].kind ? -1 : a[1].kind > b[1].kind ? 1 : a[0] < b[0] ? -1 : 1))
|
||||
.map(([text, m]) => ({ kind: m.kind, n: m.n, text }));
|
||||
return { overlay, report: { coverage, totals: overlay.meta.totals, pairs: index.pairs, nameConflicts, untranslated: samples, missing } };
|
||||
}
|
||||
|
||||
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 zh = [];
|
||||
const en = [];
|
||||
for (const t of TABLES) {
|
||||
const zj = await loadTable(opts.cache, ZH_URL, t.rel, { refresh: false, offline: opts.offline, log });
|
||||
const ej = await loadTable(opts.cacheEn, src.url, t.rel, { refresh: opts.refresh, offline: opts.offline, log });
|
||||
zh.push(t.pick ? t.pick(zj) : zj);
|
||||
en.push(t.pick ? t.pick(ej) : ej);
|
||||
}
|
||||
const seasonEn = en[0]?.act2 ? 'act2autochess' : en[0]?.act1 ? 'act1autochess' : 'none (no 盟约 season in this EN build)';
|
||||
let version = null;
|
||||
const versionFile = join(opts.cacheEn, 'excel', 'data_version.txt');
|
||||
if (!opts.offline && (opts.refresh || !existsSync(versionFile))) {
|
||||
try {
|
||||
const res = await fetch(`${src.url}excel/data_version.txt`, { signal: AbortSignal.timeout(30_000) });
|
||||
if (res.ok) { await mkdir(dirname(versionFile), { recursive: true }); await writeFile(versionFile, await res.text()); }
|
||||
} catch { /* optional */ }
|
||||
}
|
||||
try { version = (await readFile(versionFile, 'utf8')).trim(); } catch { /* optional */ }
|
||||
const data = {};
|
||||
for (const f of DATA_FILES) {
|
||||
const abs = join(opts.data, `${f}.json`);
|
||||
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 = {
|
||||
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 } });
|
||||
|
||||
// self-check: applying the overlay keeps every record's shape (strings replace strings only)
|
||||
let applied = 0;
|
||||
for (const [file, fov] of Object.entries(overlay.files)) {
|
||||
const r = applyFileOverlay(data[file], fov);
|
||||
applied += r.applied;
|
||||
if (r.stale) throw new Error(`overlay of ${file} does not apply cleanly (${r.stale} stale leaves)`);
|
||||
}
|
||||
const text = `${JSON.stringify(overlay)}\n`;
|
||||
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):`);
|
||||
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)`);
|
||||
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); }
|
||||
log(` ${relative(ROOT, opts.out)} is up to date`);
|
||||
return;
|
||||
}
|
||||
await mkdir(dirname(opts.out), { recursive: true });
|
||||
const tmp = `${opts.out}.tmp-${process.pid}`;
|
||||
await writeFile(tmp, text);
|
||||
await rename(tmp, opts.out);
|
||||
log(` wrote ${relative(ROOT, opts.out)} (${(rep.bytes / 1024).toFixed(0)} KB); report ${relative(ROOT, opts.report)}`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch((e) => { console.error(`build-i18n: ${e.stack || e.message}`); process.exit(1); });
|
||||
}
|
||||
+632
@@ -0,0 +1,632 @@
|
||||
#!/usr/bin/env node
|
||||
// tools/i18n.mjs — UI string tooling for the gettext-style i18n (shared/i18n.js, public/i18n/<lang>.json; docs/I18N.md).
|
||||
//
|
||||
// node tools/i18n.mjs extract [paths…] [--list] [--json]
|
||||
// per file: the msgids passed to t() / tc() / 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 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
|
||||
// can change) are only marked N_('…') and listed: call t() where they are shown. Literals compared, used as keys,
|
||||
// or passed to string methods are left alone, and so is a line marked `// i18n-ignore`. A text run with a child
|
||||
// that is not a plain value (`${a || b}`, a vnode) is split into its static pieces. Prints a summary; --write saves
|
||||
// the files. Review the result.
|
||||
// node tools/i18n.mjs seed --from <file.json> [--lang en] [--all] [--write]
|
||||
// fill public/i18n/<lang>.json from a flat { msgid: translation } map for the msgids the code uses (--all: also
|
||||
// for the Chinese literals not wrapped yet, under the msgid the codemod would give them). Existing entries win.
|
||||
//
|
||||
// Default paths: public/js, shared, server (extract / seed skip server/sim, whose strings are game logic; check reads
|
||||
// its msg() and ctx.toast texts too). Server texts: a Chinese literal passed to m.toast / ctx.toast / tickerText is a
|
||||
// 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 path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const CJK = /[\u3400-\u9fff\uf900-\ufaff\u3000-\u303f\uff01-\uff60]/;
|
||||
const HAN = /[\u3400-\u9fff\uf900-\ufaff]/;
|
||||
const DEFAULT_ROOTS = ['public/js', 'shared', 'server'];
|
||||
const SKIP_DIRS = new Set(['node_modules', 'vendor', 'assets', 'fonts', 'dev']);
|
||||
/** Calls whose first argument is a msgid (tc: the second, keyed `context::msgid`). */
|
||||
const MSGID_CALLS = new Set(['t', 'N_', 'msg']);
|
||||
/**
|
||||
* Server messaging methods (m.toast, ctx.toast, this.tickerText …, called on an object): a Chinese string literal they
|
||||
* get is sent as text and translated by the client as a msgid (main.js translateWire).
|
||||
*/
|
||||
const SERVER_TEXT_CALLS = new Set(['toast', 'tickerText', 'ticker']);
|
||||
/** Calls whose arguments are never UI text to wrap. */
|
||||
const SKIP_CALLS = new Set(['t', 'tc', 'N_', 'msg', 'tName', 'dn', 'format', 'renderMessage', 'require', 'import']);
|
||||
/** String methods: a literal argument is data, not display text. */
|
||||
const STRING_METHODS = new Set(['includes', 'startsWith', 'endsWith', 'indexOf', 'lastIndexOf', 'replace', 'replaceAll', 'split',
|
||||
'match', 'matchAll', 'test', 'search', 'has', 'get', 'set', 'delete', 'localeCompare', 'padStart', 'padEnd', 'join', 'add']);
|
||||
/**
|
||||
* Object tables of msgids defined without N_() (their values are translated where they are shown): file → names.
|
||||
* `check` treats their string values as msgids.
|
||||
*/
|
||||
const MSGID_TABLES = [
|
||||
['shared/constants.js', ['ERR_TEXT', 'DIFFICULTY_NAMES']],
|
||||
['public/js/net.js', ['CLIENT_ERR_TEXT']],
|
||||
['public/js/main.js', ['CLOSE_REASON']],
|
||||
];
|
||||
|
||||
let acornMod = null;
|
||||
async function acorn() {
|
||||
if (acornMod) return acornMod;
|
||||
try { acornMod = await import('acorn'); } catch {
|
||||
throw new Error('tools/i18n.mjs needs the dev dependencies (npm install): acorn comes with eslint');
|
||||
}
|
||||
return acornMod;
|
||||
}
|
||||
|
||||
// ===== message ids ===================================================================================================
|
||||
|
||||
/**
|
||||
* The param name of a template-literal expression: an identifier keeps its name, `a.b.c` → `c` (`x.length` → `n`);
|
||||
* anything else is positional (`0`, `1` …, its index among the expressions).
|
||||
* @param {string} src expression source
|
||||
* @param {number} index
|
||||
*/
|
||||
export function paramName(src, index) {
|
||||
const s = String(src).trim();
|
||||
if (/^[A-Za-z_$][\w$]*$/.test(s)) return s;
|
||||
const m = s.match(/^[A-Za-z_$][\w$]*(?:\??\.[A-Za-z_$][\w$]*)+$/);
|
||||
if (m) {
|
||||
const last = s.split(/\??\./).pop();
|
||||
return last === 'length' ? 'n' : last;
|
||||
}
|
||||
return String(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Msgid and params of a template literal from its static parts and expression sources.
|
||||
* @param {string[]} quasis cooked static parts (n + 1)
|
||||
* @param {string[]} exprs expression sources (n)
|
||||
* @returns {{ msgid: string, params: { name: string, src: string }[] }}
|
||||
*/
|
||||
export function templateMsgid(quasis, exprs) {
|
||||
const names = new Map(); // src → name
|
||||
const used = new Set();
|
||||
const params = [];
|
||||
let msgid = quasis[0] ?? '';
|
||||
exprs.forEach((src, i) => {
|
||||
let name = names.get(src);
|
||||
if (!name) {
|
||||
let base = paramName(src, i);
|
||||
name = base;
|
||||
for (let k = 2; used.has(name); k++) name = `${base}${k}`;
|
||||
names.set(src, name);
|
||||
used.add(name);
|
||||
params.push({ name, src });
|
||||
}
|
||||
msgid += `{${name}}${quasis[i + 1] ?? ''}`;
|
||||
});
|
||||
return { msgid, params };
|
||||
}
|
||||
|
||||
/** JS source of a single-quoted string. */
|
||||
export function quote(s) {
|
||||
return `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')}'`;
|
||||
}
|
||||
|
||||
/** Source of a params object for t(): `{ a, b: expr, 0: expr }`. */
|
||||
function paramsSource(params) {
|
||||
if (!params.length) return '';
|
||||
return `, { ${params.map(({ name, src }) => (name === src ? name : `${name}: ${src}`)).join(', ')} }`;
|
||||
}
|
||||
|
||||
// ===== AST helpers ===================================================================================================
|
||||
|
||||
/** Generic AST walk with ancestors (enter(node, ancestors) → false skips the children). */
|
||||
function walk(node, enter, ancestors = []) {
|
||||
if (!node || typeof node.type !== 'string') return;
|
||||
if (enter(node, ancestors) === false) return;
|
||||
ancestors.push(node);
|
||||
for (const key of Object.keys(node)) {
|
||||
if (key === 'loc' || key === 'range' || key === 'start' || key === 'end') continue;
|
||||
const v = node[key];
|
||||
if (Array.isArray(v)) { for (const c of v) if (c && typeof c.type === 'string') walk(c, enter, ancestors); } else if (v && typeof v.type === 'string') walk(v, enter, ancestors);
|
||||
}
|
||||
ancestors.pop();
|
||||
}
|
||||
|
||||
const calleeName = (call) => {
|
||||
const c = call?.callee;
|
||||
if (!c) return null;
|
||||
if (c.type === 'Identifier') return c.name;
|
||||
if (c.type === 'MemberExpression' && !c.computed && c.property.type === 'Identifier') return c.property.name;
|
||||
return null;
|
||||
};
|
||||
const isConsoleCall = (call) => call?.callee?.type === 'MemberExpression' && call.callee.object?.type === 'Identifier' && call.callee.object.name === 'console';
|
||||
|
||||
/** Why a literal must not be wrapped (null = wrap it). */
|
||||
function skipReason(node, ancestors) {
|
||||
const parent = ancestors[ancestors.length - 1];
|
||||
for (let i = ancestors.length - 1; i >= 0; i--) {
|
||||
const a = ancestors[i];
|
||||
if (a.type === 'CallExpression') {
|
||||
if (isConsoleCall(a)) return 'console';
|
||||
const name = calleeName(a);
|
||||
if (SKIP_CALLS.has(name) && a.callee.type === 'Identifier') return 'in-call';
|
||||
}
|
||||
if (a.type === 'NewExpression' && a.callee.type === 'Identifier' && /Error$/.test(a.callee.name)) return 'error';
|
||||
if (a.type === 'ThrowStatement') return 'error';
|
||||
if (a.type === 'ImportDeclaration' || a.type === 'ExportAllDeclaration' || (a.type === 'ExportNamedDeclaration' && a.source)) return 'import';
|
||||
}
|
||||
if (!parent) return null;
|
||||
if (parent.type === 'Property' && parent.key === node && !parent.computed) return 'key';
|
||||
if (parent.type === 'MemberExpression' && parent.property === node) return 'key';
|
||||
if (parent.type === 'BinaryExpression' && ['===', '!==', '==', '!=', 'in', 'instanceof'].includes(parent.operator)) return 'compare';
|
||||
if (parent.type === 'SwitchCase' && parent.test === node) return 'compare';
|
||||
if (parent.type === 'CallExpression' && parent.arguments.includes(node)) {
|
||||
const name = calleeName(parent);
|
||||
if (parent.callee.type === 'MemberExpression' && STRING_METHODS.has(name)) return 'string-method';
|
||||
}
|
||||
if (parent.type === 'TaggedTemplateExpression') return 'tagged';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether a node sits in a function (render time) rather than at module level (evaluated once at load). */
|
||||
const inFunction = (ancestors) => ancestors.some((a) => a.type === 'FunctionDeclaration' || a.type === 'FunctionExpression' || a.type === 'ArrowFunctionExpression' || a.type === 'MethodDefinition');
|
||||
|
||||
/**
|
||||
* Split an html`` template into its text runs and attribute values with Chinese (htm syntax: the static parts are
|
||||
* HTML-like, `${}` are children or attribute values). A text run is the text between two tags, `${}` children included
|
||||
* when they are simple values (identifiers, member chains, literals, String(…)); a run with other children (vnodes,
|
||||
* components, conditionals) is split into its static pieces.
|
||||
* @param {any} tpl TemplateLiteral node of an html`` tag
|
||||
* @returns {{ kind: 'text'|'attr', start: number, end: number, quasis: string[], exprs: any[], attr?: string }[]}
|
||||
*/
|
||||
function htmlSegments(tpl) {
|
||||
const out = [];
|
||||
const qs = tpl.quasis;
|
||||
let state = 'TEXT';
|
||||
let quoteCh = null;
|
||||
/** @type {{ parts: ({ text: string, from: number, to: number } | { expr: any, from: number, to: number })[] } | null} */
|
||||
let run = null;
|
||||
let attr = null;
|
||||
const simple = (e) => e.type === 'Identifier' || e.type === 'MemberExpression' || e.type === 'Literal' || (e.type === 'CallExpression' && calleeName(e) === 'String');
|
||||
const textSeg = (parts) => {
|
||||
const quasis = [];
|
||||
const exprs = [];
|
||||
let cur = '';
|
||||
for (const p of parts) { if ('expr' in p) { quasis.push(cur); cur = ''; exprs.push(p.expr); } else cur += p.text; }
|
||||
quasis.push(cur);
|
||||
const lead = quasis[0].match(/^\s*/)[0];
|
||||
const trail = quasis[quasis.length - 1].match(/\s*$/)[0];
|
||||
if (quasis.length === 1 && lead.length === quasis[0].length) return null;
|
||||
quasis[0] = quasis[0].slice(lead.length);
|
||||
quasis[quasis.length - 1] = quasis[quasis.length - 1].slice(0, quasis[quasis.length - 1].length - trail.length);
|
||||
const first = parts[0];
|
||||
const last = parts[parts.length - 1];
|
||||
const start = first.from + ('expr' in first ? 0 : lead.length);
|
||||
const end = last.to - ('expr' in last ? 0 : trail.length);
|
||||
return { kind: 'text', start, end, quasis, exprs };
|
||||
};
|
||||
const flushRun = () => {
|
||||
if (!run) return;
|
||||
const parts = run.parts;
|
||||
run = null;
|
||||
if (!parts.some((p) => !('expr' in p) && HAN.test(p.text))) return;
|
||||
if (parts.every((p) => !('expr' in p) || simple(p.expr))) {
|
||||
const seg = textSeg(parts);
|
||||
if (seg) out.push(seg);
|
||||
return;
|
||||
}
|
||||
// split around the non-simple children: each piece of static text (with its simple neighbours) on its own
|
||||
let piece = [];
|
||||
const flushPiece = () => {
|
||||
if (piece.some((p) => !('expr' in p) && HAN.test(p.text))) {
|
||||
// drop simple exprs at the edges of a piece only if they are not adjacent to text? keep them: they are values
|
||||
const seg = textSeg(piece);
|
||||
if (seg) out.push(seg);
|
||||
}
|
||||
piece = [];
|
||||
};
|
||||
for (const p of parts) {
|
||||
if ('expr' in p && !simple(p.expr)) { flushPiece(); continue; }
|
||||
piece.push(p);
|
||||
}
|
||||
flushPiece();
|
||||
};
|
||||
for (let qi = 0; qi < qs.length; qi++) {
|
||||
const q = qs[qi];
|
||||
const text = q.value.raw;
|
||||
const base = q.start;
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
if (state === 'TEXT') {
|
||||
const lt = text.indexOf('<', i);
|
||||
const stop = lt < 0 ? text.length : lt;
|
||||
if (stop > i) {
|
||||
if (!run) run = { parts: [] };
|
||||
run.parts.push({ text: text.slice(i, stop), from: base + i, to: base + stop });
|
||||
}
|
||||
if (lt < 0) break;
|
||||
flushRun();
|
||||
if (text.startsWith('<!--', lt)) { state = 'COMMENT'; i = lt + 4; continue; }
|
||||
state = 'TAG';
|
||||
i = lt + 1;
|
||||
continue;
|
||||
}
|
||||
if (state === 'COMMENT') {
|
||||
const e = text.indexOf('-->', i);
|
||||
if (e < 0) break;
|
||||
state = 'TEXT';
|
||||
i = e + 3;
|
||||
continue;
|
||||
}
|
||||
if (state === 'TAG') {
|
||||
const c = text[i];
|
||||
if (c === '>') { state = 'TEXT'; i++; continue; }
|
||||
if (c === '"' || c === "'") {
|
||||
const nm = text.slice(0, i).match(/([A-Za-z_:][-\w:.]*)\s*=\s*$/);
|
||||
attr = { name: nm ? nm[1] : null, start: base + i, parts: [] };
|
||||
quoteCh = c;
|
||||
state = 'ATTRV';
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// ATTRV
|
||||
const e = text.indexOf(quoteCh, i);
|
||||
if (e < 0) { attr.parts.push({ text: text.slice(i) }); break; }
|
||||
attr.parts.push({ text: text.slice(i, e) });
|
||||
const quasis = [];
|
||||
const exprs = [];
|
||||
let cur = '';
|
||||
for (const p of attr.parts) { if ('expr' in p) { quasis.push(cur); cur = ''; exprs.push(p.expr); } else cur += p.text; }
|
||||
quasis.push(cur);
|
||||
if (attr.name && quasis.some((x) => HAN.test(x))) out.push({ kind: 'attr', attr: attr.name, start: attr.start, end: base + e + 1, quasis, exprs });
|
||||
attr = null;
|
||||
state = 'TAG';
|
||||
i = e + 1;
|
||||
}
|
||||
if (qi < tpl.expressions.length) {
|
||||
const ex = tpl.expressions[qi];
|
||||
// the `${…}` spans from the end of this static part to the start of the next one
|
||||
if (state === 'TEXT') {
|
||||
if (!run) run = { parts: [] };
|
||||
run.parts.push({ expr: ex, from: q.end, to: qs[qi + 1].start });
|
||||
} else if (state === 'ATTRV') {
|
||||
attr.parts.push({ expr: ex });
|
||||
}
|
||||
}
|
||||
}
|
||||
flushRun();
|
||||
return out;
|
||||
}
|
||||
|
||||
// ===== scanning a file ===============================================================================================
|
||||
|
||||
/**
|
||||
* Scan a source file.
|
||||
* @param {string} src
|
||||
* @param {string} file label
|
||||
* @returns {Promise<{ msgids: { msgid: string, line: number }[], 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>') {
|
||||
const { parse } = await acorn();
|
||||
let ast;
|
||||
try {
|
||||
ast = parse(src, { ecmaVersion: 'latest', sourceType: 'module', locations: true, allowHashBang: true });
|
||||
} catch (e) {
|
||||
throw new Error(`${file}: cannot parse (${e.message})`, { cause: e });
|
||||
}
|
||||
const msgids = [];
|
||||
const literals = [];
|
||||
const lineOf = (pos) => src.slice(0, pos).split('\n').length;
|
||||
// a line marked `i18n-ignore` (in a comment) holds no UI text (font samples, data keys …)
|
||||
const ignored = new Set(src.split('\n').map((l, i) => (/i18n-ignore/.test(l) ? i + 1 : 0)).filter(Boolean));
|
||||
walk(ast, (node, ancestors) => {
|
||||
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === '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' });
|
||||
}
|
||||
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && MSGID_CALLS.has(node.callee.name)) {
|
||||
const a = node.arguments[0];
|
||||
if (a && a.type === 'Literal' && typeof a.value === 'string') msgids.push({ msgid: a.value, line: lineOf(a.start), via: node.callee.name });
|
||||
else if (a && a.type === 'TemplateLiteral' && !a.expressions.length) msgids.push({ msgid: a.quasis[0].value.cooked, line: lineOf(a.start), via: node.callee.name });
|
||||
}
|
||||
if (node.type === 'TaggedTemplateExpression' && node.tag.type === 'Identifier' && node.tag.name === 'html') {
|
||||
for (const seg of htmlSegments(node.quasi)) {
|
||||
const { msgid, params } = templateMsgid(seg.quasis, seg.exprs.map((e) => src.slice(e.start, e.end)));
|
||||
literals.push({ kind: seg.kind, attr: seg.attr, start: seg.start, end: seg.end, line: lineOf(seg.start), msgid, params, exprs: seg.exprs.map((e) => [e.start, e.end]), module: !inFunction(ancestors), reason: null });
|
||||
}
|
||||
// expressions inside are walked normally (nested html``, strings in ${…})
|
||||
return true;
|
||||
}
|
||||
if (node.type === 'Literal' && typeof node.value === 'string' && HAN.test(node.value)) {
|
||||
const parent = ancestors[ancestors.length - 1];
|
||||
if (parent && parent.type === 'TemplateLiteral') return true;
|
||||
// an argument of m.toast / ctx.toast / tickerText, directly or as a branch of `a ? '…' : '…'` / `x || '…'`
|
||||
let arg = node;
|
||||
let k = ancestors.length - 1;
|
||||
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' });
|
||||
return true;
|
||||
}
|
||||
const line = lineOf(node.start);
|
||||
const reason = ignored.has(line) ? 'ignored' : skipReason(node, ancestors);
|
||||
literals.push({ kind: 'str', start: node.start, end: node.end, line, msgid: node.value, params: [], module: !inFunction(ancestors), reason });
|
||||
return true;
|
||||
}
|
||||
if (node.type === 'TemplateLiteral') {
|
||||
const parent = ancestors[ancestors.length - 1];
|
||||
if (parent && parent.type === 'TaggedTemplateExpression') return true;
|
||||
const cooked = node.quasis.map((q) => q.value.cooked ?? q.value.raw);
|
||||
if (!cooked.some((s) => HAN.test(s))) return true;
|
||||
const reason = ignored.has(lineOf(node.start)) ? 'ignored' : skipReason(node, ancestors);
|
||||
const { msgid, params } = templateMsgid(cooked, node.expressions.map((e) => src.slice(e.start, e.end)));
|
||||
literals.push({ kind: 'tpl', start: node.start, end: node.end, line: lineOf(node.start), msgid, params, exprs: node.expressions.map((e) => [e.start, e.end]), module: !inFunction(ancestors), reason });
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return { msgids, literals };
|
||||
}
|
||||
|
||||
/** String values of `const NAME = { … }` / `Object.freeze({ … })` object tables (MSGID_TABLES). */
|
||||
async function tableMsgids(src, names) {
|
||||
const { parse } = await acorn();
|
||||
const ast = parse(src, { ecmaVersion: 'latest', sourceType: 'module' });
|
||||
const out = [];
|
||||
walk(ast, (node) => {
|
||||
if (node.type !== 'VariableDeclarator' || node.id.type !== 'Identifier' || !names.includes(node.id.name)) return true;
|
||||
walk(node.init, (n) => {
|
||||
if (n.type === 'Property' && n.value.type === 'Literal' && typeof n.value.value === 'string' && HAN.test(n.value.value)) out.push(n.value.value);
|
||||
return true;
|
||||
});
|
||||
return false;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* JS files under the paths. server/sim is skipped unless `sim` (its strings are game logic and data; only the messages
|
||||
* it sends through ctx.toast matter, and `check` reads those).
|
||||
*/
|
||||
function listFiles(paths, { sim = false } = {}) {
|
||||
const out = [];
|
||||
const visit = (abs) => {
|
||||
const st = statSync(abs);
|
||||
if (st.isDirectory()) {
|
||||
for (const name of readdirSync(abs).sort()) {
|
||||
if (SKIP_DIRS.has(name) || name.startsWith('.')) continue;
|
||||
const rel = path.relative(ROOT, path.join(abs, name)).split(path.sep).join('/');
|
||||
if (rel === 'server/sim' && !sim) continue;
|
||||
visit(path.join(abs, name));
|
||||
}
|
||||
} else if (/\.(m?js)$/.test(abs)) out.push(abs);
|
||||
};
|
||||
for (const p of paths) visit(path.resolve(ROOT, p));
|
||||
return out;
|
||||
}
|
||||
|
||||
const rel = (abs) => path.relative(ROOT, abs).split(path.sep).join('/');
|
||||
|
||||
// ===== codemod =======================================================================================================
|
||||
|
||||
/** Relative import path from a file to shared/i18n.js. */
|
||||
function importPath(absFile) {
|
||||
let r = path.relative(path.dirname(absFile), path.join(ROOT, 'shared', 'i18n.js')).split(path.sep).join('/');
|
||||
if (!r.startsWith('.')) r = `./${r}`;
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a source: wrap its Chinese literals. Returns the new source and what was done.
|
||||
* @param {string} src
|
||||
* @param {string} absFile
|
||||
*/
|
||||
export async function codemodSource(src, absFile) {
|
||||
const { literals } = await scanSource(src, rel(absFile));
|
||||
const manual = [];
|
||||
const skipped = [];
|
||||
let needT = false;
|
||||
let needN = false;
|
||||
/** literal → { start, end, make(paramSrc) } (the text is built once nested edits are known) */
|
||||
const plans = [];
|
||||
for (const l of literals) {
|
||||
if (l.reason) { skipped.push(l); continue; }
|
||||
if (l.kind === 'text' || l.kind === 'attr') {
|
||||
plans.push({ l, start: l.start, end: l.end, make: (p) => `\${t(${quote(l.msgid)}${p})}` });
|
||||
needT = true;
|
||||
if (l.module) manual.push(l);
|
||||
continue;
|
||||
}
|
||||
if (l.module) {
|
||||
if (l.kind === 'str') { plans.push({ l, start: l.start, end: l.end, make: () => `N_(${src.slice(l.start, l.end)})` }); needN = true; }
|
||||
manual.push(l);
|
||||
continue;
|
||||
}
|
||||
plans.push({ l, start: l.start, end: l.end, make: (p) => `t(${quote(l.msgid)}${p})` });
|
||||
needT = true;
|
||||
}
|
||||
// a plan inside another one (a string in a template's ${…}) is applied to that template's param source instead
|
||||
const inner = (outer, x) => x !== outer && x.start >= outer.start && x.end <= outer.end;
|
||||
const top = plans.filter((p) => !plans.some((o) => inner(o, p)));
|
||||
const rewrite = (from, to, nested) => {
|
||||
let text = src.slice(from, to);
|
||||
for (const n of nested.filter((x) => x.start >= from && x.end <= to).sort((a, b) => b.start - a.start)) {
|
||||
text = text.slice(0, n.start - from) + build(n) + text.slice(n.end - from);
|
||||
}
|
||||
return text;
|
||||
};
|
||||
const build = (plan) => {
|
||||
const nested = plans.filter((x) => inner(plan, x) && !plans.some((o) => o !== plan && inner(plan, o) && inner(o, x)));
|
||||
const { params = [], exprs = [] } = plan.l;
|
||||
// params come from the expressions in order; the same expression source maps to one param
|
||||
const srcOf = new Map();
|
||||
exprs.forEach(([a, b]) => { const orig = src.slice(a, b); if (!srcOf.has(orig)) srcOf.set(orig, rewrite(a, b, nested)); });
|
||||
const ps = params.map(({ name, src: orig }) => ({ name, src: srcOf.get(orig) ?? orig }));
|
||||
return plan.make(paramsSource(ps));
|
||||
};
|
||||
const edits = top.map((p) => ({ start: p.start, end: p.end, text: build(p) })).sort((a, b) => b.start - a.start);
|
||||
let out = src;
|
||||
for (const e of edits) out = out.slice(0, e.start) + e.text + out.slice(e.end);
|
||||
const names = [needT && 't', needN && 'N_'].filter(Boolean);
|
||||
if (names.length) {
|
||||
const imp = importPath(absFile);
|
||||
const re = new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${imp.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}['"];?`);
|
||||
const m = out.match(re);
|
||||
if (m) {
|
||||
const have = m[1].split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const want = [...new Set([...have, ...names])];
|
||||
out = out.replace(re, `import { ${want.join(', ')} } from '${imp}';`);
|
||||
} else {
|
||||
// after the last import statement
|
||||
const imports = [...out.matchAll(/^import[^;]*;[^\n]*\n/gm)];
|
||||
const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length : 0;
|
||||
out = `${out.slice(0, at)}import { ${names.join(', ')} } from '${imp}';\n${out.slice(at)}`;
|
||||
}
|
||||
}
|
||||
return { src: out, edits: plans.length, manual, skipped };
|
||||
}
|
||||
|
||||
// ===== commands ======================================================================================================
|
||||
|
||||
function parseFlags(argv) {
|
||||
const flags = { _: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
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 flags[k] = true;
|
||||
} else flags._.push(a);
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
const catalogPath = (lang) => 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) {
|
||||
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`);
|
||||
}
|
||||
|
||||
async function usedMsgids(files) {
|
||||
const used = new Map(); // msgid → first "file:line"
|
||||
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
|
||||
// the texts it sends count there
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
async function cmdExtract(flags) {
|
||||
const files = listFiles(flags._.length ? flags._ : DEFAULT_ROOTS);
|
||||
const rows = [];
|
||||
let tCount = 0, litCount = 0;
|
||||
for (const f of files) {
|
||||
const { msgids, literals } = await scanSource(readFileSync(f, 'utf8'), rel(f));
|
||||
const open = literals.filter((l) => !l.reason || l.reason === 'compare' || l.reason === 'string-method' || l.reason === 'key');
|
||||
const shown = literals.filter((l) => !l.reason);
|
||||
tCount += msgids.length;
|
||||
litCount += shown.length;
|
||||
if (msgids.length || shown.length) rows.push({ file: rel(f), t: msgids.length, untranslated: shown.length, literals: shown, other: open.length - shown.length });
|
||||
}
|
||||
if (flags.json) { console.log(JSON.stringify(rows.map(({ literals, ...r }) => ({ ...r, literals: literals.map((l) => ({ line: l.line, kind: l.kind, msgid: l.msgid, module: l.module })) })), null, 1)); return; }
|
||||
for (const r of rows) {
|
||||
console.log(`${r.file.padEnd(48)} t() ${String(r.t).padStart(4)} untranslated ${String(r.untranslated).padStart(4)}`);
|
||||
if (flags.list) for (const l of r.literals) console.log(` ${String(l.line).padStart(5)} ${l.kind.padEnd(4)}${l.module ? ' (module)' : ''} ${l.msgid.replace(/\n/g, '⏎').slice(0, 90)}`);
|
||||
}
|
||||
const cat = readCatalog(flags.lang || 'en');
|
||||
const ready = rows.reduce((n, r) => n + r.literals.filter((l) => typeof cat[l.msgid] === 'string' && cat[l.msgid]).length, 0);
|
||||
console.log(`total: ${tCount} msgids in t() / tc() / N_() / msg(); ${litCount} Chinese literals not wrapped, in ${rows.filter((r) => r.untranslated).length} files`
|
||||
+ ` (${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, '⏎')}`);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
async function cmdCodemod(flags) {
|
||||
if (!flags._.length) throw new Error('codemod needs file paths');
|
||||
for (const p of flags._) {
|
||||
const abs = path.resolve(ROOT, p);
|
||||
const src = readFileSync(abs, 'utf8');
|
||||
const res = await codemodSource(src, abs);
|
||||
console.log(`${rel(abs)}: ${res.edits} literals wrapped, ${res.skipped.length} left (compare / key / string method / console / error)`);
|
||||
for (const l of res.manual) console.log(` manual line ${l.line}: module-level ${l.kind} — call t() where it is shown: ${l.msgid.slice(0, 70)}`);
|
||||
if (flags.write && res.src !== src) writeFileSync(abs, res.src);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdSeed(flags) {
|
||||
if (!flags.from) throw new Error('seed needs --from <file.json>');
|
||||
const lang = flags.lang || 'en';
|
||||
const from = JSON.parse(readFileSync(path.resolve(ROOT, flags.from), 'utf8'));
|
||||
const files = listFiles(flags._.length ? flags._ : DEFAULT_ROOTS);
|
||||
const want = new Map(await usedMsgids(files));
|
||||
if (flags.all) {
|
||||
for (const f of files) {
|
||||
const { literals } = await scanSource(readFileSync(f, 'utf8'), rel(f));
|
||||
for (const l of literals) if ((!l.reason || l.reason === 'tagged') && !want.has(l.msgid)) want.set(l.msgid, `${rel(f)}:${l.line}`);
|
||||
}
|
||||
}
|
||||
const cat = readCatalog(lang);
|
||||
let added = 0;
|
||||
for (const [m] of want) {
|
||||
if (typeof cat[m] === 'string' && cat[m]) continue;
|
||||
const tr = from[m];
|
||||
if (typeof tr === 'string' && tr && !HAN.test(tr)) { cat[m] = tr; added++; }
|
||||
}
|
||||
console.log(`${want.size} msgids considered, ${added} added to public/i18n/${lang}.json${flags.write ? '' : ' (dry run: --write saves)'}`);
|
||||
if (flags.write) writeCatalog(lang, cat);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [cmd, ...rest] = process.argv.slice(2);
|
||||
const flags = parseFlags(rest);
|
||||
const cmds = { extract: cmdExtract, check: cmdCheck, codemod: cmdCodemod, seed: cmdSeed };
|
||||
if (!cmds[cmd]) {
|
||||
console.log('usage: node tools/i18n.mjs extract|check|codemod|seed … (see the header of tools/i18n.mjs)');
|
||||
process.exitCode = cmd ? 2 : 0;
|
||||
return;
|
||||
}
|
||||
await cmds[cmd](flags);
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch((e) => { console.error(`i18n: ${e.message}`); process.exitCode = 2; });
|
||||
}
|
||||
|
||||
export { CJK, MSGID_TABLES };
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"_about": "Fallback English for data/*.json texts the official EN tables lack (tools/build-i18n.mjs step 5). Taken from GitHub PR #70 (branch en-translation) by @YuriRestia, reused with credit (the owner's decision of 2026-10-05; NOTICE.md). Entries PR #70 took from ak-spa-database.pages.dev (no licence) are not included. Regenerate the list of untranslated texts with node tools/build-i18n.mjs (report: .cache/build-i18n-report.json `missing`).",
|
||||
"“新车和新生活”": "\"New Car, New Life\"",
|
||||
"“独属自己的一隅”": "\"A Corner of One's Own\"",
|
||||
"“老朋友”": "\"Old Friend\"",
|
||||
"【炎】6名成员激活时召唤的友方单位;开战时攻击力/生命值增加【炎】干员攻击力/生命值总和的30%(见 bonds.json yanShip)": "A friendly unit summoned when 6 members of [Yan] are active; at battle start, ATK/HP increased by 30% of the total ATK/HP of [Yan] Operators (see bonds.json yanShip)",
|
||||
"伤心": "Sad",
|
||||
"再见!": "Bye!",
|
||||
"剑舞": "Sword Dance",
|
||||
"博士士 1": "Doctor-tor 1",
|
||||
"博士士 2": "Doctor-tor 2",
|
||||
"博士士 3": "Doctor-tor 3",
|
||||
"博士士 4": "Doctor-tor 4",
|
||||
"博士士 5": "Doctor-tor 5",
|
||||
"博士士 6": "Doctor-tor 6",
|
||||
"合作愉快": "Nice teamwork",
|
||||
"合作愉快!": "Nice teamwork!",
|
||||
"在<@ba.kw>生息演算</>中,首次开启技能后,生命上限、攻击力、防御力提升至150%": "In <@ba.kw>Reclamation Algorithm</>, after the skill is activated for the first time, Max HP, ATK, and DEF increase to 150%",
|
||||
"在<@ba.kw>集成战略</>中,自身和<替身>造成的物理和法术伤害提升40%,受到的物理和法术伤害减少40%": "In <@ba.kw>Integrated Strategies</>, Physical and Arts damage dealt by self and the <Substitute> +40%, Physical and Arts damage taken -40%",
|
||||
"在生息演算中,首次开启技能后,生命上限、攻击力、防御力提升至150%": "In Reclamation Algorithm, after the skill is activated for the first time, Max HP, ATK, and DEF increase to 150%",
|
||||
"在集成战略中,自身和<替身>造成的物理和法术伤害提升40%,受到的物理和法术伤害减少40%": "In Integrated Strategies, Physical and Arts damage dealt by self and the <Substitute> +40%, Physical and Arts damage taken -40%",
|
||||
"害怕": "Scared",
|
||||
"对不起": "Sorry",
|
||||
"对不起!": "Sorry!",
|
||||
"对敌方领袖造成伤害最高(仅胜利时)": "Highest damage dealt to the Enemy Leader (victory only)",
|
||||
"开心": "Happy",
|
||||
"归溟幽灵鲨特限证章": "Skadi the Corrupting Heart Limited Badge",
|
||||
"很快就好!": "Almost done!",
|
||||
"快死了": "Dying",
|
||||
"思考": "Thinking",
|
||||
"战场#04 活性源石": "Battlefield #04 Active Originium",
|
||||
"战场#05(上半) 涨潮/特制水上平台": "Battlefield #05 (First Half) High Tide/Special Water Platform",
|
||||
"战场#07(下半) 排气格栅": "Battlefield #07 (Second Half) Exhaust Grille",
|
||||
"攻击力+8%,攻击被<$ba.tremble>战栗</>的目标时,无视目标33%的防御力,地面敌人首次进入自身攻击范围时,使其<$ba.tremble>战栗</>6秒": "ATK +8%; when attacking <$ba.tremble>Trembling</> targets, ignores 33% of the target's DEF; when a ground enemy first enters Attack Range, inflicts <$ba.tremble>Tremble</> on it for 6 seconds",
|
||||
"攻击力+8%,攻击被战栗的目标时,无视目标33%的防御力,地面敌人首次进入自身攻击范围时,使其战栗6秒": "ATK +8%; when attacking Trembling targets, ignores 33% of the target's DEF; when a ground enemy first enters Attack Range, inflicts Tremble on it for 6 seconds",
|
||||
"攻击命中生命值在80%以上的敌人时,3秒内异客对其造成的伤害提升20%;在<@ba.kw>生息演算</>中,每10秒选择场上5个目标进行雷击(密集的目标更易被选中),对范围内所有单位造成异客攻击力500%的法术伤害,并获得该范围内的视野12秒,使命中的友方单位获得2点技力": "When hitting an enemy with more than 80% HP, Passenger deals 20% more damage to it for 3 seconds; in <@ba.kw>Reclamation Algorithm</>, every 10 seconds strikes 5 targets on the field with lightning (dense targets are more likely to be chosen), dealing Arts damage equal to 500% of Passenger's ATK to all units in the area, granting vision of that area for 12 seconds, and giving hit friendly units 2 SP",
|
||||
"攻击命中生命值在80%以上的敌人时,3秒内异客对其造成的伤害提升20%;在生息演算中,每10秒选择场上5个目标进行雷击(密集的目标更易被选中),对范围内所有单位造成异客攻击力500%的法术伤害,并获得该范围内的视野12秒,使命中的友方单位获得2点技力": "When hitting an enemy with more than 80% HP, Passenger deals 20% more damage to it for 3 seconds; in Reclamation Algorithm, every 10 seconds strikes 5 targets on the field with lightning (dense targets are more likely to be chosen), dealing Arts damage equal to 500% of Passenger's ATK to all units in the area, granting vision of that area for 12 seconds, and giving hit friendly units 2 SP",
|
||||
"攻击时有25%的几率使目标<$ba.root>束缚</>1.5秒,首次命中敌人时提升至100%且<$ba.root>束缚</>时间提升至3秒": "Attacks have a 25% chance to <$ba.root>Bind</> the target for 1.5 seconds; the first hit on each enemy has a 100% chance and the <$ba.root>Bind</> duration is increased to 3 seconds",
|
||||
"攻击时有25%的几率使目标束缚1.5秒,首次命中敌人时提升至100%且束缚时间提升至3秒": "Attacks have a 25% chance to Bind the target for 1.5 seconds; the first hit on each enemy has a 100% chance and the Bind duration is increased to 3 seconds",
|
||||
"攻击范围内存在已受伤的友方干员时,自身造成的伤害提升至110%": "When there is an injured allied Operator within Attack Range, damage dealt increases to 110%",
|
||||
"攻击范围内存在已受伤的友方干员时,自身造成的伤害提升至<@ba.kw>110%</>": "When there is an injured allied Operator within Attack Range, damage dealt increases to <@ba.kw>110%</>",
|
||||
"敬礼!": "Salute!",
|
||||
"新合同": "New Contract",
|
||||
"晋升精锐次数最多": "Most Elite promotions",
|
||||
"欢呼!": "Cheers!",
|
||||
"欲雪时": "When Snow Is Coming",
|
||||
"沟通小助手": "Little Communication Helper",
|
||||
"没问题!": "No problem!",
|
||||
"消耗资金最多": "Most Funds spent",
|
||||
"激活的盟约层数总和最高": "Highest total activated Alliance Stacks",
|
||||
"甄选干员": "Selected Operator",
|
||||
"百炼嘉维尔特限证章": "Gavial the Invincible Limited Badge",
|
||||
"目标生命值损失最少": "Least LP lost",
|
||||
"策略【不稳定要素】(昆图斯)": "Strategy [Unstable Element] (Quintus)",
|
||||
"米米子 1": "Mimiko 1",
|
||||
"米米子 2": "Mimiko 2",
|
||||
"米米子 3": "Mimiko 3",
|
||||
"米米子 4": "Mimiko 4",
|
||||
"米米子 5": "Mimiko 5",
|
||||
"米米子 6": "Mimiko 6",
|
||||
"维维美 1": "Wiwime 1",
|
||||
"维维美 2": "Wiwime 2",
|
||||
"维维美 3": "Wiwime 3",
|
||||
"维维美 4": "Wiwime 4",
|
||||
"维维美 5": "Wiwime 5",
|
||||
"维维美 6": "Wiwime 6",
|
||||
"表情套组:博士士": "Emote Set: Doctor-tor",
|
||||
"表情套组:米米子": "Emote Set: Mimiko",
|
||||
"表情套组:维维美": "Emote Set: Wiwime",
|
||||
"装备配发数量最多": "Most Equipment equipped",
|
||||
"谢谢": "Thanks",
|
||||
"谢谢!": "Thanks!",
|
||||
"造成伤害时有30%概率对敌人施加灼痕效果:攻击力-20%、38%的<$ba.magicfragile>【法术脆弱】</>,不可叠加持续8秒": "When dealing damage, 30% chance to inflict Scorch on the enemy: ATK -20% and 38% <$ba.magicfragile>[Arts Fragility]</>, non-stackable, lasts 8 seconds",
|
||||
"造成伤害时有30%概率对敌人施加灼痕效果:攻击力-20%、38%的【法术脆弱】,不可叠加持续8秒": "When dealing damage, 30% chance to inflict Scorch on the enemy: ATK -20% and 38% [Arts Fragility], non-stackable, lasts 8 seconds",
|
||||
"酷!": "Cool!"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"_about": "Fallback English for the remake's own data texts (names and notes tools/build-data.mjs writes; no official counterpart). Official EN terms where they exist: 'Active Originium', 'High Tide', 'Mutated Cells', 'Rockrock', 'Stronghold Protocol: Alliance' (the EN activity name). '排气格栅' follows PR #70's wording (no official EN occurrence found).",
|
||||
"卫戍协议:盟约": "Stronghold Protocol: Alliance",
|
||||
"活性源石": "Active Originium",
|
||||
"涨潮": "High Tide",
|
||||
"排气格栅": "Exhaust Grille",
|
||||
"联防阵地(1名玩家)": "Unite Field (1 player)",
|
||||
"联防阵地(2名玩家)": "Unite Field (2 players)",
|
||||
"维多利亚盟约每25层 / 洛洛的定制品": "Victoria Alliance (every 25 stacks) / Rockrock's custom items",
|
||||
"生效时原干员销毁,突变细胞与其他装备退回整备区,可再次配发;随后获得一名高一阶的随机初始干员(最高6阶),进入整备区,需要重新部署": "When it takes effect, the Operator is destroyed and the Mutated Cells and other equipment return to the Bench, ready to be equipped again; then a random starting Operator one Tier higher (up to Tier 6) joins the Bench and has to be deployed again"
|
||||
}
|
||||
Reference in new issue
Block a user