#!/usr/bin/env node // tools/i18n.mjs — UI string tooling for the gettext-style i18n (shared/i18n.js, public/i18n/.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 [… | --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 [--fill ] // write a pack skeleton public/i18n/.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 : the packs of another checkout (the msgids are always this checkout's code). // node tools/i18n.mjs codemod [--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 [--lang en] [--all] [--write] // fill public/i18n/.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('', 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 = '') { 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 | 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} json the pack's UI strings * @param {Map | null }>} used * @param {Set} 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// 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 | null, msgids: string[], fill?: Record | null }} opts * @returns {{ json: Record, 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} */ 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 '); 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 };