#!/usr/bin/env node // gen_proto_i18n.mjs — 原型 i18n 单源 codegen(i18n 单源工程 ③) // // 让 design/prototype/screens/{ui-desktop,ui-mobile}.html 里的 `I18N` 数据块 // 不再手写,而是从 **翻译单源** 生成,消除与 Flutter app 的重复: // 产品串 → design/i18n/strings.json 的 getters(取 zh/en;单源赢) // 原型专属 → design/prototype/i18n/overlay.json(开发挡板 + app 没有的原型串,zh/en) // 映射关系 → design/prototype/i18n/alias.json(protoKey → flutterKey) // // 输出各按该文件原生 shape: // desktop per-key : var I18N = { 'k':{zh:'..',en:'..'}, ... }; // mobile per-lang: const I18N = { zh:{ k:'..', ... }, en:{ k:'..', ... } }; // 只重写 `// <<>>` ... `// <<>>` 之间的内容,其余(HTML_KEYS // 等)保持原样。此后 I18N 块**只由本生成器写,禁手改**。 // // 用法: node design/codegen/gen_proto_i18n.mjs 写入 // node design/codegen/gen_proto_i18n.mjs --check 只校验(block 漂移即非零退出) // 确定性: 键按字典序排序,zh/en 两语键集与顺序一致 → 重跑零 diff。 // --check: 只比对 marker 块(before/after 取自当前文件,unrelated HTML 编辑两侧抵消,不误报)。 import fs from 'node:fs'; import path from 'node:path'; import url from 'node:url'; const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..', '..'); const STRINGS = path.join(ROOT, 'design/i18n/strings.json'); const I18N_DIR = path.join(ROOT, 'design/prototype/i18n'); const SCREENS = path.join(ROOT, 'design/prototype/screens'); const START = '// <<>>'; const END = '// <<>>'; const LANGS = ['zh', 'en']; const strings = JSON.parse(fs.readFileSync(STRINGS, 'utf8')); const getters = strings.getters; const alias = JSON.parse(fs.readFileSync(path.join(I18N_DIR, 'alias.json'), 'utf8')); const overlay = JSON.parse(fs.readFileSync(path.join(I18N_DIR, 'overlay.json'), 'utf8')); // 单引号 JS 字面量转义(值可能含
/emoji/{占位}/单引号) function esc(s) { return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\r?\n/g, '\\n'); } // mobile 裸标识符键无需引号;含 . 等的键(如 rr.forced)加引号 function keyLit(k) { return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(k) ? k : `'${k}'`; } // 解析某文件的 { protoKey: {zh,en} } 完整字典(alias 从单源取,overlay 从 overlay 取) function resolve(file) { const a = alias[file] || {}; const o = overlay[file] || {}; const out = {}; const errs = []; for (const [pk, fk] of Object.entries(a)) { if (!getters[fk]) { errs.push(`alias ${file}.${pk} → 未知 flutter key '${fk}'(不在 strings.json.getters)`); continue; } out[pk] = { zh: getters[fk].zh, en: getters[fk].en }; } for (const [pk, v] of Object.entries(o)) { if (out[pk]) { errs.push(`键冲突 ${file}.${pk} 同时在 alias 与 overlay`); continue; } out[pk] = { zh: v.zh, en: v.en }; } if (errs.length) { console.error('✗ ' + errs.join('\n✗ ')); process.exit(1); } return out; } function emitDesktop(dict) { const keys = Object.keys(dict).sort(); const L = ['var I18N = {']; keys.forEach((k, i) => { const comma = i === keys.length - 1 ? '' : ','; L.push(` '${k}':{zh:'${esc(dict[k].zh)}',en:'${esc(dict[k].en)}'}${comma}`); }); L.push('};'); return L.join('\n'); } function emitMobile(dict) { const keys = Object.keys(dict).sort(); const L = ['const I18N = {']; LANGS.forEach((lang, li) => { L.push(` ${lang}:{`); keys.forEach((k, i) => { const comma = i === keys.length - 1 ? '' : ','; L.push(` ${keyLit(k)}:'${esc(dict[k][lang])}'${comma}`); }); L.push(li === LANGS.length - 1 ? ' }' : ' },'); }); L.push('};'); return L.join('\n'); } const CHECK = process.argv.includes('--check'); function rewrite(fileName, emitted) { const p = path.join(SCREENS, fileName); const src = fs.readFileSync(p, 'utf8'); const s = src.indexOf(START); const e = src.indexOf(END); if (s < 0 || e < 0 || e < s) { console.error(`✗ ${fileName}: 缺少 ${START} / ${END} marker`); process.exit(1); } const lineEnd = src.indexOf('\n', s) + 1; // START 标记行之后 const before = src.slice(0, lineEnd); const after = src.slice(e); // 从 END 标记开始保留 const next = before + emitted + '\n' + after; if (CHECK) return next !== src; // true = block 漂移 fs.writeFileSync(p, next); return false; } const desktopDict = resolve('desktop'); const mobileDict = resolve('mobile'); const drifted = [ rewrite('ui-desktop.html', emitDesktop(desktopDict)) && 'ui-desktop.html', rewrite('ui-mobile.html', emitMobile(mobileDict)) && 'ui-mobile.html', ].filter(Boolean); if (CHECK) { if (drifted.length) { console.error(`✗ 原型 I18N 块漂移(与 strings.json/alias/overlay 不一致):${drifted.join(', ')}`); console.error(' 修复:node design/codegen/gen_proto_i18n.mjs 后提交。'); process.exit(1); } console.log('✓ 原型 I18N 块与单源一致(无漂移)'); } else { console.log(`✓ desktop I18N: ${Object.keys(desktopDict).length} keys · mobile I18N: ${Object.keys(mobileDict).length} keys`); }