Live2D

一个skills轻松搞定项目中i18n多语言

以cursor为例

整个skills分为三个部分:

  1. i18n-scan 入口配置,校验工具
  2. i18n-fix 规范设置,修复工具 
  3. i18n-transform 最终执行工具

目录结构如下:

image

首先来看一下i18n-scan

目录结构

image

 SKILL.md文件

---
name: i18n-fix
description: 修复代码中影响国际化转换的问题,根据具体问题类型选择对应的修复模式。当前支持的修复类型:template-fix(模板字符串修复)。当用户说"处理模板字符串"、"修复模板字符串中文"、"修复 unprocessedCode",或明确指定某种 fix 模式时使用。
---

# i18n Fix

修复代码中阻碍国际化转换的问题。每种修复类型对应一个独立的 reference,包含完整的规则和执行流程。

## 支持的修复类型

| 修复类型 | 触发场景 | 详细规则 |
|---|---|---|
| **template-fix** | 源码扫描输出存在 unprocessedCode / "处理模板字符串" | `references/template-fix.md` |

> 后续新增修复类型时,在此表追加一行,并在 `references/` 下新建对应文件即可。

## 执行步骤

### Step 1:识别修复类型

根据用户描述、问题上下文或明确指定,匹配上表中的修复类型:
- 描述中提到模板字符串 / unprocessedCode → **template-fix**
- 用户直接说"用 template-fix" → **template-fix**
- 无法匹配时,列出支持的修复类型让用户选择

### Step 2:读取对应 reference,执行修复

根据 Step 1 的结果,读取对应的 reference 文件,按其中的规则和流程逐项处理。

各修复类型的详细步骤见:
→ `references/template-fix.md`(模板字符串修复)

### Step 3:输出修复摘要

修复完成后向用户报告:
- 修改了哪些文件的哪些行
- 是否存在规则未覆盖、需要人工处理的情况

references/template-fix.md 文件

# 模板字符串修复:详细规则与执行流程

## 核心目标

将含中文的模板字符串中的复杂嵌套表达式提取为局部 `const` 变量,让 must 工具可以自动识别整段模板字符串中的中文并替换。

## 处理规则

### 规则 1:只提取含中文的模板字符串

跳过不含中文字符的模板字符串(如纯 URL 拼接、纯数字拼接等)。

### 规则 2:提取嵌套表达式为 const

将 `${}` 中的**复杂表达式**(链式访问、函数调用、运算)提取到模板字符串**正上方**的 `const` 声明:

```typescript
// 处理前
const msg = `${data?.players1?.playersSeatNumber}号签位和${data?.players2?.playersSeatNumber}号签位`;

// 处理后
const playersOneSeatNumber = data?.players1?.playersSeatNumber;
const playersTwoSeatNumber = data?.players2?.playersSeatNumber;
const msg = `${playersOneSeatNumber}号签位和${playersTwoSeatNumber}号签位`;
```

### 规则 3:变量命名转换

提取变量名时:
- 取表达式**最后两个有语义的属性名**拼接(忽略 `data`、`item`、`res` 等纯容器名)
- **数字后缀转英文**:`1` → `One`,`2` → `Two`,`3` → `Three` ... `9` → `Nine`
- 使用 camelCase

```
data?.players1?.playersSeatNumber  →  playersOneSeatNumber
data?.players2?.playersSeatNumber  →  playersTwoSeatNumber
item?.config?.maxCount             →  configMaxCount
res?.data?.totalNum                →  dataTotalNum
name.toString()                    →  nameStr(函数调用,取调用前的变量名 + 语义后缀)
',' + name                         →  nameWithPrefix(运算,描述语义)
```

### 规则 4:简单变量不提取

`${}` 内已经是单一变量(无链式、无运算、无函数调用)则保持不变:

```typescript
// 不需要处理(${ name } 已是简单变量)
`你好${name}`
```

### 规则 5:提取位置

`const` 声明放在**包含该模板字符串的最近一个语句的正上方**,不要插入到函数参数列表内部。

## 执行流程

1. **读取待处理位置**
   - 来源:i18n-scan 源码扫描输出的 unprocessedCode(文件路径 + 行号 + 原始代码)
   - 或用户直接提供

2. **逐项处理**
   - 读取对应文件,定位到指定行
   - 识别模板字符串中的 `${}` 表达式
   - 按规则 2–5 提取变量,原地修改文件

3. **完成后告知用户**
   - 列出修改了哪些文件的哪些行
   - 提示可重新运行 i18n-scan 源码扫描确认 unprocessedCode 已清零

## 示例

**输入(i18n-scan 源码扫描输出):**
```
src/utils/api/uploadService.ts
  line 60:16  `你好${name.toString()}`
  line 61:16  `你好${',' + name}`
```

**处理结果:**
```typescript
// line 60 处理前
const greeting = `你好${name.toString()}`;

// line 60 处理后
const nameStr = name.toString();
const greeting = `你好${nameStr}`;

// line 61 处理前
const greeting2 = `你好${',' + name}`;

// line 61 处理后
const nameWithPrefix = ',' + name;
const greeting2 = `你好${nameWithPrefix}`;
```

 

再来看一下i18n-scan

目录结构

image

 SKILL.md文件

---
name: i18n-scan
description: 检查当前项目的国际化状态,分两个维度:① 源码扫描——找出源文件中尚未国际化的中文(支持限定目录);② locale 校验——检查 zh-HK / en 翻译文件是否与 zh-CN 完整对齐。两个维度均为只读操作。当用户说"检查国际化状态"、"有哪些中文没处理"、"预览中文"、"扫描哪些文件需要国际化"、"检查翻译 key"、"对比多语言文件"、"翻译文件有没有对齐"、"全面检查一下国际化"时使用。
---

# i18n Scan

两种只读扫描,覆盖国际化的不同阶段:

| 维度 | 扫描对象 | 回答的问题 |
|---|---|---|
| **源码扫描** | `.tsx / .ts` 源文件 | 哪些文件还有中文没被处理? |
| **locale 校验** | `.json` 语言资源文件 | 三语言翻译文件是否完整对齐? |

## 执行步骤

### Step 0:环境检查

**检查 must 工具是否已安装并配置:**

```bash
# 1. 检查 package.json 中是否有 @ali/parrot-tool-must 依赖
grep "@ali/parrot-tool-must" package.json

# 2. 检查 .must.config.js 配置文件是否存在
ls -la .must.config.js
```

**检查结果处理:**

- **must 未安装** (`grep` 无输出):
  
  询问用户:
  ```
  ⚠️ 未检测到 must 工具依赖
  
  i18n 转换需要 @ali/parrot-tool-must 工具支持。是否安装?
  
  安装命令:npm install --save-dev @ali/parrot-tool-must
  ```
  
  等待用户确认后执行安装。

- **配置文件缺失** (`ls` 报错):
  
  询问用户:
  ```
  ⚠️ 未检测到 .must.config.js 配置文件
  
  需要创建 must 工具配置文件才能使用 i18n 功能。是否创建?
  
  配置文件将包含:
  - 源码目录配置
  - i18next 方法定制
  - key 生成规则
  - 排除规则等
  ```
  
  若用户同意,读取 `.cursor/skills/i18n-scan/references/must.config.template.js` 模板,复制到项目根目录并命名为 `.must.config.js`。提示用户可根据项目实际情况调整配置(如 `name`、`exclude` 路径等)。

- **均已就绪**:直接进入 Step 1

### Step 1:判断运行哪个维度

| 用户意图 | 运行维度 |
|---|---|
| "哪些文件有中文没处理" / "预览中文" / "扫描源码" | 只运行源码扫描 |
| "翻译文件有没有对齐" / "检查 key 是否完整" | 只运行 locale 校验 |
| "全面检查国际化" / "检查国际化状态" | 两个维度都运行 |

### Step 2:运行源码扫描(按需,需通过 Step 0 检查)

从本文件所在目录(`.cursor/skills/i18n-scan/`)执行:

```bash
# 扫描整个 src 目录
node scripts/preview.js

# 限定目录(path 相对于项目根目录)
node scripts/preview.js --path=./src/pages/Home
```

**产出:**
- 按命名空间分组的待转换文件清单
- unprocessedCode:含复杂模板字符串的文件和行号,需人工介入

详细的命名空间推断规则和输出格式参考:
→ `references/preview.md`

### Step 3:运行 locale 校验(按需)

从本文件所在目录(`.cursor/skills/i18n-scan/`)执行:

```bash
node scripts/check.js
```

**产出:**
- 每个命名空间的 ✅ / ❌ 状态
- 缺少或多余的 key 列表
- 嵌套结构不一致的位置
- key 顺序提示(`·`,弱提示,不影响通过)

详细的输出解读和修复方式参考:
→ `references/check.md`

### Step 4:汇总并告知用户

将两个维度的扫描结果整理后输出给用户,不做任何修复:

- **源码扫描**:严格按照 `references/preview.md` Step 4 中描述的格式输出——按命名空间分组,列出公共目录、涉及文件数、推断方式的汇总表,并在表格后追加 unprocessedCode 列表(若有)
- **locale 校验**:严格按照 `references/check.md` Step 2 中描述的格式输出——直接呈现脚本原始输出内容,包括每个命名空间的 ✅ / ❌ 状态、缺失 / 多余 key 明细、结构不一致位置,以及 `·` 顺序提示

后续如何处理由用户决定。

references结构下:
check.md文件

# locale 校验:详细执行步骤

