public/i18n/en.json: Chinese msgid -> English, seeded from PR #70 (@YuriRestia, credited) for every Chinese literal of the current code (≈830 entries; 966 of the 1 036 literals left for phase 2 already translated), completed by hand in the official EN terms. public/js/ui/lang.js: boot language (?lang=en|zh, then localStorage sp.pref.lang, else Chinese; the browser language is not consulted), switchLang, useLang, LangToggle; main.js renders after initLang and re-renders in place on a switch, and translates server texts (m.toast / m.ticker frames, room.closed reasons, error codes via ui/toasts.js describeError). Converted: title screen (English title, wordmark hidden), lobby, room, settings (语言 row, On/Off, quality, hotkeys), the shell (crash screen, toasts) and DifficultyTag. tools/i18n.mjs: extract (msgids + untranslated literals per file), check (msgids missing from en.json), codemod (html`` text / attributes / strings / template literals -> t(), nested strings, N_ for module level, i18n-ignore lines), seed (fill en.json from a msgid map).
633 lines
30 KiB
JavaScript
633 lines
30 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() / 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 };
|