#!/usr/bin/env node /** * F-07: mechanically extract I18N.zh / I18N.en from app.js Object.assign * blocks (app.js:250-2668) into TypeScript modules. * * The dictionaries are executed by the JS engine (new Function), not copied * by hand and not parsed with a regex. 2419 lines of Object.assign would * drift under transcription. * * node frontend/src/i18n/extract-i18n.mjs # write zh.ts / en.ts * node frontend/src/i18n/extract-i18n.mjs --check # fail if generated drifted * node frontend/src/i18n/extract-i18n.mjs --self-test */ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(HERE, "../../.."); const APP_JS = path.join(ROOT, "openai4s/server/webui/app.js"); const ZH_TS = path.join(HERE, "zh.ts"); const EN_TS = path.join(HERE, "en.ts"); const ASSIGN_ZH = "Object.assign(I18N.zh,"; const ASSIGN_EN = "Object.assign(I18N.en,"; const GENERATED_HEADER = `/** * Generated by frontend/src/i18n/extract-i18n.mjs from openai4s/server/webui/app.js. * Do not edit by hand. Re-run: node frontend/src/i18n/extract-i18n.mjs */ `; /** * End index (exclusive) of `Object.assign(I18N.xx, { ... });` starting at * assignPos. Walks braces while honoring double-quoted strings and escapes * so a `}` inside a value cannot close the block. */ export function endOfAssign(src, assignPos) { const brace = src.indexOf("{", assignPos); if (brace < 0) { throw new Error(`no object literal after Object.assign at ${assignPos}`); } let depth = 0; let inStr = false; let esc = false; for (let i = brace; i < src.length; i++) { const c = src[i]; if (inStr) { if (esc) { esc = false; continue; } if (c === "\\") { esc = true; continue; } if (c === '"') inStr = false; continue; } if (c === '"') { inStr = true; continue; } if (c === "{") depth += 1; else if (c === "}") { depth -= 1; if (depth === 0) { const rest = src.slice(i + 1).match(/^\s*\)\s*;/); if (!rest) { throw new Error(`expected ); after closing } at index ${i}`); } return i + 1 + rest[0].length; } } } throw new Error("unterminated Object.assign block"); } /** * Execute the two Object.assign blocks from app.js and return { zh, en }. * Using the JS engine is the ground truth: every escape and comma is * interpreted the same way the browser interprets the original file. */ export function extractAppJsDicts(source) { const text = source ?? fs.readFileSync(APP_JS, "utf8"); const zhPos = text.indexOf(ASSIGN_ZH); const enPos = text.indexOf(ASSIGN_EN); if (zhPos < 0 || enPos < 0) { throw new Error("app.js is missing Object.assign(I18N.zh/en, …) blocks"); } if (enPos <= zhPos) { throw new Error("I18N.en Object.assign must follow I18N.zh"); } const zhEnd = endOfAssign(text, zhPos); const enEnd = endOfAssign(text, enPos); if (zhEnd > enPos) { throw new Error("I18N.zh Object.assign overlaps I18N.en"); } const chunk = text.slice(zhPos, enEnd); const I18N = { zh: {}, en: {} }; new Function("I18N", chunk)(I18N); return I18N; } export function emitDictTs(dict) { const body = Object.entries(dict) .map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)},`) .join("\n"); return `${GENERATED_HEADER}const dict: Record = {\n${body}\n};\n\nexport default dict;\n`; } export function keyDiff(a, b) { const msgs = []; const keys = new Set([...Object.keys(a), ...Object.keys(b)]); for (const k of keys) { if (!Object.prototype.hasOwnProperty.call(a, k)) { msgs.push(`missing in left: ${k}`); } else if (!Object.prototype.hasOwnProperty.call(b, k)) { msgs.push(`missing in right: ${k}`); } else if (a[k] !== b[k]) { msgs.push( `value mismatch ${k}: ${JSON.stringify(a[k])} vs ${JSON.stringify(b[k])}`, ); } } return msgs; } export function writeDicts(dicts) { fs.writeFileSync(ZH_TS, emitDictTs(dicts.zh)); fs.writeFileSync(EN_TS, emitDictTs(dicts.en)); } export function checkDicts(dicts) { const errors = []; for (const [lang, file] of [ ["zh", ZH_TS], ["en", EN_TS], ]) { if (!fs.existsSync(file)) { errors.push(`missing ${path.relative(ROOT, file)}`); continue; } const expected = emitDictTs(dicts[lang]); const actual = fs.readFileSync(file, "utf8"); if (expected !== actual) { errors.push( `${path.relative(ROOT, file)} drifted from app.js (re-run extract-i18n.mjs)`, ); } } return errors; } function selfTest() { const dicts = extractAppJsDicts(); const zhKeys = Object.keys(dicts.zh); const enKeys = Object.keys(dicts.en); if (zhKeys.length !== enKeys.length) { throw new Error(`zh ${zhKeys.length} vs en ${enKeys.length}`); } const onlyZh = zhKeys.filter((k) => !Object.prototype.hasOwnProperty.call(dicts.en, k)); const onlyEn = enKeys.filter((k) => !Object.prototype.hasOwnProperty.call(dicts.zh, k)); if (onlyZh.length || onlyEn.length) { throw new Error(`key set mismatch onlyZh=${onlyZh} onlyEn=${onlyEn}`); } if (zhKeys.length < 1207) { throw new Error(`expected ≥1207 keys, got ${zhKeys.length}`); } const roundTrip = extractAppJsDicts(); const zhDiff = keyDiff(dicts.zh, roundTrip.zh); const enDiff = keyDiff(dicts.en, roundTrip.en); if (zhDiff.length || enDiff.length) { throw new Error(`non-deterministic extract zh=${zhDiff} en=${enDiff}`); } const emitted = emitDictTs(dicts.zh); if (!emitted.includes("export default dict")) { throw new Error("emitDictTs missing default export"); } console.log( `extract-i18n self-test: zh=${zhKeys.length} en=${enKeys.length} aligned`, ); } function main(argv) { const args = argv.slice(2); if (args.includes("--self-test")) { selfTest(); return; } const dicts = extractAppJsDicts(); const zhN = Object.keys(dicts.zh).length; const enN = Object.keys(dicts.en).length; if (args.includes("--check")) { const errors = checkDicts(dicts); if (errors.length) { for (const e of errors) console.error(e); process.exitCode = 1; return; } console.log(`extract-i18n --check: zh.ts/en.ts match app.js (${zhN}/${enN})`); return; } writeDicts(dicts); console.log(`wrote ${path.relative(ROOT, ZH_TS)} (${zhN} keys)`); console.log(`wrote ${path.relative(ROOT, EN_TS)} (${enN} keys)`); } const thisFile = fileURLToPath(import.meta.url); const invoked = process.argv[1] && path.resolve(process.argv[1]) === thisFile; if (invoked) { main(process.argv); }