848 lines
43 KiB
JavaScript
848 lines
43 KiB
JavaScript
#!/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() / 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 [<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
|
||
// 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, 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]/;
|
||
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', 'tParts', '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', 'tParts', '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';
|
||
/** A logger call (`log.warn(…)`, `this.m.log?.warn?.(…)`): developer text, like console.*. */
|
||
const isLogCall = (call) => {
|
||
const c = call?.callee;
|
||
if (!c || c.type !== 'MemberExpression') return false;
|
||
const o = c.object;
|
||
const LOG = /^(log|logger)$/;
|
||
return (o.type === 'Identifier' && LOG.test(o.name)) || (o.type === 'MemberExpression' && !o.computed && o.property.type === 'Identifier' && LOG.test(o.property.name));
|
||
};
|
||
/** The developer detail of an error result (`{ error: 'BAD_TARGET', detail: '…' }`, `ev.detail = '…'`): never shown to players. */
|
||
const isDetail = (node, parent) => (parent.type === 'Property' && parent.value === node && !parent.computed && (parent.key.name ?? parent.key.value) === 'detail')
|
||
|| (parent.type === 'AssignmentExpression' && parent.right === node && parent.left.type === 'MemberExpression' && !parent.left.computed && parent.left.property.name === 'detail');
|
||
|
||
/** Why a literal must not be wrapped (null = wrap it). `aliases`: local name → shared/i18n.js export (`t as tr`). */
|
||
function skipReason(node, ancestors, aliases = new Map()) {
|
||
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) || isLogCall(a)) return 'console';
|
||
const name = calleeName(a);
|
||
if (a.callee.type === 'Identifier' && SKIP_CALLS.has(aliases.get(name) ?? name)) 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;
|
||
// (inside a detail too: `detail: \`… ${x ? '甲' : '乙'}\``)
|
||
if (isDetail(node, parent) || ancestors.some((a, i) => i > 0 && isDetail(a, ancestors[i - 1]))) return 'detail';
|
||
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 ===============================================================================================
|
||
|
||
/**
|
||
* 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, 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>') {
|
||
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;
|
||
// shared/i18n.js functions imported under another name (`import { t as tr }` where `t` is a local variable)
|
||
const aliases = new Map();
|
||
for (const n of ast.body) {
|
||
if (n.type !== 'ImportDeclaration' || !/shared\/i18n\.js$/.test(String(n.source.value))) continue;
|
||
for (const sp of n.specifiers) if (sp.type === 'ImportSpecifier' && sp.imported.name !== sp.local.name) aliases.set(sp.local.name, sp.imported.name);
|
||
}
|
||
const fnName = (callee) => (callee.type === 'Identifier' ? aliases.get(callee.name) ?? callee.name : null);
|
||
// 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));
|
||
// a file marked `i18n-ignore-file` (developer reports, bilingual error pages) holds no UI text to translate
|
||
const fileIgnored = /\/\/[^\n]*i18n-ignore-file/.test(src);
|
||
walk(ast, (node, ancestors) => {
|
||
if (node.type === 'CallExpression' && fnName(node.callee) === '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);
|
||
// 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)) {
|
||
const { msgid, params } = templateMsgid(seg.quasis, seg.exprs.map((e) => src.slice(e.start, e.end)));
|
||
const line = lineOf(seg.start);
|
||
literals.push({ kind: seg.kind, attr: seg.attr, start: seg.start, end: seg.end, line, msgid, params, exprs: seg.exprs.map((e) => [e.start, e.end]), module: !inFunction(ancestors), reason: fileIgnored || ignored.has(line) ? 'ignored' : 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', params: [] });
|
||
return true;
|
||
}
|
||
const line = lineOf(node.start);
|
||
const reason = fileIgnored || ignored.has(line) ? 'ignored' : skipReason(node, ancestors, aliases);
|
||
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 = fileIgnored || ignored.has(lineOf(node.start)) ? 'ignored' : skipReason(node, ancestors, aliases);
|
||
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', 'fill', 'root'].includes(k)) flags[k] = argv[++i];
|
||
else flags[k] = true;
|
||
} else flags._.push(a);
|
||
}
|
||
return flags;
|
||
}
|
||
|
||
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, root = ROOT) {
|
||
const meta = Object.entries(cat).filter(([k]) => k.startsWith('_'));
|
||
const rest = Object.entries(cat).filter(([k]) => !k.startsWith('_'));
|
||
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();
|
||
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
|
||
// 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;
|
||
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)) note(m, file, null);
|
||
}
|
||
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() / tParts() / 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)`);
|
||
}
|
||
|
||
// ===== 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')) });
|
||
}
|
||
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) {
|
||
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 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|template|codemod|seed|index … (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 };
|