## Step 1:运行检查脚本

从 `.cursor/skills/i18n-scan/` 目录执行:

```bash
node scripts/check.js
```

脚本以 **zh-CN 为基准**,逐一对比 zh-HK 和 en,进行四类检查:

- **① 重复 key 检测**(前置扫描,独立于 ✅/❌ 判定):同一文件内是否存在同名 key
- **② key 完整性**:叶子节点 key 是否全部一致(缺少 / 多余)
- **③ 嵌套结构一致性**:`{ "a": { "b": "x" } }` 和 `{ "a.b": "x" }` 视为结构不同
- **④ key 顺序提示**(弱提示,不影响 ✅/❌ 判定):排列顺序是否与 zh-CN 一致

## Step 2:解读输出

```
🔍 i18n key 一致性检查 — 全部 40 个命名空间
基准语言:zh-CN 对比语言:zh-HK / en
────────────────────────────────────────────────────────────
  ✅  home
  ✅  liveList   · [en] 2 个 key 顺序与 zh-CN 不一致
  ⚠️  重复 key(zh-CN/orderSchedule.json):"schedule_updating"
  ⚠️  重复 key(zh-HK/orderSchedule.json):"schedule_updating"
  ⚠️  重复 key(en/orderSchedule.json):"schedule_updating"
  ✅  orderSchedule
  ❌  eventMain
      [en] 多余 1 个 key(不存在于 zh-CN):
              - eventcard.competition_application_under_review
      [en] 结构不一致(2 处):
              - "applying" 在 zh-CN 是嵌套对象,在 en 中为平铺 key
────────────────────────────────────────────────────────────
共检查 40 个命名空间:✅ 39 个一致,❌ 1 个存在差异,· 1 处顺序提示
```

### 重复 key 警告(`⚠️`)说明

`⚠️` 是独立的前置警告,**不影响命名空间的 ✅/❌ 判定**。原因:`JSON.parse()` 遇到重复 key 时会静默取最后一个值覆盖前者,导致检查脚本感知不到重复的存在,key 对齐检查仍然可能通过。

> **典型场景**:两次 i18n-transform 写入同一命名空间时,若发生追加而非合并,会引入重复 key。

重复 key 的危险性:
- 前一个 key 的翻译值被静默丢弃,可能导致翻译不符合预期
- 文件可读性差,维护时容易引发混乱
- 部分 JSON linter / 编辑器会直接报错

## Step 3:修复差异

**重复 key(`⚠️`,需优先处理):**
打开对应文件,搜索重复的 key 名,保留正确的一条,删除多余的。三语言文件需同步处理。

**缺少 key(最常见):**
在 `src/i18n/locales/<lang>/<namespace>.json` 中补充翻译。

**多余 key:**
- 有实际代码引用 → 不能删除,需在 zh-CN(及 zh-HK)补上对应翻译
- 无实际引用 → 直接从目标语言文件中删除

**结构不一致:**
统一改为与 zh-CN 相同的嵌套格式,将平铺点分 key 改写为嵌套对象。

**顺序提示(`·`):**
按 zh-CN 中的 key 排列顺序,调整目标语言文件中对应 key 的位置,便于 diff 对比维护。

**文件不存在:**
参考 i18n-transform skill 的 **Step 3(新命名空间初始化)**,创建对应语言的 JSON 文件并注册命名空间。

## Step 4:修复后重新验证

```bash
node scripts/check.js
```

确认全部输出为 ✅、无 `⚠️` 重复 key 警告、且无 `·` 顺序提示后完成。

must-config-template.md文件

# must.config.js 配置模板说明

这是 `@ali/parrot-tool-must` 工具的配置文件模板,用于 i18next 国际化转换。

## 主要配置项

### 基础配置

- **name**: 应用名称(需根据项目修改)
- **sourcePath**: 源码目录,默认 `./src`
- **fileType**: 扫描的文件类型,默认 `ts`(包含 .ts 和 .tsx)

### 排除规则

- **exclude**: 函数,返回 true 表示跳过该文件
  - 示例:排除 app.ts、document.tsx 等不需要国际化的文件
  - 支持字符串匹配和正则表达式

### macro 配置(i18next 定制)

- **path**: 语言文件位置,默认 `src/i18n/locales`
- **method**: i18next 调用方法,默认 `i18next.t("$key$")`
- **import**: 自动添加的 import 语句
- **keyGenerator**: key 生成规则
  - 从文件路径提取目录名
  - 从翻译文本提取有意义的英文单词(前3个)
  - 用 `.` 连接,如 `common.upload_success`

### 过滤规则

- **matchCopy**: 判断文案是否需要转换
  - 过滤非中文文本
  - 过滤 `console.log`、`Error`、`noI18n()` 等调用内的文案

## 使用方式

将此模板复制到项目根目录,命名为 `.must.config.js`,然后根据项目调整:

1. 修改 `name` 为实际应用名
2. 调整 `exclude` 中的排除路径
3. 根据需要调整 key 生成规则

## 参考

完整配置说明参考 must 工具官方文档。

must.config.template.js文件

const path = require('path');

/**
 * must 工具配置模板
 * 用于 i18next 国际化转换
 */

// 无意义的英文单词,不参与 key 生成
const NOT_MEANING_WORD = [
  'a',
  'an',
  'the',
  'of',
  'to',
  'in',
  'on',
  'at',
  'for',
  'with',
  'and',
  'or',
  'but',
  'is',
  'are',
  'was',
  'were',
  'be',
  'been',
  'has',
  'have',
  'had',
  'do',
  'does',
  'did',
  'will',
  'would',
  'can',
  'could',
  'should',
  'may',
  'might',
  'must',
  'shall',
  'it',
  'this',
  'that',
  'these',
  'those',
  'i',
  'you',
  'he',
  'she',
  'we',
  'they',
  'by',
  'from',
  'as',
  'into',
  'out',
  'up',
  'down',
];

// key 分隔符
const KEY_SPLITTER = '.';

// 中文正则
const CHINESE_REG = /[^\x00-\xff]/;

// 函数边界类型(停止向上查找的节点类型)
const BOUNDARY_TYPES = new Set([
  'Program',
  'FunctionDeclaration',
  'FunctionExpression',
  'ArrowFunctionExpression',
  'ClassMethod',
  'ObjectMethod',
]);

/**
 * 判断当前 AST 节点是否在指定函数调用内
 * 用于过滤不需要国际化的字符串,如 console.log('中文')
 */
function isInExcludedCall(astPath, excludeSet) {
  let cur = astPath.parentPath;
  while (cur) {
    const { type, node } = cur;
    if (BOUNDARY_TYPES.has(type)) return false;
    if (type === 'CallExpression' || type === 'NewExpression') {
      const { callee } = node;
      if (callee.type === 'MemberExpression') return excludeSet.has(callee.object?.name);
      if (callee.type === 'Identifier') return excludeSet.has(callee.name);
      return false;
    }
    cur = cur.parentPath;
  }
  return false;
}

module.exports = {
  // 工作目录
  cwd: './',

  // 应用名(根据项目修改)
  name: 'my-app',

  // 提取的源码目录
  sourcePath: './src',

  // 排除的文件/目录列表(根据项目调整)
  exclude: path => {
    const excludePaths = [
      '/src/app.ts',
      '/src/document.tsx',
      // 添加其他不需要国际化的文件路径
    ];
    if (excludePaths.some(p => (p instanceof RegExp ? p.test(path) : path.includes(p))))
      return true;
    return false;
  },

  // 提取文件类型
  fileType: 'ts',

  macro: {
    // 适配 i18next 的 placeholder 模板
    placeholder: variable => {
      return `{{${variable}}}`;
    },

    // 语言配置文件数据位置
    path: 'src/i18n/locales',

    // 定制 i18next 函数方法
    method: 'i18next.t("$key$")',

    // 定制 i18next 函数引入
    import: 'import i18next from "i18next";',

    // 是否将原始文案展示为注释
    implement: false,

    // key 生成器规则
    keyGenerator: (copy, filePath, config) => {
      const enCopy = copy['en-US'];

      // 提取有意义的英文单词
      const words = enCopy.split(/[^a-zA-Z]/i);
      const validWords = words.filter(w => /^[\w|\d]+$/i.test(w)).map(w => w.toLowerCase());

      // 过滤无意义词
      const namedWords = validWords.filter(w => !NOT_MEANING_WORD.includes(w));

      // 如果过滤后为空,但过滤前有值,则回退到过滤前的结果
      let finalWords = namedWords.length > 0 ? namedWords : validWords;
      if (finalWords.length === 0) {
        finalWords = ['unnamed_content'];
      }

      const keyArray = [];

      const relativeFilePath = filePath.replace(`${config.cwd}/`, '');
      const parsedPath = path.parse(relativeFilePath);
      const dirPart = parsedPath.dir
        .split('/')
        .filter(p => !!p && p !== 'index' && p !== 'src')
        .slice(-1) // 只取最后一层目录
        .map(p => p.toLowerCase());

      // 添加目录名(如果有)
      if (dirPart.length > 0) {
        keyArray.push(...dirPart);
      }

      // 添加翻译文本(取前3个有意义单词,用下划线连接)
      keyArray.push(finalWords.slice(0, 3).join('_'));

      return keyArray.join(KEY_SPLITTER);
    },
  },

  // 匹配需要转换的文案(过滤规则)
  matchCopy: (text, astPath) => {
    // 过滤非中文
    if (!CHINESE_REG.test(text) || text === '、') return null;

    // 过滤不需要转换的函数调用
    if (
      isInExcludedCall(
        astPath,
        new Set([
          'noI18n', // 显式标记不需要国际化
          'console', // 控制台输出
          'Error', // 错误信息
        ]),
      )
    )
      return null;

    return text;
  },

  // 是否上传到美杜莎(内部系统)
  isNeedUploadCopyToMedusa: false,
};

