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:
@@ -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} 语`);
|
||||
@@ -1,55 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
# 从 6 份手写 strings_*.dart 抽取 l10n 到单源 JSON。只读,不改 Dart。
|
||||
import re, json, sys, os
|
||||
# 完整抽取 l10n → 单源 JSON:getters(String get) + lists(List<String> get) + methods(带参)。
|
||||
# 只读 Dart,不改。与已提交 strings.json 的 getters/methods 校验无回归,新增 lists 段。
|
||||
import re, json, os
|
||||
|
||||
BASE = "/Users/wangjia/code/pangolin/.claude/worktrees/configurable-proxy/client/lib/l10n"
|
||||
OUT = "/Users/wangjia/code/pangolin/.claude/worktrees/configurable-proxy/design/i18n/strings.json"
|
||||
LANGS = ["zh", "en", "ja", "ko", "ru", "es"]
|
||||
|
||||
# Dart 字符串字面量:单引号或双引号(各自允许 \ 转义);getter 与带参方法
|
||||
STR = r"""(?:'((?:[^'\\]|\\.)*)'|"((?:[^"\\]|\\.)*)")"""
|
||||
STR_RE = re.compile(STR)
|
||||
GET_RE = re.compile(r"String\s+get\s+(\w+)\s*=>\s*" + STR + r"\s*;")
|
||||
MET_RE = re.compile(r"String\s+(\w+)\(([^)]*)\)\s*=>\s*" + STR + r"\s*;")
|
||||
# List<String> get name => const [ ... ]; —— 跨行,惰性到第一个 '];'
|
||||
LIST_RE = re.compile(r"List<String>\s+get\s+(\w+)\s*=>\s*const\s*\[(.*?)\]\s*;", re.DOTALL)
|
||||
|
||||
|
||||
def unescape(s):
|
||||
return s.replace("\\'", "'").replace('\\"', '"').replace('\\\\', '\\').replace('\\n', '\n').replace('\\$', '$')
|
||||
return (s.replace("\\'", "'").replace('\\"', '"')
|
||||
.replace('\\n', '\n').replace('\\$', '$').replace('\\\\', '\\'))
|
||||
|
||||
getters = {} # key -> {lang: val}
|
||||
methods = {} # key -> {args, tpl:{lang:val}}
|
||||
counts = {}
|
||||
|
||||
getters, lists, methods, counts = {}, {}, {}, {}
|
||||
for lang in LANGS:
|
||||
path = os.path.join(BASE, f"strings_{lang}.dart")
|
||||
src = open(path, encoding="utf-8").read()
|
||||
g = 0
|
||||
for m in GET_RE.finditer(src):
|
||||
k = m.group(1); v = unescape(m.group(2) if m.group(2) is not None else m.group(3))
|
||||
getters.setdefault(k, {})[lang] = v
|
||||
g += 1
|
||||
mm = 0
|
||||
for m in MET_RE.finditer(src):
|
||||
k = m.group(1); args = m.group(2).strip()
|
||||
v = unescape(m.group(3) if m.group(3) is not None else m.group(4))
|
||||
src = open(os.path.join(BASE, f"strings_{lang}.dart"), encoding="utf-8").read()
|
||||
g = m = ln = 0
|
||||
for mt in GET_RE.finditer(src):
|
||||
k = mt.group(1)
|
||||
v = unescape(mt.group(2) if mt.group(2) is not None else mt.group(3))
|
||||
getters.setdefault(k, {})[lang] = v; g += 1
|
||||
for mt in MET_RE.finditer(src):
|
||||
k = mt.group(1); args = mt.group(2).strip()
|
||||
v = unescape(mt.group(3) if mt.group(3) is not None else mt.group(4))
|
||||
methods.setdefault(k, {"args": args, "tpl": {}})
|
||||
methods[k]["tpl"][lang] = v
|
||||
mm += 1
|
||||
counts[lang] = {"getters": g, "methods": mm}
|
||||
methods[k]["tpl"][lang] = v; m += 1
|
||||
for mt in LIST_RE.finditer(src):
|
||||
k = mt.group(1); body = mt.group(2)
|
||||
items = [unescape(a if a is not None else b) for a, b in STR_RE.findall(body)]
|
||||
lists.setdefault(k, {})[lang] = items; ln += 1
|
||||
counts[lang] = {"getters": g, "methods": m, "lists": ln}
|
||||
|
||||
# 完整性校验:每个 key 六语齐全
|
||||
# 完整性:每 key 六语齐全
|
||||
missing = []
|
||||
for k, d in getters.items():
|
||||
for l in LANGS:
|
||||
if l not in d: missing.append(f"getter {k}:{l}")
|
||||
for k, d in lists.items():
|
||||
for l in LANGS:
|
||||
if l not in d: missing.append(f"list {k}:{l}")
|
||||
for k, d in methods.items():
|
||||
for l in LANGS:
|
||||
if l not in d["tpl"]: missing.append(f"method {k}:{l}")
|
||||
|
||||
out = {"_note": "l10n 单源(唯一真相源)。codegen 生成 client/lib/l10n/strings_*.dart + app_text.dart + 原型读同源。勿手改生成物。",
|
||||
"getters": getters, "methods": methods}
|
||||
os.makedirs("/Users/wangjia/code/pangolin/.claude/worktrees/configurable-proxy/design/i18n", exist_ok=True)
|
||||
outpath = "/Users/wangjia/code/pangolin/.claude/worktrees/configurable-proxy/design/i18n/strings.json"
|
||||
json.dump(out, open(outpath, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
# 与已提交 JSON 的 getters/methods 校验无回归
|
||||
old = json.load(open(OUT, encoding="utf-8")) if os.path.exists(OUT) else {}
|
||||
regress = []
|
||||
if old:
|
||||
if set(old.get("getters", {})) != set(getters):
|
||||
regress.append("getter 键集变化")
|
||||
for k, d in old.get("getters", {}).items():
|
||||
if getters.get(k) != d: regress.append(f"getter 值变化:{k}")
|
||||
if set(old.get("methods", {})) != set(methods):
|
||||
regress.append("method 键集变化")
|
||||
|
||||
print("每语言抽取数:", counts)
|
||||
print(f"合并后:getters={len(getters)} methods={len(methods)}")
|
||||
print(f"缺失(应为0):{len(missing)}", missing[:5])
|
||||
print("输出:", outpath)
|
||||
out = {"_note": "l10n 单源(唯一真相源)。design/codegen/gen_l10n_dart.mjs 生成 client/lib/l10n/strings_*.dart;"
|
||||
"原型 serve.mjs 读同源。勿手改生成物;abstract 声明由 app_text.dart 手写(编译器强制 concrete⊆abstract)。",
|
||||
"getters": getters, "lists": lists, "methods": methods}
|
||||
json.dump(out, open(OUT, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
|
||||
print("每语言:", counts)
|
||||
print(f"合并:getters={len(getters)} lists={len(lists)} methods={len(methods)}")
|
||||
print(f"缺失(应0):{len(missing)}", missing[:5])
|
||||
print(f"回归(应0):{len(regress)}", regress[:5])
|
||||
print("lists 键:", list(lists))
|
||||
print("输出:", OUT)
|
||||
|
||||
+173
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_note": "l10n 单源(唯一真相源)。codegen 生成 client/lib/l10n/strings_*.dart + app_text.dart + 原型读同源。勿手改生成物。",
|
||||
"_note": "l10n 单源(唯一真相源)。design/codegen/gen_l10n_dart.mjs 生成 client/lib/l10n/strings_*.dart;原型 serve.mjs 读同源。勿手改生成物;abstract 声明由 app_text.dart 手写(编译器强制 concrete⊆abstract)。",
|
||||
"getters": {
|
||||
"brand": {
|
||||
"zh": "穿山甲",
|
||||
@@ -1970,6 +1970,178 @@
|
||||
"es": "Antes"
|
||||
}
|
||||
},
|
||||
"lists": {
|
||||
"days7": {
|
||||
"zh": [
|
||||
"一",
|
||||
"二",
|
||||
"三",
|
||||
"四",
|
||||
"五",
|
||||
"六",
|
||||
"日"
|
||||
],
|
||||
"en": [
|
||||
"M",
|
||||
"T",
|
||||
"W",
|
||||
"T",
|
||||
"F",
|
||||
"S",
|
||||
"S"
|
||||
],
|
||||
"ja": [
|
||||
"月",
|
||||
"火",
|
||||
"水",
|
||||
"木",
|
||||
"金",
|
||||
"土",
|
||||
"日"
|
||||
],
|
||||
"ko": [
|
||||
"월",
|
||||
"화",
|
||||
"수",
|
||||
"목",
|
||||
"금",
|
||||
"토",
|
||||
"일"
|
||||
],
|
||||
"ru": [
|
||||
"Пн",
|
||||
"Вт",
|
||||
"Ср",
|
||||
"Чт",
|
||||
"Пт",
|
||||
"Сб",
|
||||
"Вс"
|
||||
],
|
||||
"es": [
|
||||
"L",
|
||||
"M",
|
||||
"X",
|
||||
"J",
|
||||
"V",
|
||||
"S",
|
||||
"D"
|
||||
]
|
||||
},
|
||||
"featsFree": {
|
||||
"zh": [
|
||||
"仅 1 个基础节点",
|
||||
"每日 10 分钟时长",
|
||||
"使用前观看广告",
|
||||
"注册享 7 天免费试用"
|
||||
],
|
||||
"en": [
|
||||
"1 basic node only",
|
||||
"10 min per day",
|
||||
"Watch an ad to start",
|
||||
"7-day free trial on sign-up"
|
||||
],
|
||||
"ja": [
|
||||
"基本ノード1つのみ",
|
||||
"1日10分",
|
||||
"広告を見て開始",
|
||||
"登録で7日間無料トライアル"
|
||||
],
|
||||
"ko": [
|
||||
"기본 노드 1개",
|
||||
"하루 10분",
|
||||
"광고 시청 후 시작",
|
||||
"가입 시 7일 무료 체험"
|
||||
],
|
||||
"ru": [
|
||||
"Только 1 базовый узел",
|
||||
"10 минут в день",
|
||||
"Смотрите рекламу, чтобы начать",
|
||||
"7 дней бесплатно при регистрации"
|
||||
],
|
||||
"es": [
|
||||
"Solo 1 nodo básico",
|
||||
"10 min al día",
|
||||
"Mira un anuncio para empezar",
|
||||
"Prueba gratis de 7 días al registrarte"
|
||||
]
|
||||
},
|
||||
"featsPro": {
|
||||
"zh": [
|
||||
"80+ 全球节点",
|
||||
"无限流量 · 极速",
|
||||
"5 台设备同时在线",
|
||||
"流媒体优化"
|
||||
],
|
||||
"en": [
|
||||
"80+ locations",
|
||||
"Unlimited · fast",
|
||||
"5 devices",
|
||||
"Streaming optimized"
|
||||
],
|
||||
"ja": [
|
||||
"80以上の拠点",
|
||||
"無制限 · 高速",
|
||||
"5台のデバイス",
|
||||
"ストリーミング最適化"
|
||||
],
|
||||
"ko": [
|
||||
"80개 이상 지역",
|
||||
"무제한 · 빠른 속도",
|
||||
"기기 5대",
|
||||
"스트리밍 최적화"
|
||||
],
|
||||
"ru": [
|
||||
"80+ локаций",
|
||||
"Безлимитно · быстро",
|
||||
"5 устройств",
|
||||
"Оптимизация для стриминга"
|
||||
],
|
||||
"es": [
|
||||
"Más de 80 ubicaciones",
|
||||
"Ilimitado · rápido",
|
||||
"5 dispositivos",
|
||||
"Optimizado para streaming"
|
||||
]
|
||||
},
|
||||
"featsTeam": {
|
||||
"zh": [
|
||||
"专业版全部功能",
|
||||
"10 个成员席位",
|
||||
"集中计费与管理",
|
||||
"优先客服"
|
||||
],
|
||||
"en": [
|
||||
"Everything in Pro",
|
||||
"10 seats",
|
||||
"Central billing",
|
||||
"Priority support"
|
||||
],
|
||||
"ja": [
|
||||
"Proのすべての機能",
|
||||
"10ライセンス",
|
||||
"一括請求",
|
||||
"優先サポート"
|
||||
],
|
||||
"ko": [
|
||||
"Pro의 모든 기능",
|
||||
"10인 좌석",
|
||||
"통합 결제",
|
||||
"우선 지원"
|
||||
],
|
||||
"ru": [
|
||||
"Всё из Pro",
|
||||
"10 мест",
|
||||
"Единый счёт",
|
||||
"Приоритетная поддержка"
|
||||
],
|
||||
"es": [
|
||||
"Todo lo de Pro",
|
||||
"10 plazas",
|
||||
"Facturación centralizada",
|
||||
"Soporte prioritario"
|
||||
]
|
||||
}
|
||||
},
|
||||
"methods": {
|
||||
"adRewarded": {
|
||||
"args": "int minutes",
|
||||
|
||||
Reference in New Issue
Block a user