feat(i18n): l10n codegen — strings.json 单源生成 6 份 strings_*.dart

② 单源 codegen 落地:
- strings.json 补全 lists 段(days7/featsFree/featsPro/featsTeam),
  之前 extraction 漏了 4 个 List<String> getter;现 246 getter+4 list+5 method×6 语。
- design/codegen/gen_l10n_dart.mjs:strings.json → strings_{zh,en,ja,ko,ru,es}.dart,
  getter 转义 \$、method 保留 $var 插值,dart format 定型(保证漂移闸零 diff)。
- app_text.dart 保持手写(enum/abstract 声明/AppLangMisc 逻辑),编译器强制 concrete⊆abstract 兜底。
- 往返无损:生成的 Dart 重新抽取 == strings.json 逐字节一致;flutter analyze 0 issue、flutter test 244 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A79VtQA1BwTuQN1ThpvYpo
This commit is contained in:
wangjia
2026-07-27 08:47:44 +08:00
parent 9ab550d398
commit f76e3cdffd
9 changed files with 662 additions and 372 deletions
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
// gen_l10n_dart.mjs — l10n 单源 codegen
//
// 真源:design/i18n/strings.json(getters + lists + methods × 6 语)。
// 生成:client/lib/l10n/strings_{zh,en,ja,ko,ru,es}.dart(concrete AppText 实现)。
// 手写保留:client/lib/l10n/app_text.dart(enum AppLang / abstract AppText 声明 /
// AppLangLabel / AppLangMisc 逻辑)。Dart 编译器强制 concrete⊆abstract 兜底漂移。
//
// 用法:node design/codegen/gen_l10n_dart.mjs
// 生成后自动 `dart format`,保证 CI「重生成零 diff」闸稳定(漂移闸 = 重生成 + git diff)。
import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';
import { execFileSync } from 'node:child_process';
const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..', '..');
const SRC = path.join(ROOT, 'design/i18n/strings.json');
const OUT_DIR = path.join(ROOT, 'client/lib/l10n');
const LANGS = ['zh', 'en', 'ja', 'ko', 'ru', 'es'];
const CLASS = { zh: 'StringsZh', en: 'StringsEn', ja: 'StringsJa', ko: 'StringsKo', ru: 'StringsRu', es: 'StringsEs' };
const HEADER_NOTE = { zh: '中文', en: 'English', ja: '日本語', ko: '한국어', ru: 'Русский', es: 'Español' };
// 字面量转义(getter / list 元素):无插值,$ 一律转义为字面量。
function litStr(s) {
return s.replace(/\\/g, '\\\\').replace(/\$/g, '\\$').replace(/'/g, "\\'").replace(/\n/g, '\\n');
}
// 模板转义(method):保留 $var 插值,仅转义反斜杠/引号/换行。
function tplStr(s) {
return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n');
}
const data = JSON.parse(fs.readFileSync(SRC, 'utf8'));
const { getters, lists, methods } = data;
function genFile(lang) {
const L = [];
L.push(`// strings_${lang}.dart — ${HEADER_NOTE[lang]}文案(自动生成 · 勿手改)`);
L.push('//');
L.push('// GENERATED by design/codegen/gen_l10n_dart.mjs from design/i18n/strings.json.');
L.push('// 改文案:改 strings.json 后重跑 codegen;新增 key 需同步 app_text.dart 抽象声明。');
L.push(`import 'app_text.dart';`);
L.push('');
L.push(`class ${CLASS[lang]} extends AppText {`);
L.push(` const ${CLASS[lang]}();`);
L.push('');
L.push(' @override');
L.push(` AppLang get lang => AppLang.${lang};`);
L.push('');
for (const [k, m] of Object.entries(getters)) {
L.push(' @override');
L.push(` String get ${k} => '${litStr(m[lang])}';`);
}
for (const [k, m] of Object.entries(lists)) {
const items = m[lang].map((x) => `'${litStr(x)}'`).join(', ');
L.push(' @override');
L.push(` List<String> get ${k} => const [${items}];`);
}
for (const [k, m] of Object.entries(methods)) {
L.push(' @override');
L.push(` String ${k}(${m.args}) => '${tplStr(m.tpl[lang])}';`);
}
L.push('}');
L.push('');
return L.join('\n');
}
const written = [];
for (const lang of LANGS) {
const out = path.join(OUT_DIR, `strings_${lang}.dart`);
fs.writeFileSync(out, genFile(lang));
written.push(out);
}
// dart format 定型,保证零 diff 闸稳定
try {
execFileSync('dart', ['format', ...written], { cwd: ROOT, stdio: 'inherit' });
} catch (e) {
console.warn('⚠ dart format 失败(dart 不在 PATH?),已生成未格式化文件:', e.message);
}
const g = Object.keys(getters).length, l = Object.keys(lists).length, me = Object.keys(methods).length;
console.log(`✓ 生成 ${written.length} 份:getters=${g} lists=${l} methods=${me} × ${LANGS.length}`);