preview.md文件

# 源码扫描:详细执行步骤

## Step 1:运行预览脚本

从 `.cursor/skills/i18n-scan/` 目录执行:

```bash
# 全量扫描
node scripts/preview.js

# 指定目录(path 相对于项目根目录)
node scripts/preview.js --path=./src/pages/Home
```

## Step 2:解析输出

从脚本输出中提取:
- **changedFiles**:会被自动修改的文件列表
- **unprocessedCode**:含模板字符串中文、需手动处理的文件和行号

## Step 3:为每个文件推断命名空间

**规则优先级(从高到低):**

1. **查文件内已有的 `i18next.t()` 调用**
   - 提取第一个参数中冒号前的部分:`i18next.t('home:xxx')` → `home`

2. **查同层目录其他文件的 `i18next.t()` 调用**
   - 取出现频率最高的命名空间

3. **按路径规则推断**(以上均未找到时)

   | 文件路径 | 命名空间规则 | 示例 |
   |---|---|---|
   | `src/pages/<Page>/...` | 页面名首字母小写 | `src/pages/Home/` → `home` |
   | `src/pages/<A>/<B>/...` | 取第二段,首字母小写 | `src/pages/cMatch/MyMatch/` → `myMatch` |
   | `src/components/`、`src/hooks/`、`src/utils/`、`src/common/` 等非 pages 目录 | 统一使用 `common` | — |

   > **⚠️ 路径规则是最后兜底**:同一目录下若已有其他文件使用了某个命名空间(如 `match/common/` 下的文件都用 `matchCommon`),优先级 2 会直接取到正确结果。

## Step 4:输出汇总

**按命名空间分组**,推算同组文件的**最长公共目录**- 取各文件所在目录,逐段对比,取共同最长前缀
- **`common` 命名空间例外**:若文件来自 `src/utils`、`src/hooks`、`src/common` 等不同目录,前缀退化到 `src` 没有意义,改为各写一行,不合并

```
| 命名空间 | 公共目录 | 涉及文件数 | 推断方式 |
|---|---|---|---|
| home | src/pages/Home | 2 | 路径推断 |
| matchCommon | src/pages/match/common | 3 | 已有用法 |
| common | src/utils | 2 | 非 pages 目录(分开列) |
| common | src/hooks | 1 | 非 pages 目录(分开列) |
```

若存在 **unprocessedCode**,在表格后追加:

```
⚠️ 以下文件含模板字符串中文,需先使用 [i18n-fix] skill 处理后再重新扫描:
  - src/utils/api/uploadService.ts(line 60, 61)
```

处理完模板字符串后,重新执行 Step 1–4 更新结果。

scripts结构下:
preview.js文件

/**
 * JSX 国际化预览脚本(只读,不修改源文件)
 * 运行后返回哪些文件会被修改,不展示具体 diff
 *
 * 原理:debug: true 时工具把替换结果写入 .debug.xxx,只有真正有内容替换才会生成,
 * 遍历源文件检查 .debug 文件是否存在即可判断该文件是否会被修改,最后清理 debug 文件。
 */
const path = require('path');
const fs = require('fs-extra');

const { extract } = require('@ali/parrot-tool-must');

const { parseArgs } = require('./utils/parse-args');
const baseConfig = require(path.join(__dirname, '../../../../.must.config.js'));
// 始终以项目根目录为工作目录,与执行位置无关
const cwd = path.resolve(__dirname, '../../../..');

/**
 * 预览哪些文件会被国际化修改
 * @param {string[]} files 文件绝对路径列表
 * @return {Promise<{changedFiles: string[], unprocessedCode: object}>}
 */
async function preview(files, config) {
  try {
    const { unprocessedCode } = await extract.js.parse(files, config);

    // debug 文件存在 = 该文件有可自动替换的中文
    const changedFiles = files.filter(filePath => {
      const p = path.parse(filePath);
      return fs.existsSync(path.join(p.dir, `${p.name}.debug${p.ext}`));
    });

    // unprocessedCode 的文件含有模板字符串中文,需手动处理,也属于受影响文件
    const unprocessedFiles = Object.keys(unprocessedCode || {});

    // 合并去重(同一文件可能既有自动替换又有模板字符串)
    const allChanged = [...new Set([...changedFiles, ...unprocessedFiles])];

    const relativeUnprocessed = {};
    Object.entries(unprocessedCode || {}).forEach(([filePath, items]) => {
      relativeUnprocessed[path.relative(cwd, filePath)] = items;
    });

    return {
      changedFiles: allChanged.map(f => path.relative(cwd, f)),
      unprocessedCode: relativeUnprocessed,
    };
  } finally {
    // 清理 debug 文件
    files.forEach(filePath => {
      const p = path.parse(filePath);
      fs.removeSync(path.join(p.dir, `${p.name}.debug${p.ext}`));
    });
  }
}

async function run() {
  const params = parseArgs();
  const targetPath = params.path || params.dir;
  const config = {
    ...baseConfig,
    cwd, // 覆盖 baseConfig.cwd('./'),避免 must 工具在 skills 目录下创建多余的 src 目录
    ...(targetPath ? { sourcePath: targetPath } : {}),
    debug: true,
  };

  const files = extract.findFiles({ ...config, cwd });
  const { changedFiles, unprocessedCode } = await preview(files, config);

  console.log(`共 ${changedFiles.length} 个文件会被修改:`);
  changedFiles.forEach(f => console.log(`  - ${f}`));
  if (Object.keys(unprocessedCode).length > 0) {
    console.log('\n以下模板字符串需手动处理:');
    Object.entries(unprocessedCode).forEach(([filePath, items]) => {
      console.log(`  ${filePath}`);
      items.forEach(({ line, column, code }) => {
        console.log(`    line ${line}:${column}  ${code}`);
      });
    });
  }
}

run();

check.js文件

/**
 * i18n 三语言 key 一致性检查脚本
 *
 * 以 zh-CN 为基准,检查 zh-HK 和 en 是否与其完全一致,包含三类检查:
 *   1. key 一致性:缺少或多余的叶子 key
 *   2. 结构一致性:{ a: { b: "x" } } 与 { "a.b": "x" } 被视为结构不同
 *   3. 顺序提示(弱提示):叶子 key 的排列顺序与 zh-CN 不一致
 *
 * 用法(从本文件所在目录执行):
 *   node scripts/check.js    # 检查全部命名空间
 */

const path = require('path');
const fs = require('fs');

const LOCALES_DIR = path.resolve(__dirname, '../../../../src/i18n/locales');
const LANGS = ['zh-CN', 'zh-HK', 'en'];

/**
 * 递归遍历 JSON,同时收集:
 *   - leaves:所有叶子节点的路径(点分隔,用于 key 一致性检查,保留插入顺序)
 *   - branches:所有中间对象节点的路径(用于结构一致性检查)
 *
 * 注意:路径中的点来自 JSON 层级拼接,与 key 名称中本身含有的点共用同一符号。
 * 只要两个文件使用相同拼接规则,对比结果就是准确的。
 */
function extractStructure(obj, prefix = '') {
  const leaves = [];
  const branches = [];

  for (const [k, v] of Object.entries(obj)) {
    const fullKey = prefix ? `${prefix}.${k}` : k;
    if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
      branches.push(fullKey);
      const child = extractStructure(v, fullKey);
      leaves.push(...child.leaves);
      branches.push(...child.branches);
    } else {
      leaves.push(fullKey);
    }
  }

  return { leaves, branches };
}

/**
 * 检查两个有序 key 列表的相对顺序是否一致(只对比共同存在的 key)
 * 返回顺序不一致的 key 数量,0 表示顺序相同
 */
function checkOrder(baseLeaves, langLeaves) {
  const langSet = new Set(langLeaves);
  const baseSet = new Set(baseLeaves);

  // 取各自文件中共同 key 的排列序列
  const commonInBase = baseLeaves.filter(k => langSet.has(k));
  const commonInLang = langLeaves.filter(k => baseSet.has(k));

  if (commonInBase.join('\0') === commonInLang.join('\0')) return 0;

  // 计算有多少个 key 位置与 zh-CN 不同
  let diffCount = 0;
  for (let i = 0; i < commonInBase.length; i++) {
    if (commonInBase[i] !== commonInLang[i]) diffCount++;
  }
  return diffCount;
}

/**
 * 扫描 JSON 原始文本中的重复 key,按对象作用域分别检测
 * 用栈跟踪嵌套层级,仅在同一父对象内出现两次才视为重复
 * 返回重复 key 数组,无重复则返回空数组
 */
