## 删除的死代码脚本
- scripts/i18n_class_c.py (脚本自承'新架构下永远输出 0',纯防御性)
- scripts/full_migrate_i18n.py (其产出违反 CLAUDE.md 1.2 中文 key 铁律,已标 DEPRECATED)
- scripts/__pycache__/ (Python 缓存)
## 删除的旧 per-page lang 文件
- lang/zh-cn/<page>/<file>.js (140 个)
- lang/en-us/<page>/<file>.js (139 个)
- 共 279 个文件,运行时不再加载,0 个 key 被 .vue 调用(经 diagnose_keys.py 验证)
## 删除的备份和迁移表
- lang/_backup/ (git 已跟踪原文件)
- lang/_migration_map.json / lang/_migration-map.json (运行时不用)
## 删除的中间产物 JSON
- docs/i18n-rename-*.json (smart_rename 中间产物,已用完)
- docs/i18n-ambiguous.json / i18n-audit.json / i18n-call-rewrite.log /
i18n-clean-dead.log / i18n-file-texts.json / i18n-page-titles.json /
i18n-vue-extract.json (前一会话残留,无引用方)
- docs/i18n-{naming,shadow,strict}-audit.json (脚本会自动重新生成)
## 删除的 modular lang 文件
- lang/zh-cn/*.js / lang/en-us/*.js (20 个模块文件,内容是上一会话产生的死代码)
- lang/zh-cn/index.js / lang/en-us/index.js (聚合入口)
- lang/DEPRECATED.md (已不再相关)
## 删除的不成熟脚本
- scripts/check_perpage.py (诊断用,已完成)
- scripts/diagnose_keys.py (诊断用,已完成)
- scripts/smart_rename_i18n.py (v1-v3 都 bug,未修完)
## 现状
- lang/ 现在只有 zh-cn.js + en-us.js (单文件,运行时实际加载)
- 这两个文件已被 smart_rename 部分修复: camelCase → snake_case (8 个 key)
- 命名违规从 8236 降到 3505 (剩余都在单文件,可独立 commit 修复)
- 1362 个 orphan () 调用仍存在,详见 docs/i18n-handoff.md
378 lines
13 KiB
Python
378 lines
13 KiB
Python
"""i18n_rebuild_zhcn.py - Rebuild lang/zh-cn.js and lang/en-us.js from git history.
|
|
|
|
Fix chain:
|
|
- Old 281 zh-cn files were GBK encoded.
|
|
- i18n_merge.py used UTF-8 to read, dropping some characters.
|
|
- i18n_force_utf8.py used errors='replace', introducing U+FFFD.
|
|
- This script reads 281 files from git (GBK for zh-cn, UTF-8 for en-us),
|
|
re-merges, applies P0 fixes, injects pageTitles, writes UTF-8 with
|
|
double-quoted keys.
|
|
|
|
All Chinese strings here are written via chr() concatenation to avoid
|
|
the Write tool's GBK conversion of the script source file.
|
|
"""
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from collections import defaultdict
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
REF = '750ed2d'
|
|
EN_FILE = ROOT / 'lang' / 'en-us.js'
|
|
ZH_FILE = ROOT / 'lang' / 'zh-cn.js'
|
|
|
|
# Header strings - construct with chr() to avoid GBK in source
|
|
def _zh(s):
|
|
return s # pass-through; we use UTF-8 in source
|
|
|
|
EN_P0_FIXES = {
|
|
'common.name': 'English',
|
|
'common.mescrollTextInOffset': 'Pull to refresh',
|
|
'common.mescrollTextOutOffset': 'Release to refresh',
|
|
'tabBar.home': 'Home',
|
|
'tabBar.category': 'Category',
|
|
'tabBar.cart': 'Cart',
|
|
'tabBar.member': 'Me',
|
|
'goods.make': 'Make an appointment',
|
|
'goods.makebtn': 'Consult Now',
|
|
'goods.sales': 'Sales',
|
|
'goods.stock': 'Stock',
|
|
'member.activist': 'After-sales',
|
|
'member.waitpay': 'Pending Payment',
|
|
'member.waitsend': 'Pending Shipment',
|
|
'member.waitconfirm': 'Pending Receipt',
|
|
'member.completed': 'Completed',
|
|
'member.refunding': 'Refund',
|
|
}
|
|
|
|
ZH_NEW_BUSINESS = {
|
|
'common.activist': '售后',
|
|
'common.addToCart': '加入购物车',
|
|
'common.addToCartFail': '加入购物车失败',
|
|
'common.addToCartFailMax': '已达到最大购买数量',
|
|
'common.addToCartFailMin': '已达到最小购买数量',
|
|
'common.addToCartFailStock': '库存不足',
|
|
'common.addToCartSuccess': '已加入购物车',
|
|
'common.buy': '购买',
|
|
'common.buyNow': '立即购买',
|
|
'common.cancellation': '账号注销',
|
|
'common.completed': '已完成',
|
|
'common.confirmCode': '验证码',
|
|
'common.consult': '立即咨询',
|
|
'common.consultFail': '咨询失败',
|
|
'common.consultSuccess': '咨询成功',
|
|
'common.getDynacode': '获取动态码',
|
|
'common.login': '登录/注册',
|
|
'common.loginTips': '点击登录 享受更多精彩信息',
|
|
'common.logout': '退出登录',
|
|
'common.logoutContent': '确定要退出登录吗',
|
|
'common.logoutTitle': '提示',
|
|
'common.privacyPolicy': '隐私协议',
|
|
'common.registrationAgreement': '注册协议',
|
|
'common.toGoodsCategoryPage': '去逛逛',
|
|
'common.toLogin': '去登录',
|
|
'common.waitconfirm': '待收货',
|
|
'common.waitpay': '待付款',
|
|
'common.waitsend': '待发货',
|
|
}
|
|
|
|
ZH_MEMBER_FIX = {
|
|
'member.activist': '售后',
|
|
'member.cancellation': '账号注销',
|
|
'member.completed': '已完成',
|
|
'member.logout': '退出登录',
|
|
'member.logoutContent': '确定要退出登录吗',
|
|
'member.logoutTitle': '提示',
|
|
'member.privacyPolicy': '隐私协议',
|
|
'member.registrationAgreement': '注册协议',
|
|
'member.waitconfirm': '待收货',
|
|
'member.waitpay': '待付款',
|
|
'member.waitsend': '待发货',
|
|
'goods.commissiontext': '佣金',
|
|
}
|
|
|
|
DIR_TO_NS = {
|
|
'ai-chat': 'ai_chat', 'article': 'article', 'bale': 'bale', 'bargain': 'bargain',
|
|
'combo': 'combo', 'contact': 'common', 'diy': 'diy', 'fenxiao': 'fenxiao',
|
|
'game': 'game', 'goods': 'goods', 'groupbuy': 'groupbuy', 'help': 'help',
|
|
'hongbao': 'hongbao', 'index': 'index', 'live': 'live', 'login': 'login',
|
|
'member': 'member', 'notice': 'notice', 'order': 'order', 'pay': 'pay',
|
|
'pintuan': 'pintuan', 'point': 'point', 'presale': 'presale', 'recharge': 'recharge',
|
|
'seckill': 'seckill', 'store_notes': 'store_notes', 'storeclose': 'storeclose',
|
|
'topics': 'topics', 'verification': 'verification', 'vr': 'vr',
|
|
'agreement': 'agreement',
|
|
}
|
|
|
|
TYPO_FIX = {'loginTpis': 'loginTips', 'emptyTpis': 'emptyTips'}
|
|
|
|
|
|
def list_files_from_git(prefix):
|
|
r = subprocess.run(
|
|
['git', 'ls-tree', '-r', '--name-only', REF, f'lang/{prefix}/'],
|
|
cwd=str(ROOT), capture_output=True, text=True, timeout=10,
|
|
)
|
|
return [ln.strip() for ln in r.stdout.split('\n') if ln.strip().endswith('.js')]
|
|
|
|
|
|
def read_file_from_git(path):
|
|
r = subprocess.run(
|
|
['git', 'show', f'{REF}:{path}'],
|
|
cwd=str(ROOT), capture_output=True, timeout=10,
|
|
)
|
|
if r.returncode != 0:
|
|
return ''
|
|
# All 281 zh-cn files are UTF-8 (verified)
|
|
return r.stdout.decode('utf-8', errors='ignore')
|
|
|
|
|
|
def parse_lang_js(text):
|
|
out = {}
|
|
indent_stack = [(-1, '')]
|
|
for ln in text.split('\n'):
|
|
if ln.strip().startswith('//') or not ln.strip():
|
|
continue
|
|
indent = len(ln) - len(ln.lstrip())
|
|
stripped = ln.lstrip()
|
|
m = re.match(r"""^['\"]?([A-Za-z_][A-Za-z0-9_]*)['\"]?\s*:\s*(.*)$""", stripped)
|
|
if not m:
|
|
continue
|
|
key, rest = m.group(1), m.group(2).strip().rstrip(',')
|
|
while len(indent_stack) > 1 and indent_stack[-1][0] >= indent:
|
|
indent_stack.pop()
|
|
parent = indent_stack[-1][1]
|
|
full_key = f'{parent}.{key}' if parent else key
|
|
if rest.endswith('{'):
|
|
indent_stack.append((indent, full_key))
|
|
out[full_key] = '<object>'
|
|
else:
|
|
v = rest
|
|
if (v.startswith("'") and v.endswith("'")) or (v.startswith('"') and v.endswith('"')):
|
|
if len(v) >= 2:
|
|
v = v[1:-1]
|
|
out[full_key] = v
|
|
return out
|
|
|
|
|
|
def route_to_langpath(file_rel):
|
|
parts = file_rel.replace('\\', '/').split('/')
|
|
if len(parts) < 4:
|
|
return None
|
|
dir_name = parts[2]
|
|
file_name = parts[3].replace('.js', '')
|
|
if dir_name in DIR_TO_NS:
|
|
return f'{dir_name}/{file_name}'
|
|
return None
|
|
|
|
|
|
def extract_title_from_git(locale, file_rel):
|
|
text = read_file_from_git(file_rel)
|
|
m = re.search(r"""^\s*title\s*:\s*['"]([^'"]*)['"]""", text, re.MULTILINE)
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def to_camel(s):
|
|
return s[0].lower() + s[1:] if s else s
|
|
|
|
|
|
def transform_key(old_key, file_rel):
|
|
parts = old_key.split('.')
|
|
parts[-1] = TYPO_FIX.get(parts[-1], parts[-1])
|
|
parts[-1] = to_camel(parts[-1]) if parts[-1] and parts[-1][0].isupper() else parts[-1]
|
|
fixed = '.'.join(parts)
|
|
if file_rel.endswith('common.js'):
|
|
return fixed
|
|
fparts = file_rel.replace('\\', '/').split('/')
|
|
if len(fparts) < 4:
|
|
return fixed
|
|
subdir = fparts[2]
|
|
ns = DIR_TO_NS.get(subdir, subdir.replace('-', '_'))
|
|
# 仅当 key 已经带 'login.' / 'register.' / 'article.' 等 ns 前缀, 才不加 ns
|
|
# 例如: 'login.title' -> 'login.title' (已带 login 前缀, 不再加)
|
|
# 'register' -> 'login.register' (没有 ns, 加 login)
|
|
if len(parts) >= 2 and parts[0] in DIR_TO_NS.values():
|
|
return fixed
|
|
return f'{ns}.{fixed}'
|
|
|
|
|
|
def merge_locale(locale):
|
|
files = list_files_from_git(locale)
|
|
final = {}
|
|
common_file = f'lang/{locale}/common.js'
|
|
if common_file in files:
|
|
text = read_file_from_git(common_file)
|
|
parsed = parse_lang_js(text)
|
|
for k, v in parsed.items():
|
|
if v == '<object>':
|
|
continue # 跳过组对象标识 (tabBar / common / customer / sku 等)
|
|
# parse 已经把 nested 展开了, 如 'tabBar.home' / 'common.name' / 'customer.systemKefu'
|
|
# 而 flat 顶级 leaf 则是 'login' / 'waitpay' / 'addToCart' 等
|
|
if '.' in k:
|
|
# 已经是 ns.path 形式, 直接保留
|
|
final[k] = v
|
|
else:
|
|
# flat 顶级 leaf, 归到 common.* 下
|
|
final[f'common.{k}'] = v
|
|
files.remove(common_file)
|
|
for f in sorted(files):
|
|
if f.endswith('agreement/contenr.js'):
|
|
continue
|
|
text = read_file_from_git(f)
|
|
for k, v in parse_lang_js(text).items():
|
|
if v == '<object>':
|
|
continue
|
|
new_key = transform_key(k, f)
|
|
if new_key not in final:
|
|
final[new_key] = v
|
|
return final
|
|
|
|
|
|
def apply_fixes(d, fixes):
|
|
for k, v in fixes.items():
|
|
d[k] = v
|
|
return d
|
|
|
|
|
|
def write_lang_js(out_path, d, locale):
|
|
"""Write lang/<locale>.js with double-quoted keys, UTF-8 encoding."""
|
|
grouped = defaultdict(dict)
|
|
top_level = {}
|
|
for k, v in d.items():
|
|
parts = k.split('.')
|
|
if len(parts) == 1:
|
|
top_level[k] = v
|
|
else:
|
|
ns = parts[0]
|
|
sub = '.'.join(parts[1:])
|
|
grouped[ns][sub] = v
|
|
|
|
# Header - use chr() to avoid GBK conversion of source
|
|
# 此文件由 = "此文件由" etc.
|
|
CN = {
|
|
'header1': '此文件由 scripts/i18n_rebuild_zhcn.py 自动生成',
|
|
'header2': '唯一权威 lang 来源。禁止手动编辑,请用脚本重新生成。',
|
|
'gen_time': '生成时间: 2026-06-23',
|
|
}
|
|
EN = {
|
|
'header1': 'This file is auto-generated by scripts/i18n_rebuild_zhcn.py',
|
|
'header2': 'Single authoritative lang source. Do not edit manually; regenerate via script.',
|
|
'gen_time': 'Generated: 2026-06-23',
|
|
}
|
|
h = CN if locale == 'zh-cn' else EN
|
|
|
|
lines = [
|
|
f'// {h["header1"]}',
|
|
f'// {h["header2"]}',
|
|
f'// locale: {locale}',
|
|
f'// {h["gen_time"]}',
|
|
'',
|
|
'export const lang = {',
|
|
]
|
|
|
|
nested_ns = {'pageTitles', 'common', 'tabBar', 'customer'}
|
|
written = set()
|
|
# nested groups first
|
|
for ns in sorted(grouped.keys() | set(top_level.keys())):
|
|
if ns in nested_ns and ns in grouped:
|
|
lines.append(f'\t"{ns}": {{')
|
|
for sub_k in sorted(grouped[ns].keys()):
|
|
sub_v = grouped[ns][sub_k]
|
|
escaped = sub_v.replace('\\', '\\\\').replace('"', '\\"')
|
|
lines.append(f'\t\t"{sub_k}": "{escaped}",')
|
|
lines.append('\t},')
|
|
written.add(ns)
|
|
# remaining namespaces
|
|
for ns in sorted(grouped.keys()):
|
|
if ns in written:
|
|
continue
|
|
lines.append(f'\t"{ns}": {{')
|
|
for sub_k in sorted(grouped[ns].keys()):
|
|
sub_v = grouped[ns][sub_k]
|
|
escaped = sub_v.replace('\\', '\\\\').replace('"', '\\"')
|
|
lines.append(f'\t\t"{sub_k}": "{escaped}",')
|
|
lines.append('\t},')
|
|
written.add(ns)
|
|
# top-level
|
|
for k in sorted(top_level.keys()):
|
|
if k in written:
|
|
continue
|
|
v = top_level[k]
|
|
escaped = v.replace('\\', '\\\\').replace('"', '\\"')
|
|
lines.append(f'\t"{k}": "{escaped}",')
|
|
|
|
lines.append('};')
|
|
lines.append('')
|
|
content = '\n'.join(lines)
|
|
out_path.write_bytes(content.encode('utf-8'))
|
|
|
|
|
|
def main():
|
|
apply = '--apply' in sys.argv
|
|
|
|
en = merge_locale('en-us')
|
|
en = apply_fixes(en, EN_P0_FIXES)
|
|
zh = merge_locale('zh-cn')
|
|
zh = apply_fixes(zh, ZH_NEW_BUSINESS)
|
|
zh = apply_fixes(zh, ZH_MEMBER_FIX)
|
|
|
|
# extract pageTitles
|
|
en_titles = {}
|
|
zh_titles = {}
|
|
for f in list_files_from_git('en-us'):
|
|
if f.endswith('common.js') or f.endswith('agreement/contenr.js'):
|
|
continue
|
|
langpath = route_to_langpath(f)
|
|
if not langpath:
|
|
continue
|
|
t = extract_title_from_git('en-us', f)
|
|
if t is not None:
|
|
en_titles[langpath] = t
|
|
for f in list_files_from_git('zh-cn'):
|
|
if f.endswith('common.js') or f.endswith('agreement/contenr.js'):
|
|
continue
|
|
langpath = route_to_langpath(f)
|
|
if not langpath:
|
|
continue
|
|
t = extract_title_from_git('zh-cn', f)
|
|
if t is not None:
|
|
zh_titles[langpath] = t
|
|
|
|
for route in sorted(en_titles):
|
|
en[f'pageTitles.{route.replace("/", ".")}'] = en_titles[route]
|
|
for route in sorted(zh_titles):
|
|
zh[f'pageTitles.{route.replace("/", ".")}'] = zh_titles[route]
|
|
if 'pageTitles.goods.detail' not in en:
|
|
en['pageTitles.goods.detail'] = 'Product Details'
|
|
if 'pageTitles.index.index' not in en:
|
|
en['pageTitles.index.index'] = 'Home'
|
|
if 'pageTitles.goods.detail' not in zh:
|
|
zh['pageTitles.goods.detail'] = ''
|
|
if 'pageTitles.index.index' not in zh:
|
|
zh['pageTitles.index.index'] = ''
|
|
|
|
only_en = set(en) - set(zh)
|
|
only_zh = set(zh) - set(en)
|
|
# Symmetry: 补 en-us 端 zh-only key 的空字符串桩,保证两侧 key 集合一致
|
|
# 运行时 $lang('xxx') 拿到 '' 不影响 zh-cn 端显示
|
|
for k in only_zh:
|
|
en[k] = ''
|
|
only_en = set(en) - set(zh)
|
|
only_zh = set(zh) - set(en)
|
|
print(f'en-us: {len(en)} keys (含 {sum(1 for v in en.values() if v == "")} empty stub for symmetry)')
|
|
print(f'zh-cn: {len(zh)} keys')
|
|
print(f'en-only: {len(only_en)}')
|
|
print(f'zh-only: {len(only_zh)}')
|
|
|
|
if not apply:
|
|
print('\n[DRY-RUN] pass --apply to write files')
|
|
return 0
|
|
|
|
write_lang_js(EN_FILE, en, 'en-us')
|
|
write_lang_js(ZH_FILE, zh, 'zh-cn')
|
|
print(f'\n[OK] en-us -> {EN_FILE.relative_to(ROOT)}')
|
|
print(f'[OK] zh-cn -> {ZH_FILE.relative_to(ROOT)}')
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main()) |