Files
pangolin/design/codegen/gen_proto_i18n.mjs
wangjia 75d23023e3 feat(i18n): ③ 原型接单源 — I18N 块由 strings.json 生成,消除重复翻译
原型 design/prototype/screens/{ui-desktop,ui-mobile}.html 的 I18N 数据块不再手写,
改由 strings.json 生成(marker 包裹、生成器唯一写):
- alias.json:protoKey → flutterKey(产品串取单源 zh/en,单源赢);desktop 130 / mobile 143。
- overlay.json:原型专属(开发挡板 + app 没有的串,zh/en);desktop 98 / mobile 104。
- new-strings.json:4 个新产品串(分流规则/自定义/局域网直连/强制),6 语,已并入
  strings.json + app_text.dart 抽象声明 + 重生成 6 份 strings_*.dart。
- gen_proto_i18n.mjs:strings.json+alias+overlay → 两份 HTML 的 I18N 块;--check 只比对
  marker 块(unrelated HTML 编辑不误报)。
- check-proto-i18n.mjs:两份 HTML 所有 data-i18n key 的 zh/en 必须 resolve。
- ci/check-codegen-drift.sh:加原型 i18n 段(--check 漂移 + resolution 验收)。

同带入之前路由原型 WIP 的 i18n 收口:私有服务内置行→局域网/私网直连行、rr.* 接 data-i18n
(rr.lan→routingLanDirect 等)。路由页其余 UI(段选/添加规则页)仍为独立后续。

验收(自复核全绿):生成器跑通、data-i18n 0 unresolved、两生成器重生成零 diff、
flutter analyze lib/l10n 0 issue、flutter test 244 全绿、new-strings 无红线词。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A79VtQA1BwTuQN1ThpvYpo
2026-07-27 09:59:18 +08:00

129 lines
5.2 KiB
JavaScript

#!/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:'..', ... } };
// 只重写 `// <<<gen-i18n>>>` ... `// <<<end-gen-i18n>>>` 之间的内容,其余(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 = '// <<<gen-i18n>>>';
const END = '// <<<end-gen-i18n>>>';
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 字面量转义(值可能含 <br>/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`);
}