function findDuplicateKeys(rawText) {
  const duplicates = [];
  // 每个栈帧对应一个 JSON 对象作用域,存储该作用域内已见过的 key
  const stack = [new Set()];

  for (const line of rawText.split('\n')) {
    const trimmed = line.trim();
    if (!trimmed) continue;

    // 先处理 } —— 离开当前作用域
    if (trimmed.startsWith('}')) {
      if (stack.length > 1) stack.pop();
    }

    // 检测当前行的 key(仅匹配行首的 "key": 形式)
    const keyMatch = trimmed.match(/^"([^"]+)"\s*:/);
    if (keyMatch) {
      const key = keyMatch[1];
      const currentSet = stack[stack.length - 1];
      if (currentSet.has(key)) {
        if (!duplicates.includes(key)) duplicates.push(key);
      } else {
        currentSet.add(key);
      }
    }

    // 后处理 { —— 进入新作用域
    if (trimmed.endsWith('{')) {
      stack.push(new Set());
    }
  }

  return duplicates;
}

/**
 * 读取并解析指定语言 + 命名空间的 JSON 文件,失败返回 null
 * 同时检测文件中是否存在重复 key(JSON.parse 会静默合并,需要提前扫描)
 */
function readJSON(lang, namespace) {
  const filePath = path.join(LOCALES_DIR, lang, `${namespace}.json`);
  if (!fs.existsSync(filePath)) return null;
  try {
    const raw = fs.readFileSync(filePath, 'utf-8');
    const duplicates = findDuplicateKeys(raw);
    if (duplicates.length > 0) {
      console.error(
        `  ⚠️  重复 key(${lang}/${namespace}.json):${duplicates.map(k => `"${k}"`).join(', ')}`,
      );
    }
    return JSON.parse(raw);
  } catch (e) {
    console.error(`  ⚠️  解析失败:${filePath}\n     ${e.message}`);
    return null;
  }
}

/**
 * 检查单个命名空间
 * @returns {{ ok: boolean, issues: string[], orderHints: string[] }}
 */
function checkNamespace(namespace) {
  const issues = [];
  const orderHints = [];

  const baseJSON = readJSON('zh-CN', namespace);
  if (baseJSON === null) {
    return {
      ok: false,
      issues: [`zh-CN/${namespace}.json 不存在`],
      orderHints,
    };
  }
  const base = extractStructure(baseJSON);
  const baseLeaves = new Set(base.leaves);
  const baseBranches = new Set(base.branches);

  for (const lang of ['zh-HK', 'en']) {
    const langJSON = readJSON(lang, namespace);

    if (langJSON === null) {
      issues.push(`[${lang}] 文件不存在:${lang}/${namespace}.json`);
      continue;
    }

    const lang_ = extractStructure(langJSON);
    const langLeaves = new Set(lang_.leaves);
    const langBranches = new Set(lang_.branches);

    // ── 1. key 一致性(叶子节点) ──────────────────────────────
    const missingLeaves = [...baseLeaves].filter(k => !langLeaves.has(k));
    const extraLeaves = [...langLeaves].filter(k => !baseLeaves.has(k));

    if (missingLeaves.length > 0) {
      issues.push(
        `[${lang}] 缺少 ${missingLeaves.length} 个 key(存在于 zh-CN):\n` +
          missingLeaves.map(k => `        - ${k}`).join('\n'),
      );
    }
    if (extraLeaves.length > 0) {
      issues.push(
        `[${lang}] 多余 ${extraLeaves.length} 个 key(不存在于 zh-CN):\n` +
          extraLeaves.map(k => `        - ${k}`).join('\n'),
      );
    }

    // ── 2. 结构一致性(对象节点 vs 平铺点分 key) ─────────────
    const structDiffs = [];

    // zh-CN 是嵌套对象,lang 用了平铺 key(branch 消失)
    for (const b of baseBranches) {
      if (!langBranches.has(b)) {
        structDiffs.push(`"${b}" 在 zh-CN 是嵌套对象,在 ${lang} 中为平铺 key`);
      }
    }
    // lang 是嵌套对象,zh-CN 用了平铺 key(lang 多出 branch)
    for (const b of langBranches) {
      if (!baseBranches.has(b)) {
        structDiffs.push(`"${b}" 在 ${lang} 是嵌套对象,在 zh-CN 中为平铺 key`);
      }
    }

    if (structDiffs.length > 0) {
      issues.push(
        `[${lang}] 结构不一致(${structDiffs.length} 处):\n` +
          structDiffs.map(d => `        - ${d}`).join('\n'),
      );
    }

    // ── 3. 顺序提示(弱提示,不影响 ok 状态) ─────────────────
    const diffCount = checkOrder(base.leaves, lang_.leaves);
    if (diffCount > 0) {
      orderHints.push(`[${lang}] ${diffCount} 个 key 顺序与 zh-CN 不一致`);
    }
  }

  return { ok: issues.length === 0, issues, orderHints };
}

/**
 * 从 zh-CN 目录获取所有命名空间名称
 */
function getAllNamespaces() {
  const dir = path.join(LOCALES_DIR, 'zh-CN');
  if (!fs.existsSync(dir)) {
    console.error(`目录不存在:${dir}`);
    return [];
  }
  return fs
    .readdirSync(dir)
    .filter(f => f.endsWith('.json'))
    .map(f => f.replace('.json', ''))
    .sort();
}

function run() {
  const namespaces = getAllNamespaces();

  if (namespaces.length === 0) {
    console.log('未找到任何命名空间文件');
    return;
  }

  const scope = `全部 ${namespaces.length} 个命名空间`;

  console.log(`\n🔍 i18n key 一致性检查 — ${scope}`);
  console.log(`基准语言:zh-CN 对比语言:zh-HK / en`);
  console.log(`检查内容:key 完整性 + 嵌套结构一致性 + 顺序提示`);
  console.log('─'.repeat(60));

  let okCount = 0;
  const failList = [];
  let totalOrderHints = 0;

  for (const ns of namespaces) {
    const { ok, issues, orderHints } = checkNamespace(ns);
    if (ok) {
      const orderSuffix = orderHints.length > 0 ? `   · ${orderHints.join('  ')}` : '';
      console.log(`  ✅  ${ns}${orderSuffix}`);
      okCount++;
      totalOrderHints += orderHints.length;
    } else {
      console.log(`  ❌  ${ns}`);
      issues.forEach(issue => {
        issue.split('\n').forEach(line => console.log(`      ${line}`));
      });
      if (orderHints.length > 0) {
        orderHints.forEach(hint => console.log(`      · ${hint}`));
        totalOrderHints += orderHints.length;
      }
      failList.push(ns);
    }
  }

  console.log('─'.repeat(60));
  const orderNote = totalOrderHints > 0 ? `,· ${totalOrderHints} 处顺序提示` : '';
  console.log(
    `\n共检查 ${namespaces.length} 个命名空间:` +
      `✅ ${okCount} 个一致` +
      (failList.length > 0 ? `,❌ ${failList.length} 个存在差异` : '') +
      orderNote,
  );

  if (failList.length > 0) {
    process.exit(1);
  }
}

run();

utils/parse-args.js文件

/**
 * 解析命令行参数,支持 --key=value 格式
 */
function parseArgs() {
  const args = process.argv.slice(2);
  const params = {};
  args.forEach(arg => {
    if (arg.startsWith('--')) {
      const [key, value] = arg.substring(2).split('=');
      params[key] = value;
    }
  });
  return params;
}

module.exports = { parseArgs };

 

最后是执行的i18n-transform

目录结构:

image

SKILL.md文件

---
name: i18n-transform
description: 将指定目录下的中文文案提取并转换为 i18next 调用,同时自动处理转换后可能产生的问题(模块顶层初始化、中文比较、TypeScript 类型支持、重复翻译合并),最终补全 zh-HK / en 翻译资源。当用户说"转换中文"、"提取 i18n"、"运行 must 转换"、"国际化某个目录"时使用。必须指定 --path 和 --namespace 参数。可携带翻译对照表(zh-CN / zh-HK / en 三列 Markdown 表格)用于自动补全多语言,未携带则仅输出缺失清单供人工处理。
---

# i18n Transform

## 前置检查

### 1. 环境检查(必须通过)

**在执行任何转换操作前,必须先调用 i18n-scan skill 的 Step 0 进行环境检查:**

从 `.cursor/skills/i18n-scan/` 目录执行环境检查:

```bash
# 1. 检查 must 工具是否安装
grep "@ali/parrot-tool-must" package.json

# 2. 检查配置文件是否存在
ls -la .must.config.js
```

**若检查失败(must 未安装或配置缺失):**

1. 停止执行当前 i18n-transform 流程
2. 提示用户环境未就绪:
   ```
   ❌ 环境未就绪,无法执行 i18n 转换
   
   缺失:<must 工具 / 配置文件>
   
   请先完成环境配置,或使用 i18n-scan skill 自动检查和配置环境。
   ```
3. 等待用户解决环境问题后重新开始

**环境检查通过后,继续执行下方"翻译对照表"和后续步骤。**

### 2. 翻译对照表

本 skill 支持接收一份 **翻译对照表**,用于 Step 8 自动补全 zh-HK / en 翻译资源。格式为三列 Markdown 表格:

```
| zh-CN | zh-HK | en |
|---|---|---|
| 上传成功 | 上傳成功 | Upload successful |
| 昨天 | 昨天 | Yesterday |
```

**若用户未提供对照表:** 在环境检查通过后主动询问用户:

> "是否提供翻译对照表?提供后可在 Step 8 自动补全 zh-HK / en 翻译,否则仅输出缺失清单供人工处理。"

若用户明确表示不提供,则跳过询问直接执行,Step 8 按无对照表模式处理。

## 执行步骤

### Step 1:运行转换脚本

从本文件所在目录(`.claude/skills/i18n-transform/`)执行:

```bash
node scripts/transform.js --path=<目标目录> --namespace=<命名空间>
```

**参数说明:**

- `--path`:要转换的目录或文件路径,相对于项目根目录,例如 `./src/utils` 或 `./src/pages/Home`
- `--namespace`:i18next 命名空间,例如 `common`、`home`、`matchCommon`

**示例:**

```bash
node scripts/transform.js --path=./src/utils --namespace=common
node scripts/transform.js --path=./src/pages/Home --namespace=home
```

脚本会:

1. 提取目标目录下所有文件中的中文文案
2. 生成 i18next 调用(`i18next.t("<namespace>:<key>")`)并写回源文件
3. 将提取的 zh-CN 语言资源合并写入 `src/i18n/locales/zh-CN/<namespace>.json`
4. 在终端输出:提取 key 数、`failList`、以及需要手动处理的 `unprocessedCode`

### Step 2:检查 unprocessedCode

脚本输出中若有 `unprocessedCode`,说明存在模板字符串无法自动转换,需要先使用 **i18n-fix** skill 处理后再重新执行 Step 1。

```
⚠️  以下模板字符串需手动处理(unprocessedCode):
  src/utils/api/uploadService.ts
    line 60:16  `你好${name.toString()}`
```

### Step 3:新命名空间初始化(仅新命名空间)

检查 `src/i18n/locales/en/` 下是否已存在该命名空间的 JSON 文件:

```bash
ls src/i18n/locales/en/<namespace>.json
```

若文件不存在,说明是新命名空间,需创建 en / zh-HK 语言文件并在页面入口注册命名空间。详细操作参考: → `references/post-transform-issues.md` **Step 0**

### Step 4:TypeScript 类型支持(仅新命名空间)

检查 `src/global.d.ts` 是否已包含该命名空间:

```bash
grep -n "<namespace>" src/global.d.ts
```

若无结果,说明是新命名空间,需追加类型声明。详细操作参考: → `references/post-transform-issues.md` **Step 1**

### Step 5:修复模块顶层初始化问题

脚本转换会将顶层常量对象/数组中的中文替换为 `i18next.t()`,但这会在模块加载时报错(命名空间尚未加载)。

使用以下命令扫描可能有问题的位置:

```bash
git diff --name-only | xargs grep -n "i18next.t("
```

**判断是否需要 getter:** 仅当代码位于文件顶层(所有函数/类之外)且在对象属性或数组元素中时才需要。

> **⚠️ 注意 enum 场景**:扫描时若发现 `enum` 成员被替换为 `i18next.t()` 调用(TypeScript 编译错误),直接还原为原始字符串字面量并标注 `TODO-i18n`,引用该 enum 的顶层展示 Map/Options 按正常 getter 模式处理即可。

详细修复模式(含 enum 子场景)参考: → `references/post-transform-issues.md` **Step 2**

### Step 6:处理中文比较问题

must 工具会将所有中文字符串替换为 `i18next.t()`,但部分中文字符串用于逻辑比较(如 `===`)或作为对象 key 访问,转换后会产生运行时错误。

使用以下命令扫描:

```bash
# 相等性比较
git diff --name-only | xargs grep -n "i18next\.t(" | grep -E "===|==|!==|!="

# 对象属性访问
git diff --name-only | xargs grep -n "\[i18next\.t("

# 字符串方法(⚠️ 容易遗漏)
git diff --name-only | xargs grep -n "i18next\.t(" | grep -E "\.indexOf\(|\.includes\(|\.startsWith\(|\.endsWith\(|\.match\(|\.search\(|\.split\(|\.replace\(|\.replaceAll\(|\.localeCompare\("
```

**处理策略(按数据来源区分):**

- 前端定义的数据 → 添加唯一 `key` 字段,改用 key 比较
- 后端返回的中文 → 还原为中文字符串比较,渲染时使用动态翻译
- 对象属性访问 → 还原为中文字符串

详细修复模式参考: → `references/post-transform-issues.md` **Step 3**

#### 6a:清理被还原的孤立翻译 key

**每当将 `i18next.t()` 还原为中文字符串时,脚本写入 JSON 的对应 key 就变成了孤立 key(代码中已无引用),必须从语言文件中手动删除。**

操作方式:对上一步每条"已还原"的记录,找到其对应的 i18next key,从 `src/i18n/locales/zh-CN/<namespace>.json` 中删除该条目。

示例:

```
还原:name3.split(i18next.t('common:api.unnamed_content')) → name3.split('|')
→ 需从 common.json 中删除孤立 key:api.unnamed_content
```

#### 6b:输出修复摘要(必须输出)

处理完成后,**必须**向用户输出本步骤完整摘要,格式如下:

```
📋 Step 6 中文比较问题处理结果:

  已处理(共 N 处):
    - src/pages/Home/index.tsx:84   [相等性比较] name === i18next.t(...) → 改用 value 比较
    - src/utils/common.ts:120       [对象属性访问] publicIcon[i18next.t(...)] → publicIcon['自定义']
    - src/utils/api/foo.ts:79       [字符串方法-还原] name3.split(i18next.t(...)) → name3.split(noI18n('|'))
                                    └─ 已从 common.json 删除孤立 key: api.unnamed_content

  ⚠️  以上所有处理均需人工验证(共 N 处):
    - src/pages/Home/index.tsx:84   请确认 value 比较逻辑覆盖所有分支,无遗漏边界情况
    - src/utils/common.ts:120       请确认对象 key 确实来自服务端中文,且 noI18n 包裹位置正确
    - src/utils/api/foo.ts:79       请确认还原后逻辑正确,孤立 key 已从翻译文件中删除

  ⚠️  还需人工处理(共 N 处):
    - src/pages/Home/index.tsx:210  [字符串方法] text.split(i18next.t(...)) 数据来源不明,已标注 TODO-i18n
    - src/utils/api/bar.ts:45       [相等性比较] 前端数据结构复杂,建议人工确认 key 字段是否完整覆盖
```

> **⚠️ 强制规范**>
> 1. **绝不能**输出"⚠️ 需人工跟进(共 0 处):无"。凡是本步骤做过任何修改(含自动修复),都必须在"以上所有处理均需人工验证"中逐条列出,要求人工逐一核查。
> 2. 自动修复不等于正确——数据来源判断、边界情况、孤立 key 清理均可能出错,必须人工兜底。
> 3. 代码中已标注 `TODO-i18n` 的位置必须告知用户,方便后续跟进。

若本步骤扫描结果为空(无任何中文比较),输出"✅ Step 6:未发现中文比较问题"即可。

### Step 7:合并重复翻译

转换后 `<namespace>.json` 中可能存在多个相同中文值,将它们提取到 `common` 对象统一管理。

```bash
cat src/i18n/locales/zh-CN/<namespace>.json | grep -oP '": "\K[^"]+' | sort | uniq -d
```

⚠️ 批量替换时务必使用精确匹配,避免误替换语义不同的 key。

详细步骤参考: → `references/post-transform-issues.md` **Step 4**

### Step 8:补全 zh-HK / en 翻译资源

从 `.cursor/skills/i18n-scan/` 目录运行 locale 校验,关注所有命名空间的结果(重点处理当前命名空间):

```bash
node scripts/check.js
```

检查结果分三类,处理优先级从高到低:

**① 结构不一致(❌,须修复)** 将目标语言文件中平铺的点分 key 改写为与 zh-CN 相同的嵌套对象格式。

**② key 顺序提示(·,建议修复)** 按 zh-CN 中的 key 排列顺序,调整目标语言文件中对应 key 的位置,保持三语言文件 diff 对比友好。

**③ 缺失 key(最常见)**

- **有翻译对照表**:按对照表逐 key 查找并写入,找不到的收集为"无法补全"列表,最后二次运行 check 验证,输出补全摘要
- **无翻译对照表**:将缺失 key 及其 zh-CN 值整理后告知用户,不做任何翻译

详细流程及输出格式参考: → `references/fill-translations.md`

全部处理完成后再次运行 i18n-scan locale 校验,确认当前命名空间输出 ✅ 且无 `·` 顺序提示。详细步骤参考: → `.cursor/skills/i18n-scan/references/check.md`

---

### Step 9:全面巡检与收尾

Step 8 完成后,运行 i18n-scan 两个维度做全量收尾检查:

```bash
# 1. 源码扫描:确认是否还有未转换的中文
node .cursor/skills/i18n-scan/scripts/preview.js --path=<本次转换的目录>

# 2. locale 校验:确认所有命名空间对齐
node .cursor/skills/i18n-scan/scripts/check.js
```

#### 9a:处理源码扫描结果

**⚠️ 重要原则:禁止 AI 自行判断"不需要转换"**

只要源码扫描发现中文,**必须**以警告形式向用户报告,不得自行判断为"不需要转换"就跳过。即使是以下场景也必须报告:

- enum 注释中的中文
- 模板字符串中的中文(即使是纯 URL)
- 代码注释中的中文
- 任何其他形式的中文

**报告格式(强制要求):**

```
⚠️ 源码扫描发现残留中文(共 N 处):

必须人工确认是否需要处理:
  1. src/apis/superMatch/group.type.ts
     - line 4:7    /** 单打 */          [enum 注释]
     - line 6:7    /** 双打 */          [enum 注释]
     - line 223    '不限'               [enum 值/后端标识]

  2. src/pages/match/TennisAllVideo/index.tsx
     - line 177:12 // 网球分享打开报告页   [代码注释]

说明:
- enum 注释:用于代码文档,通常不需要国际化,但如果展示给用户则需要处理
- enum 值:如果是后端返回的数据标识,不能修改;如果是前端展示文本,需要国际化
- 代码注释:通常不需要国际化,除非是展示给用户的注释
- 模板字符串:即使是 URL,如果包含中文参数或注释也需要确认

👉 请确认以上内容是否需要国际化处理。
```

**若源码扫描仍发现待转换的中文,判断是否重新执行:**

重新执行前,记录本轮已处理的文件列表,与上一轮对比:

- **新增文件出现**:正常情况,继续执行 Step 1
- **与上一轮完全相同的文件再次出现**:说明这些文件存在无法自动处理的特殊情况,**停止重试**,以警告形式列出文件和具体行号、中文内容
- **已连续执行 3 轮仍有剩余**:无论是否有进展,**停止重试**,以警告形式输出当前剩余文件清单

#### 9b:处理 locale 校验结果

**若 locale 校验存在 ❌ 差异:** 重新执行 Step 8 处理,不计入重试轮次。

#### 9c:输出最终摘要

**当 locale 校验全部通过后,输出最终摘要:**

```
✅ i18n-transform 完成

转换目录:<path>
命名空间:<namespace>
执行轮次:N 轮

locale 校验:✅ 全部命名空间对齐

源码扫描结果:
  ✅ 无残留中文

  或

  ⚠️ 发现 N 处中文待确认(详见上方警告列表)
     - <文件1>:N 处
     - <文件2>:N 处

⚠️  Step 6 需人工验证(如有):
  - <文件>:<行号>  <修复内容> - 请人工验证逻辑正确性
```

> **强制规范**>
> 1. **绝不能**输出"源码扫描:✅ 无残留中文"而实际上存在中文(即使是注释或特殊场景)
> 2. 所有残留中文必须以警告形式列出,附上文件路径、行号、具体内容和可能的类型标注
> 3. 由用户最终决定是否需要处理,AI 只负责报告,不做判断

references结构下:
fill-translations.md文件

# 补全 zh-HK / en 翻译资源

## 有翻译对照表

### 匹配规则

对照表通常只含**纯中文文本**映射,不含插值变量。因此匹配前需先判断:

**① zh-CN 值含有 `{{...}}` 插值变量**
- 对照表中不会存在此类条目,**直接归入"无法补全"列表**,不尝试匹配
- 示例:`"你好{{name}}"` / `"今日{{totalNum}}场比赛"` → 无法从对照表补全,需人工翻译

**② zh-CN 值为纯文本**
- 在对照表中按 zh-CN 值精确查找
- 找到 → 写入;未找到 → 归入"无法补全"列表,**不自动翻译**

### en 翻译的插值空格规范

当人工补充含插值变量的 en 翻译时(不在本 skill 自动处理范围内,仅作提醒),须遵守:

- 变量与相邻文字之间加空格:`"{{totalNum}} matches today"` ✅,`"{{totalNum}}matches today"` ❌
- 冒号等标点后、变量前加空格:`"Next opponent: {{name}}"` ✅,`"Next opponent:{{name}}"` ❌

### 输出摘要

写入完成后再次运行 i18n-check 验证,输出摘要:

```
📋 Step 8 翻译补全结果(命名空间:<namespace>):

  ✅ 已从对照表补全(共 N 个 key):
    - zh-HK: api.uploaded_successfully → "上傳成功"
    - en:    api.uploaded_successfully → "Upload successful"

  ⚠️  无法补全,需人工翻译(共 N 个 key):
    - api.hello_name          zh-CN: "你好{{name}}"          [含插值变量,对照表不含此类条目]
    - utils.pdf_document      zh-CN: "PDF文档"               [对照表中未找到]

  ✅ i18n-check 二次验证:<namespace> 通过 / 仍有 N 处差异(见上方缺失项)
```

## 无翻译对照表

将缺失 key 及其 zh-CN 值整理后告知用户,不做任何翻译:

```
📋 Step 8 缺失翻译清单(命名空间:<namespace>):

  [zh-HK 缺少]
    - api.uploaded_successfully    zh-CN: "上传成功"
    - utils.yesterday              zh-CN: "昨天"

  [en 缺少]
    - api.uploaded_successfully    zh-CN: "上传成功"
    - utils.yesterday              zh-CN: "昨天"

  请补充后重新运行 i18n-check 验证。
```

post-transform-issues.md文件

# 转换后问题处理指引

## Step 0:新命名空间初始化

**仅在命名空间为新增时执行**。判断方式:检查 `src/i18n/locales/en/` 下是否已存在该文件:

```bash
ls src/i18n/locales/en/<namespace>.json
```

### 0a:创建 en 和 zh-HK 语言文件

转换脚本已自动创建 `zh-CN/<namespace>.json`,还需手动创建另外两个语言的空文件:

```bash
echo '{}' > src/i18n/locales/en/<namespace>.json
echo '{}' > src/i18n/locales/zh-HK/<namespace>.json
```

### 0b:在页面入口文件注册命名空间

在 `--path` 对应目录的入口文件(通常是 `index.tsx`)的**组件定义之前**添加 `loadNamespaceInternal` 调用:

```typescript
import zhCN from '@/i18n/locales/zh-CN/<namespace>.json';
import zhHK from '@/i18n/locales/zh-HK/<namespace>.json';
import en from '@/i18n/locales/en/<namespace>.json';
import { loadNamespaceInternal } from '@/i18n/utils';

// 注册命名空间(必须在组件定义之前执行)
loadNamespaceInternal('<namespace>', {
  'zh-CN': zhCN,
  'zh-HK': zhHK,
  en,
});
```

**注意事项:**
- `loadNamespaceInternal` 必须在所有组件 / Hook / 函数定义之前调用(模块顶层)
- 命名空间名称须与 `--namespace` 参数完全一致
- 若 `--path` 目录没有页面入口文件(如 `src/utils`、`src/hooks` 等非 pages 目录),跳过 0b

---

## Step 1:TypeScript 类型支持

**仅在命名空间为新增时执行**。判断方式:检查 `src/global.d.ts` 中是否已有该命名空间的 import 和类型声明。

```bash
grep -n "<namespace>" src/global.d.ts
```

若无结果,在 `src/global.d.ts` 中追加:

```typescript
import <namespace> from "./i18n/locales/zh-CN/<namespace>.json";

declare module "i18next" {
  interface CustomTypeOptions {
    resources: {
      // ...已有命名空间保持不变...
      <namespace>: typeof <namespace>;  // 追加此行
    };
  }
}
```

---

## Step 2:修复模块顶层初始化问题

### 问题识别

```bash
git diff --name-only | xargs grep -n "i18next.t("
```

**需要 getter 的判断条件(同时满足):**
- 在文件顶层(所有函数/类之外)
- 在对象字面量的属性值或数组元素中

**不需要 getter 的场景:**
- 函数/组件/Hook 内部定义的常量
- `useState` 初始值
- 直接赋值给顶层变量(`const x = i18next.t(...)`,这种写法本身会报错,需整体移入函数内)

### 修复方式

**模式 1:顶层对象属性**
```typescript
// ❌ 转换后
export const roleName = {
  PLAYER: i18next.t('home:userpart.contestants'),
};

// ✅ 修复
export const roleName = {
  get PLAYER() { return i18next.t('home:userpart.contestants'); },
};
```

**模式 2:顶层嵌套对象**
```typescript
// ❌ 转换后
const EntranceMap = {
  item: { name: i18next.t('home:service.entry') },
};

// ✅ 修复(只在 name 上加 getter)
const EntranceMap = {
  item: {
    get name() { return i18next.t('home:service.entry'); },
  },
};
```

**模式 3:顶层数组元素**
```typescript
// ❌ 转换后
const tabList = [
  { label: i18next.t('home:tab.all'), value: 'all' },
];

// ✅ 修复
const tabList = [
  { get label() { return i18next.t('home:tab.all'); }, value: 'all' },
];
```

**模式 4:配置文件(Taro `*.config.ts` / ICE `build.config.ts` 等)**

`export default {}` 不支持 getter,且此类文件已被 `.must.config.js` 排除。若 `git diff` 仍显示配置文件被修改,执行以下还原流程:

```bash
# 1. 确认被修改的配置文件
git diff --name-only | grep -E '\.config\.(ts|js)$'

# 2. 查看新增了哪些 i18next key
git diff <config-file>

# 3. 还原配置文件
git checkout -- <config-file>
```

还原后,从 `src/i18n/locales/zh-CN/<namespace>.json` 中手动删除第 2 步提取到的 key,并告知用户哪些文件被还原、哪些 key 被移除。

**模式 5:TypeScript enum 成员被错误替换**

must 工具会把字符串 enum 成员值也替换为 `i18next.t()` 调用,但 TypeScript 要求 string enum 成员只能是字符串字面量,**这是编译错误**。发现时直接还原并标注 TODO-i18n,交由人工处理:

```typescript
// ❌ must 错误转换
export enum GenderLimitEnum {
  MALE = i18next.t('common:supermatch.male'),
}

// ✅ 还原,标注 TODO 交人工判断
export enum GenderLimitEnum {
  // TODO-i18n: enum 值为中文常量,建议改为英文字面量(如 MALE = 'MALE')以避免与翻译耦合
  MALE = '男',
}
```

enum 还原后,引用它的顶层展示 Map / Options 中的**展示文本**按模式 1/3 正常加 getter 即可。

---

## Step 3:处理中文比较问题

### 搜索命令

```bash
# 1. 相等性比较
git diff --name-only | xargs grep -n "i18next\.t(" | grep -E "===|==|!==|!="

# 2. 对象属性访问
git diff --name-only | xargs grep -n "\[i18next\.t("

# 3. 字符串方法(⚠️ 最容易遗漏)
git diff --name-only | xargs grep -n "i18next\.t(" | grep -E "\.indexOf\(|\.includes\(|\.startsWith\(|\.endsWith\(|\.match\(|\.search\(|\.split\(|\.replace\(|\.replaceAll\(|\.localeCompare\("
```

### 处理策略

> **⚠️ 核心原则:代码追溯,而非猜测**
>
> 处理中文比较问题的关键是**通过代码追溯准确判断数据来源**,而不是依赖字段名或用途猜测。

---

#### 步骤 1:系统性追溯数据来源

对于每个包含 `i18next.t()` 的比较语句,按以下优先级追溯:

**1. 查看 TypeScript 类型定义**

```bash
# 跳转到变量的类型定义
# 方法:Cmd/Ctrl + 点击变量名 → 查看类型 → 跳转到类型定义
```

**判断标准:**

| 类型定义位置 | 数据来源判断 |
|---|---|
| `src/services/types.ts` 或 API 相关文件 | **后端数据** |
| `src/constants/` 或组件内部定义 | **前端数据** |
| 类型注释中有 `@param` / `@returns` 说明 | 查看注释描述 |

**示例:后端数据**
```typescript
// 在 src/services/types.ts 中
export type MatchInfo = {
  /** 轮次描述 */
  roundDesc: string;
  /** 比赛状态 */
  status: MatchStatusType;
};
// ↑ 在 types.ts 中定义 = 后端接口返回的数据结构

// 使用时
if (matchInfo?.roundDesc === i18next.t('...')) { }
// ❌ 错误:后端数据不应与翻译文本比较
```

**示例:前端数据**
```typescript
// 在组件内或 constants.ts 中
const EntranceList = [
  { key: 'schedule', get name() { return i18next.t('...'); } }
];
// ↑ 本地定义 = 前端数据

// 使用时
if (item.name === i18next.t('...')) { }
// ❌ 错误:应比较 item.key
```

**2. 追溯变量赋值来源**

```bash
# 在当前文件中搜索变量的赋值语句
# 或使用 "Go to Definition" 跳转到定义处
```

**判断标准:**

```typescript
// ✅ 后端来源特征
const data = await fetchAPI();          // API 调用
const value = response.model.xxx;       // API 响应取值
const { status } = props;               // 从 props 接收(可能来自上层 API)

// ✅ 前端来源特征  
const data = SomeMap[key];              // 从本地 Map/Object 取值
const value = SomeEnum.VALUE;           // 枚举值
const list = items.map(x => ({ ... })); // 前端数据转换
```

**3. 搜索该字段的其他使用场景**

```bash
# 在整个项目中搜索该字段名
grep -rn "\.roundDesc" src/ --include="*.ts" --include="*.tsx"
```

观察该字段在项目中的其他使用方式:
- 若其他地方用 `noI18n()` 包裹 → 后端数据
- 若其他地方直接渲染 `<div>{xxx}</div>` → 后端数据
- 若其他地方用作 `i18next.t()` 的参数 → 可能是前端数据

**4. 查看 API 接口文件或接口文档**

```bash
# 搜索 mtop API 定义
grep -rn "api: 'mtop\." src/services/
```

查看 API 返回的字段结构,确认该字段是否来自后端响应。

---

#### 步骤 2:根据数据来源选择处理策略

**场景 A:前端数据源的比较 → 改用稳定 key/value 比较**

> 核心原则:绝不用翻译文本(`i18next.t()` 的返回值)做比较,翻译文本会随语言切换而变化。

**A1(优先):数据项已有稳定的 `value` / `type` / `id` 字段 → 直接比较该字段**
```typescript
// 场景:subItem 来自前端数组,本身含有唯一 value 字段
// ❌ 转换后(用翻译文本比较)
return subItem.label === i18next.t('ns:foo.not_used')
  ? i18next.t('ns:foo.no_eagle_eye')
  : subItem.label;

// ✅ 修复(直接比较 value,语言无关)
// 若外层还有类型区分(如 item.type),同时加上类型判断确保精确
return key === 'hawkeyeType' && subItem.value === 0
  ? i18next.t('ns:foo.no_eagle_eye')
  : subItem.label;
```

**A2:数据项无稳定字段 → 添加 `key` 字段**
```typescript
// ❌ 用翻译文本比较
if (name === i18next.t('home:service.schedule')) { }

// ✅ 给数据结构添加唯一 key,改用 key 比较
const EntranceMap = {
  schedule: { key: 'schedule', get name() { return i18next.t('...'); } }
};
if (item.key === 'schedule') { }
```

> ⚠️ 分析数据来源时,务必查看数据的定义处(如 `contants.ts`)。若 `label` 字段已是 `get label() { return i18next.t(...) }` 的 getter 形式,说明数据是**前端已国际化的数据**,使用场景 A。

**场景 B:后端数据源的比较 → 还原中文字符串,并用 `noI18n()` 包裹**

`noI18n` 是项目的中文豁免标记函数(定义于 `src/i18n/utils.ts`),被它包裹的字符串会被 must 扫描工具自动跳过,同时向代码审查者表明此处中文是经过评估后刻意保留的。

```typescript
import { noI18n } from '@/i18n/utils';

// ❌ must 错误转换
if (status === i18next.t('home:status.in_progress')) { }

// ✅ 还原(后端返回中文),用 noI18n 包裹标记意图
if (status === noI18n('进行中')) { }
```

**后端中文数据的渲染处理:**

当后端中文需要展示到页面时,使用**动态翻译**模式:

```typescript
// 后端返回的中文字段
const genderLimit = '混双'; // 来自 API

// ✅ 渲染时使用动态翻译(fallback 到原始值)
<div>
  {i18next.t(`common:dynamic.${genderLimit}`, { defaultValue: genderLimit })}
</div>

// 在 common:dynamic 中预先配置好后端可能返回的所有中文值:
// common/dynamic.json:
// {
//   "男": "Male",
//   "女": "Female", 
//   "混双": "Mixed Doubles",
//   "决赛": "Final",
//   "小组循环赛": "Round Robin"
// }
```

**场景 C:对象属性用翻译文本作 key → 还原中文字符串,用 `noI18n()` 包裹**

```typescript
// ❌ must 转换后
const icon = publicIcon[i18next.t('namespace:custom')];

// ✅ 还原(key 来自后端中文字段,必须保持中文)
const icon = publicIcon[noI18n('自定义')];

// 或者当 key 是变量时
const icon = publicIcon[item.category]; // item.category 是后端返回的中文
```

> **⚠️ 注意:** 在对象定义处也要检查中文 key 是否需要动态翻译。如果这个对象用于展示,考虑改造为:
> ```typescript
> const publicIconConfig = {
>   custom: { key: '自定义', icon: 'xxx' }
> };
> // 渲染时:i18next.t(`common:dynamic.${iconItem.key}`)
> ```

**场景 D:字符串方法与 i18next.t() 组合**

- 有现成布尔属性可替代 → 直接修复
- 数据来源不明确或逻辑复杂 → 标注 TODO:

```typescript
// TODO-i18n: 字符串方法与 i18next.t() 组合,需人工判断数据来源并修复
// 数据来源:[描述变量来自哪里]
// 建议方案:[A/B/C/D 场景,具体修复思路]
```


---

#### 步骤 3:特殊场景处理技巧

**技巧 1:当字段在项目中被广泛使用时,建立"参考列表"**

如果追溯过程中发现某些后端字段被大量使用(如 `status`、`roundDesc`),可在当前会话中建立临时参考:

```bash
# 搜索已确认的 noI18n() 使用
grep -rn "noI18n(" src/ --include="*.ts" --include="*.tsx" -A 1

# 输出示例:
# src/pages/xxx.tsx:  if (roundDesc === noI18n('决赛'))
# ↑ 说明 roundDesc 已被确认为后端字段
```

基于已有的 `noI18n()` 使用,可推断相同字段名在其他地方也是后端数据。

**技巧 2:利用 Git Diff 对比转换前后的变化**

```bash
# 查看某个文件转换前的内容
git show HEAD:path/to/file.tsx | grep "字段名"
```

若转换前该字段是普通中文字符串(非 `i18next.t()`),很可能是后端数据。

**技巧 3:不确定时保守处理**

若追溯后仍无法 100% 确定数据来源,采用保守策略:

```typescript
// TODO-i18n: 数据来源待确认
// 已追溯:[描述追溯过程]
// 当前判断:[前端/后端/不确定]
// 建议:人工验证后再修复,或询问项目负责人
if (xxx === i18next.t('...')) { }
```

标注 TODO 后继续处理其他明确的问题,避免误改。

---

#### 步骤 4:验证修复结果

修复后,验证逻辑是否正确:

**后端数据场景:**
```bash
# 1. 搜索是否有遗漏的翻译文本比较
grep -rn "i18next\.t(" <修复的文件> | grep -E "===|!==|=="

# 2. 确认 noI18n() 已正确导入
grep -n "import.*noI18n" <修复的文件>
```

**前端数据场景:**
```bash
# 确认数据结构已添加稳定的 key 字段
grep -n "key:" <数据定义文件>
```

---

## Step 4:合并重复翻译到 common 对象

### 查找重复值

```bash
cat src/i18n/locales/zh-CN/<namespace>.json | grep -oP '": "\K[^"]+' | sort | uniq -d
```

### 处理步骤

1. 将重复值提取到翻译文件顶部的 `common` 对象
2. 搜索旧 key 的所有引用,使用**精确匹配**替换为 `<namespace>:common.<key>`
3. 验证无误替换:对照翻译文件确认代码中的 key 都存在

**⚠️ 精确匹配,避免过度替换:**
```bash
# ✅ 正确:key 后跟引号,确保完整匹配
sed -i '' 's/module\.confirm"/common.confirm"/g' file.tsx

# ❌ 错误:前缀匹配会误替换 module.confirm_xxx
sed -i '' 's/module\.confirm/common.confirm/g' file.tsx
```

替换后验证:
```bash
# 检查是否有未在翻译文件中定义的 key
grep -rn "<namespace>:common\." <目标目录> --include="*.tsx" --include="*.ts"
```

scripts结构下:
transform.js文件

const path = require('path');
const fs = require('fs-extra');

const { extract } = require('@ali/parrot-tool-must');
const { parseArgs } = require('./utils/parse-args');
const { mergeLocales } = require('./utils/merge-locales');
const { writeZhCNLocale } = require('./utils/write-locales');

const baseConfig = require(path.join(__dirname, '../../../../.must.config.js'));

// 始终以项目根目录为工作目录,与执行位置无关
const cwd = path.resolve(__dirname, '../../../..');

async function run() {
  const params = parseArgs();
  const targetPath = params.path || params.dir;
  const namespace = params.namespace || params.ns;

  if (!targetPath || !namespace) {
    console.error('❌ 缺少必要参数!');
    console.log('用法: node scripts/transform.js --path=<目标目录> --namespace=<命名空间>');
    process.exit(1);
  }

  const config = {
    ...baseConfig,
    cwd, // 覆盖 baseConfig.cwd('./'),确保 must 工具以项目根目录为基准,不在 skills 目录下创建文件
    sourcePath: targetPath,
    macro: {
      ...baseConfig.macro,
      method: `i18next.t("${namespace}:$key$")`,
    },
  };

  const localesDir = path.resolve(cwd, config.macro.path);
  const stringsDir = mergeLocales(localesDir, [namespace]);

  try {
    const start = Date.now();
    const files = extract.findFiles({ ...config, cwd });
    const result = await extract.js.parse(files, config);
    const { strings, failList, unprocessedCode } = result;

    if (strings?.['zh-CN']) {
      writeZhCNLocale(strings['zh-CN'], namespace, cwd);
    }

    console.log('\n========== 转换结果 ==========');
    console.log(`命名空间: ${namespace}`);
    console.log(`目标目录: ${targetPath}`);
    console.log(`提取 key 数: ${Object.keys(strings?.['zh-CN'] || {}).length}`);

    if (failList?.length > 0) {
      console.log(`\n⚠️  failList(${failList.length} 项):`);
      failList.forEach(f => console.log(`  - ${f}`));
    }

    if (Object.keys(unprocessedCode || {}).length > 0) {
      console.log('\n⚠️  以下模板字符串需手动处理(unprocessedCode):');
      Object.entries(unprocessedCode).forEach(([filePath, items]) => {
        console.log(`  ${path.relative(cwd, filePath)}`);
        items.forEach(({ line, column, code }) => {
          console.log(`    line ${line}:${column}  ${code}`);
        });
      });
    }

    const elapsed = Date.now() - start;
    console.log(`\n耗时: ${elapsed}ms`);
  } catch (error) {
    console.error('❌ 转换失败:', error);
    process.exit(1);
  } finally {
    fs.removeSync(stringsDir);
  }
}

run();

utils/merge-locales.js文件

/**
 * locales 多文件合并工具
 * 把 {localesDir}/{lang}/*.json 合并成工具期望的 {localesDir}/strings/{lang}.json 单文件
 * 供 generateKeyCheckObject 读取历史 key 做冲突检测
 */
const path = require('path');
const fs = require('fs-extra');

/**
 * 递归把嵌套对象扁平化为点分隔的 key
 * { "a": { "b": "val" } } → { "a.b": "val" }
 */
function flattenObject(obj, prefix = '', result = {}) {
  Object.keys(obj).forEach(key => {
    const fullKey = prefix ? `${prefix}.${key}` : key;
    if (obj[key] !== null && typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
      flattenObject(obj[key], fullKey, result);
    } else {
      result[fullKey] = obj[key];
    }
  });
  return result;
}

/**
 * 把 localesDir 下各语言目录的 JSON 文件合并成 strings/{lang}.json
 * @param {string}   localesDir  语言包根目录绝对路径
 * @param {string[]} [includes]  指定合并哪些文件(不含扩展名),不传则合并全部
 * @return {string} stringsDir
 */
function mergeLocales(localesDir, includes) {
  const stringsDir = path.resolve(localesDir, 'strings');
  fs.ensureDirSync(stringsDir);

  const langs = fs.readdirSync(localesDir).filter(name => {
    return fs.statSync(path.resolve(localesDir, name)).isDirectory() && name !== 'strings';
  });

  langs.forEach(lang => {
    const langDir = path.resolve(localesDir, lang);
    const merged = {};
    fs.readdirSync(langDir)
      .filter(f => {
        if (!f.endsWith('.json')) return false;
        if (includes && includes.length > 0) {
          return includes.includes(path.basename(f, '.json'));
        }
        return true;
      })
      .forEach(file => {
        const data = fs.readJsonSync(path.resolve(langDir, file));
        Object.assign(merged, flattenObject(data));
      });
    fs.writeJsonSync(path.resolve(stringsDir, `${lang}.json`), merged, {
      spaces: 2,
    });
  });

  return stringsDir;
}

module.exports = { flattenObject, mergeLocales };

utlis/parse-args.js文件

/**
 * 解析命令行参数,支持 --key=value 格式
 */
function parseArgs() {
  const args = process.argv.slice(2);
  const params = {};
  args.forEach(arg => {
    if (arg.startsWith('--')) {
      const [key, value] = arg.substring(2).split('=');
      params[key] = value;
    }
  });
  return params;
}

module.exports = { parseArgs };

utlis/write-locales.js文件

const path = require('path');
const fs = require('fs-extra');

const { flattenObject } = require('./merge-locales');

/**
 * 将单个扁平 key(如 "supermatch.unlimited")写入 JSON 对象。
 * 策略:若 key 第一段在 obj 中已是嵌套对象,则递归写入其内部;
 * 否则保持扁平 dotted-key 形式,与原来格式保持一致。
 * @param {object} obj     目标 JSON 对象(原地修改)
 * @param {string} flatKey 扁平 dotted key
 * @param {string} value   翻译值
 */
function smartInsert(obj, flatKey, value) {
  const dotIdx = flatKey.indexOf('.');
  if (dotIdx === -1) {
    if (!(flatKey in obj)) obj[flatKey] = value;
    return;
  }

  const firstSeg = flatKey.slice(0, dotIdx);
  const restKey = flatKey.slice(dotIdx + 1);
  const child = obj[firstSeg];

  if (child !== undefined && child !== null && typeof child === 'object' && !Array.isArray(child)) {
    // 父节点已是嵌套对象 → 递归插入,保持嵌套风格
    smartInsert(child, restKey, value);
  } else {
    // 父节点不存在或不是对象 → 保持扁平 dotted-key 风格
    if (!(flatKey in obj)) obj[flatKey] = value;
  }
}

/**
 * 将提取的 zh-CN strings 合并写入 src/i18n/locales/zh-CN/<namespace>.json
 * 已有 key 不覆盖,只追加新 key。
 * 写入时智能匹配已有的 JSON 结构:
 *   - 若 key 前缀在已有 JSON 中已是嵌套对象(a: {b: ...}),则插入到嵌套对象内
 *   - 否则使用扁平 dotted-key 形式(a.b: "")
 * @param {object} strings    zh-CN 扁平 key-value 对象
 * @param {string} namespace
 * @param {string} cwd        项目根目录
 */
function writeZhCNLocale(strings, namespace, cwd) {
  const localesZhCN = path.resolve(cwd, 'src/i18n/locales/zh-CN');
  fs.ensureDirSync(localesZhCN);

  const targetFile = path.join(localesZhCN, `${namespace}.json`);
  const existing = fs.existsSync(targetFile) ? fs.readJsonSync(targetFile) : {};

  const existingFlat = flattenObject(existing);
  const newEntries = Object.entries(strings).filter(([key]) => !(key in existingFlat));

  for (const [key, value] of newEntries) {
    smartInsert(existing, key, value);
  }

  fs.writeJsonSync(targetFile, existing, { spaces: 2 });
  console.log(`✅ zh-CN 语言资源已写入: ${targetFile}`);
}

module.exports = { writeZhCNLocale };

 

项目中的配置结构

image

 中文配置举例

image

 

{
    "bottombtn.confirm_cancellation_application": "是否确认撤销申请"
}

英文配置举例

image

 

{
    "bottombtn.confirm_cancellation_application": "Confirm Application Cancellation?",
}


页面使用:

import i18next from 'i18next';

return<div>这里的翻译文案是: i18next.t('applicationForm:bottombtn.confirm_cancellation_application')</div> 
)

 

posted @ 2026-04-13 17:22  喻佳文  阅读(2)  评论(0)    收藏  举报