@wangyh-quuo/i18n-refactor 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mocharc.json +9 -0
- package/README.md +116 -0
- package/dist/bin/i18n-cli.d.ts +3 -0
- package/dist/bin/i18n-cli.d.ts.map +1 -0
- package/dist/bin/scan.d.ts +3 -0
- package/dist/bin/scan.d.ts.map +1 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/extract-i18n.d.ts +2 -0
- package/dist/extract-i18n.d.ts.map +1 -0
- package/dist/fileProcessor.d.ts +11 -0
- package/dist/fileProcessor.d.ts.map +1 -0
- package/dist/keyGenerator.d.ts +9 -0
- package/dist/keyGenerator.d.ts.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.esm.js +879 -0
- package/dist/main.esm.js.map +1 -0
- package/dist/utils/exportToExcel.d.ts +13 -0
- package/dist/utils/exportToExcel.d.ts.map +1 -0
- package/dist/utils/index.d.ts +24 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/scan.d.ts +7 -0
- package/dist/utils/scan.d.ts.map +1 -0
- package/locales/test-cli/i18n.config.js +3 -0
- package/locales/test-cli/locales/zh.json +10 -0
- package/locales/test-cli/output/i18n.xlsx +0 -0
- package/locales/test-cli/src/pages/home/aa.js +2 -0
- package/locales/test-cli/src/pages/home/index.vue +25 -0
- package/locales/zh.json +9 -0
- package/output/i18n.xlsx +0 -0
- package/package.json +57 -0
- package/tsconfig.json +44 -0
package/dist/main.esm.js
ADDED
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as commander from 'commander';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import xlsx from 'xlsx';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { parse } from '@babel/parser';
|
|
7
|
+
import * as _traverse from '@babel/traverse';
|
|
8
|
+
import md5 from 'md5';
|
|
9
|
+
import MagicString from 'magic-string';
|
|
10
|
+
import { parse as parse$1 } from '@vue/compiler-sfc';
|
|
11
|
+
import { compile, NodeTypes } from '@vue/compiler-dom';
|
|
12
|
+
import { isObject, merge, omit } from 'lodash-es';
|
|
13
|
+
import fg from 'fast-glob';
|
|
14
|
+
import { pathToFileURL } from 'url';
|
|
15
|
+
|
|
16
|
+
var name = "@wangyh-quuo/i18n-refactor";
|
|
17
|
+
var version = "1.0.0";
|
|
18
|
+
var description = "AST-based i18n refactoring tool for Vue & TypeScript";
|
|
19
|
+
var packageJson = {
|
|
20
|
+
name: name,
|
|
21
|
+
version: version,
|
|
22
|
+
description: description};
|
|
23
|
+
|
|
24
|
+
const DEFAULT_SOURCE_DIR = 'src';
|
|
25
|
+
var config = {
|
|
26
|
+
// 要扫描的目录
|
|
27
|
+
sourceDir: DEFAULT_SOURCE_DIR,
|
|
28
|
+
// 输出的 JSON 和 Excel 路径
|
|
29
|
+
output: {
|
|
30
|
+
json: './locales/zh.json',
|
|
31
|
+
excel: './output/i18n.xlsx',
|
|
32
|
+
},
|
|
33
|
+
// 支持的语言列表
|
|
34
|
+
languages: ['zh_CN', 'en_US'],
|
|
35
|
+
// 是否导出 Excel
|
|
36
|
+
exportExcel: true,
|
|
37
|
+
// key 生成规则(可以后面提供多个策略)
|
|
38
|
+
keyStrategy: {
|
|
39
|
+
default: 'prefix_increment', // prefix_increment or 'hash'
|
|
40
|
+
prefixRoots: [`${DEFAULT_SOURCE_DIR}/*`], // 作为模块前缀的根目录列表
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function createContext(config) {
|
|
45
|
+
return {
|
|
46
|
+
config,
|
|
47
|
+
zhMap: {},
|
|
48
|
+
existingJson: {},
|
|
49
|
+
lastIds: {},
|
|
50
|
+
existingKeys: {},
|
|
51
|
+
notReplaceFiles: [],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const context = createContext(config);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 多模块 i18n 导出为 Excel(每个模块一个 Sheet)
|
|
58
|
+
* @param {object} mergedJson 合并后的 JSON,如 { home: { key_1: '首页' } }
|
|
59
|
+
* @param {string} outputPath Excel 输出路径
|
|
60
|
+
*/
|
|
61
|
+
function exportToExcelByModule(mergedJson, outputPath = "./output/i18n.xlsx") {
|
|
62
|
+
const fullPath = path.resolve(outputPath);
|
|
63
|
+
const outputDir = path.dirname(fullPath);
|
|
64
|
+
if (!fs.existsSync(outputDir)) {
|
|
65
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
66
|
+
console.log(`📁 已创建目录: ${outputDir}`);
|
|
67
|
+
}
|
|
68
|
+
const workbook = xlsx.utils.book_new();
|
|
69
|
+
for (const moduleKey of Object.keys(mergedJson)) {
|
|
70
|
+
const moduleData = mergedJson[moduleKey];
|
|
71
|
+
const data = Object.entries(moduleData).map(([key, zh]) => ({
|
|
72
|
+
key,
|
|
73
|
+
zh_CN: zh,
|
|
74
|
+
en_US: "", // 可预留
|
|
75
|
+
}));
|
|
76
|
+
const sheet = xlsx.utils.json_to_sheet(data);
|
|
77
|
+
xlsx.utils.book_append_sheet(workbook, sheet, moduleKey);
|
|
78
|
+
}
|
|
79
|
+
if (workbook.SheetNames.length === 0) {
|
|
80
|
+
console.log("❌ 未找到任何数据,导出取消。");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
xlsx.writeFile(workbook, fullPath);
|
|
84
|
+
console.log(`✅ Excel(多 Sheet)导出成功: ${fullPath}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 获取已存在的 zh.json 文件内容
|
|
89
|
+
* @returns {Object} 解析后的 JSON 对象
|
|
90
|
+
*/
|
|
91
|
+
function getExistingJson(jsonPath) {
|
|
92
|
+
let existingJson = {};
|
|
93
|
+
if (fs.existsSync(jsonPath)) {
|
|
94
|
+
const existingContent = fs.readFileSync(jsonPath, "utf-8");
|
|
95
|
+
existingJson = JSON.parse(existingContent);
|
|
96
|
+
}
|
|
97
|
+
return existingJson;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* 将扁平对象转换为嵌套对象
|
|
101
|
+
* @param {Object} flatObj 扁平对象
|
|
102
|
+
* @returns {Object} 嵌套对象
|
|
103
|
+
*/
|
|
104
|
+
function flatToNested(flatObj) {
|
|
105
|
+
const nested = {};
|
|
106
|
+
for (const key in flatObj) {
|
|
107
|
+
const parts = key.split(".");
|
|
108
|
+
let current = nested;
|
|
109
|
+
parts.forEach((part, index) => {
|
|
110
|
+
if (!current[part]) {
|
|
111
|
+
current[part] = index === parts.length - 1 ? flatObj[key] : {};
|
|
112
|
+
}
|
|
113
|
+
current = current[part];
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return nested;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* 合并现有的 JSON 和新生成的 JSON
|
|
120
|
+
* @param {Object} newJson 新生成的 JSON 对象
|
|
121
|
+
* @returns {Object} 合并后的 JSON 对象
|
|
122
|
+
*/
|
|
123
|
+
function mergeZhJson(newJson) {
|
|
124
|
+
const existingJson = getExistingJson(context.config.output.json);
|
|
125
|
+
// 使用递归合并现有的 JSON 和新生成的 JSON
|
|
126
|
+
function deepMerge(target, source) {
|
|
127
|
+
for (const key in source) {
|
|
128
|
+
if (source.hasOwnProperty(key)) {
|
|
129
|
+
if (typeof source[key] === "object" && !Array.isArray(source[key])) {
|
|
130
|
+
if (!target[key])
|
|
131
|
+
target[key] = {};
|
|
132
|
+
deepMerge(target[key], source[key]);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
target[key] = source[key];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
deepMerge(existingJson, newJson);
|
|
141
|
+
return existingJson;
|
|
142
|
+
}
|
|
143
|
+
function writeJsonToFile() {
|
|
144
|
+
const nested = flatToNested(context.zhMap);
|
|
145
|
+
const mergedZhJson = mergeZhJson(nested);
|
|
146
|
+
const localesDir = context.config.output.json.split("/").slice(0, -1).join("/");
|
|
147
|
+
fs.mkdirSync(localesDir, { recursive: true });
|
|
148
|
+
fs.writeFileSync(context.config.output.json, JSON.stringify(mergedZhJson, null, 2), "utf-8");
|
|
149
|
+
console.log(`\n🎉 全部处理完成!已生成并合并: ${context.config.output.json}`);
|
|
150
|
+
if (context.config.exportExcel) {
|
|
151
|
+
exportToExcelByModule(mergedZhJson, context.config.output.excel);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const traverse =
|
|
156
|
+
// CJS + ESM + bundler 全兼容
|
|
157
|
+
(_traverse.default?.default ??
|
|
158
|
+
_traverse.default ??
|
|
159
|
+
_traverse);
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 匹配文件路径是否符合指定的目录模式
|
|
163
|
+
* @param {string} filePath 文件路径
|
|
164
|
+
* @param {string[]} patterns 目录模式数组
|
|
165
|
+
* @returns {Object} { matched: boolean, root: string }
|
|
166
|
+
*/
|
|
167
|
+
function matchRootDir(filePath, patterns) {
|
|
168
|
+
const normalized = path.normalize(filePath);
|
|
169
|
+
for (const pattern of patterns) {
|
|
170
|
+
const normalizedPattern = path.normalize(pattern);
|
|
171
|
+
// 精确目录:src/pages
|
|
172
|
+
if (!normalizedPattern.endsWith(path.normalize('/*'))) {
|
|
173
|
+
if (normalized.startsWith(normalizedPattern + path.sep)) {
|
|
174
|
+
return { matched: true, root: normalizedPattern };
|
|
175
|
+
}
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
// 通配目录:src/*
|
|
179
|
+
const baseDir = normalizedPattern.replace(path.normalize('/*'), '');
|
|
180
|
+
if (!normalized.startsWith(baseDir + path.sep)) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
// src/a/b/c.vue => b/c.vue
|
|
184
|
+
const rest = normalized.slice(baseDir.length + 1);
|
|
185
|
+
const segments = rest.split(path.sep);
|
|
186
|
+
if (segments.length >= 2) {
|
|
187
|
+
return { matched: true, root: baseDir + path.sep + segments[0] };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { matched: false, root: '' };
|
|
191
|
+
}
|
|
192
|
+
function isChinese(str) {
|
|
193
|
+
return /[\u4e00-\u9fa5]/.test(str);
|
|
194
|
+
}
|
|
195
|
+
function containsHTML(text) {
|
|
196
|
+
return /<\/?[a-z][\s\S]*>/i.test(text);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 获取该模块的最后一个 id
|
|
201
|
+
* @param {Object} existingJson 已存在的 JSON 对象
|
|
202
|
+
* @returns {Object} 每个模块的最后一个 id
|
|
203
|
+
*/
|
|
204
|
+
function getLastKeyId(existingJson) {
|
|
205
|
+
const lastIds = {};
|
|
206
|
+
function traverse(obj, prefix = null) {
|
|
207
|
+
for (const key in obj) {
|
|
208
|
+
if (typeof obj[key] === "object" && !Array.isArray(obj[key])) {
|
|
209
|
+
traverse(obj[key], key);
|
|
210
|
+
}
|
|
211
|
+
else if (key.startsWith("key_")) {
|
|
212
|
+
const id = parseInt(key.split("_")[1]);
|
|
213
|
+
if (prefix && (!lastIds[prefix] || id > lastIds[prefix])) {
|
|
214
|
+
lastIds[prefix] = id;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
traverse(existingJson);
|
|
220
|
+
return lastIds;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* 初始化已存在的 key
|
|
224
|
+
* @param {Object} existingJson 已存在的 JSON 对象
|
|
225
|
+
* @returns {Object} 中文文本和 key 的映射关系
|
|
226
|
+
*/
|
|
227
|
+
function initExistingKeys(existingJson) {
|
|
228
|
+
const map = {};
|
|
229
|
+
for (const module in existingJson) {
|
|
230
|
+
const group = existingJson[module];
|
|
231
|
+
for (const key in group) {
|
|
232
|
+
const value = group[key];
|
|
233
|
+
map[value] = `${module}.${key}`;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return map;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* 获取或生成唯一的 key
|
|
240
|
+
* @param {string} text 中文文本
|
|
241
|
+
* @param {string} prefix 模块前缀
|
|
242
|
+
* @returns {string} 唯一的 key
|
|
243
|
+
*/
|
|
244
|
+
function getKeyByText(text, prefix) {
|
|
245
|
+
const clean = text.trim();
|
|
246
|
+
// 如果已经存在,则直接返回对应的 key
|
|
247
|
+
if (context.existingKeys[clean])
|
|
248
|
+
return context.existingKeys[clean];
|
|
249
|
+
let key = '';
|
|
250
|
+
if (context.config.keyStrategy.default === 'prefix_increment') {
|
|
251
|
+
let id = context.lastIds[prefix] || 0; // 获取当前模块的最后一个 id,没有则从 0 开始
|
|
252
|
+
// 生成新的 key
|
|
253
|
+
key = `${prefix}.key_${++id}`;
|
|
254
|
+
// 更新模块的 ID
|
|
255
|
+
context.lastIds[prefix] = id;
|
|
256
|
+
}
|
|
257
|
+
else if (context.config.keyStrategy.default === 'hash') {
|
|
258
|
+
const hash = md5(text).slice(0, 8); // 可控制长度
|
|
259
|
+
key = `${prefix}.${hash}`;
|
|
260
|
+
}
|
|
261
|
+
context.existingKeys[clean] = key; // 记录该中文和 key 的映射关系
|
|
262
|
+
context.zhMap[key] = clean; // 添加到最终的 zhMap
|
|
263
|
+
return key;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* 获取页面模块前缀
|
|
267
|
+
* @param {string} filePath 文件路径
|
|
268
|
+
* @returns {string} 模块前缀
|
|
269
|
+
*/
|
|
270
|
+
function getPagePrefix(filePath) {
|
|
271
|
+
const matchResult = matchRootDir(filePath, context.config.keyStrategy.prefixRoots);
|
|
272
|
+
if (matchResult.matched) {
|
|
273
|
+
const normalized = path.normalize(filePath); // 保证是平台风格路径
|
|
274
|
+
const segments = normalized.replace(matchResult.root, '').split(path.sep).filter(Boolean);
|
|
275
|
+
return segments[0]; // 如 "home"
|
|
276
|
+
}
|
|
277
|
+
return "common"; // fallback
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const logSet = new Set();
|
|
281
|
+
function loggerDryRun(filePath, source, replacement) {
|
|
282
|
+
if (context.config.dryRun) {
|
|
283
|
+
if (!logSet.has(`${filePath}`)) {
|
|
284
|
+
logSet.add(`${filePath}`);
|
|
285
|
+
console.log(`[DRY RUN] Would modify: ${filePath}`);
|
|
286
|
+
}
|
|
287
|
+
console.log(`[DRY RUN] Would add key: ${replacement} from ${source.trim()}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
class Replacer {
|
|
292
|
+
replacements = [];
|
|
293
|
+
constructor(replacements) {
|
|
294
|
+
this.replacements = replacements;
|
|
295
|
+
}
|
|
296
|
+
replace(raw, filePath) {
|
|
297
|
+
const content = new MagicString(raw);
|
|
298
|
+
for (const r of this.replacements) {
|
|
299
|
+
content.overwrite(r.start, r.end, r.replacement);
|
|
300
|
+
loggerDryRun(filePath, r.source, r.replacement);
|
|
301
|
+
}
|
|
302
|
+
const replacedContent = content.toString();
|
|
303
|
+
return replacedContent;
|
|
304
|
+
}
|
|
305
|
+
static updateFile(filePath, replacedContent) {
|
|
306
|
+
if (!context.config.dryRun) {
|
|
307
|
+
fs.writeFileSync(filePath, replacedContent, "utf-8");
|
|
308
|
+
console.log(`✅ 处理完成: ${filePath}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
class ScriptParser {
|
|
314
|
+
filePath;
|
|
315
|
+
rawContent;
|
|
316
|
+
constructor(filePath) {
|
|
317
|
+
this.filePath = filePath;
|
|
318
|
+
this.rawContent = "";
|
|
319
|
+
if (fs.existsSync(filePath)) {
|
|
320
|
+
this.rawContent = fs.readFileSync(filePath, "utf-8");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
static parseScript(content, filePath) {
|
|
324
|
+
const ast = parse(content, {
|
|
325
|
+
sourceType: "module",
|
|
326
|
+
plugins: ["typescript", "jsx"],
|
|
327
|
+
});
|
|
328
|
+
const replacements = [];
|
|
329
|
+
traverse(ast, {
|
|
330
|
+
StringLiteral(path) {
|
|
331
|
+
const { node } = path;
|
|
332
|
+
if (!/[\u4e00-\u9fff]/.test(node.value)) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
// 排除 import / key
|
|
336
|
+
if (path.parent.type === "ImportDeclaration" ||
|
|
337
|
+
(path.parent.type === "ObjectProperty" &&
|
|
338
|
+
path.parent.key === node &&
|
|
339
|
+
!path.parent.computed)) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const key = getKeyByText(node.value, getPagePrefix(filePath));
|
|
343
|
+
replacements.push({
|
|
344
|
+
start: node.start,
|
|
345
|
+
end: node.end,
|
|
346
|
+
original: node.value,
|
|
347
|
+
source: node.value,
|
|
348
|
+
replacement: `t('${key}')`,
|
|
349
|
+
});
|
|
350
|
+
},
|
|
351
|
+
// 模板字符串 const msg = `你好${name}同学`; --> `${t('key_1', { 0: name })}`
|
|
352
|
+
TemplateLiteral(path) {
|
|
353
|
+
const { quasis, expressions } = path.node;
|
|
354
|
+
if (quasis.some((q) => containsHTML(q.value.cooked || q.value.raw))) {
|
|
355
|
+
if (quasis.some((q) => isChinese(q.value.cooked || q.value.raw))) {
|
|
356
|
+
context.notReplaceFiles.push({
|
|
357
|
+
source: quasis.map(q => q.value.cooked || q.value.raw).join("${...}"),
|
|
358
|
+
filePath: filePath,
|
|
359
|
+
reason: '模板字符串中包含HTML,暂不支持自动替换',
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const needReplace = quasis.some((q) => isChinese(q.value.cooked || q.value.raw)) &&
|
|
365
|
+
expressions.length &&
|
|
366
|
+
expressions.every((exp) => ["Identifier", "MemberExpression"].includes(exp.type));
|
|
367
|
+
if (needReplace) {
|
|
368
|
+
const pos = { start: 0, end: 0 };
|
|
369
|
+
let combinedText = "";
|
|
370
|
+
let i = 0;
|
|
371
|
+
let tempList = [];
|
|
372
|
+
const children = [...quasis, ...expressions].sort((a, b) => a.start - b.start);
|
|
373
|
+
children.forEach((child) => {
|
|
374
|
+
if (!pos.start) {
|
|
375
|
+
pos.start = child.start;
|
|
376
|
+
}
|
|
377
|
+
if (child.type === "TemplateElement") {
|
|
378
|
+
combinedText += child.value.cooked || "";
|
|
379
|
+
}
|
|
380
|
+
else if (child.type === "Identifier") {
|
|
381
|
+
combinedText += `{${i}}`;
|
|
382
|
+
tempList.push(child.name);
|
|
383
|
+
}
|
|
384
|
+
else if (child.type === "MemberExpression") {
|
|
385
|
+
combinedText += `{${i}}`;
|
|
386
|
+
tempList.push(content.slice(child.start, child.end));
|
|
387
|
+
}
|
|
388
|
+
pos.end = child.end;
|
|
389
|
+
});
|
|
390
|
+
const key = getKeyByText(combinedText, getPagePrefix(filePath));
|
|
391
|
+
replacements.push({
|
|
392
|
+
start: pos.start,
|
|
393
|
+
end: pos.end,
|
|
394
|
+
original: combinedText,
|
|
395
|
+
source: combinedText,
|
|
396
|
+
replacement: `\${t('${key}', { ${tempList.map((_, index) => `${index}: ${tempList[index]}`).join(", ")} })}`
|
|
397
|
+
});
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
quasis.forEach((quasi) => {
|
|
402
|
+
const cooked = quasi.value.cooked || quasi.value.raw;
|
|
403
|
+
if (!/[\u4e00-\u9fff]/.test(cooked)) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (quasi.start == null || quasi.end == null) {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const key = getKeyByText(cooked, getPagePrefix(filePath));
|
|
410
|
+
replacements.push({
|
|
411
|
+
start: quasi.start,
|
|
412
|
+
end: quasi.end,
|
|
413
|
+
original: cooked,
|
|
414
|
+
source: cooked,
|
|
415
|
+
replacement: `\${t('${key}')}`,
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
return replacements;
|
|
422
|
+
}
|
|
423
|
+
process() {
|
|
424
|
+
const replacements = ScriptParser.parseScript(this.rawContent, this.filePath);
|
|
425
|
+
return new Replacer(replacements).replace(this.rawContent, this.filePath);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function getSourceReplacePosition(sourceLocation) {
|
|
430
|
+
const source = sourceLocation.source;
|
|
431
|
+
let start = 0;
|
|
432
|
+
let end = source.length;
|
|
433
|
+
// 去掉前面的纯缩进(空格 + 换行)
|
|
434
|
+
while (start < end &&
|
|
435
|
+
(source[start] === ' ' ||
|
|
436
|
+
source[start] === '\n' ||
|
|
437
|
+
source[start] === '\r' ||
|
|
438
|
+
source[start] === '\t')) {
|
|
439
|
+
start++;
|
|
440
|
+
}
|
|
441
|
+
// 去掉尾部的纯缩进
|
|
442
|
+
while (end > start &&
|
|
443
|
+
(source[end - 1] === ' ' ||
|
|
444
|
+
source[end - 1] === '\n' ||
|
|
445
|
+
source[end - 1] === '\r' ||
|
|
446
|
+
source[end - 1] === '\t')) {
|
|
447
|
+
end--;
|
|
448
|
+
}
|
|
449
|
+
return {
|
|
450
|
+
start: start + sourceLocation.start.offset,
|
|
451
|
+
end: sourceLocation.end.offset - (source.length - end),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
class VueParser {
|
|
455
|
+
filePath;
|
|
456
|
+
rawContent;
|
|
457
|
+
templateContent;
|
|
458
|
+
scriptContent;
|
|
459
|
+
constructor(filePath) {
|
|
460
|
+
this.filePath = filePath;
|
|
461
|
+
this.rawContent = '';
|
|
462
|
+
this.templateContent = "";
|
|
463
|
+
this.scriptContent = "";
|
|
464
|
+
this.parse();
|
|
465
|
+
}
|
|
466
|
+
parse() {
|
|
467
|
+
if (!fs.existsSync(this.filePath)) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
this.rawContent = fs.readFileSync(this.filePath, "utf-8");
|
|
471
|
+
const { descriptor } = parse$1(this.rawContent);
|
|
472
|
+
if (descriptor.template) {
|
|
473
|
+
this.templateContent = descriptor.template.content;
|
|
474
|
+
}
|
|
475
|
+
if (descriptor.script || descriptor.scriptSetup) {
|
|
476
|
+
const scriptBlock = descriptor.scriptSetup || descriptor.script;
|
|
477
|
+
const scriptContent = scriptBlock?.content;
|
|
478
|
+
if (scriptContent) {
|
|
479
|
+
this.scriptContent = scriptContent;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
processTemplate(templateContent, filePath) {
|
|
484
|
+
const ast = compile(templateContent, { mode: "module" }).ast;
|
|
485
|
+
const prefix = getPagePrefix(filePath);
|
|
486
|
+
const replacements = [];
|
|
487
|
+
const handleCompoundExpression = this.handleCompoundExpression.bind(this);
|
|
488
|
+
const handleArrayExpression = this.handleArrayExpression.bind(this);
|
|
489
|
+
function walk(node, replacement) {
|
|
490
|
+
if (node.type === NodeTypes.COMMENT) {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (node.type === NodeTypes.TEXT) {
|
|
494
|
+
const text = node.content.trim();
|
|
495
|
+
if (text && isChinese(text)) {
|
|
496
|
+
const key = getKeyByText(text, prefix);
|
|
497
|
+
replacements.push({
|
|
498
|
+
...getSourceReplacePosition(node.loc),
|
|
499
|
+
original: text,
|
|
500
|
+
source: node.loc.source, // 换行文本兼容处理
|
|
501
|
+
replacement: replacement ? replacement(key) : `{{ $t('${key}') }}`,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// if
|
|
506
|
+
else if (node.type === NodeTypes.IF) {
|
|
507
|
+
if (node.branches) {
|
|
508
|
+
node.branches.forEach((branch) => walk(branch));
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
// 插槽
|
|
512
|
+
else if (node.type === NodeTypes.TEXT_CALL) {
|
|
513
|
+
walk(node.content);
|
|
514
|
+
}
|
|
515
|
+
// 2. 标签属性中的中文
|
|
516
|
+
else if (node.type === NodeTypes.ELEMENT && node.props) {
|
|
517
|
+
node.props.forEach((prop) => walk(prop));
|
|
518
|
+
}
|
|
519
|
+
// 属性
|
|
520
|
+
else if (node.type === NodeTypes.ATTRIBUTE) {
|
|
521
|
+
const nameLoc = node.nameLoc;
|
|
522
|
+
// 非动态绑定属性才需要添加 : 前缀
|
|
523
|
+
if (!nameLoc.source.startsWith(":") &&
|
|
524
|
+
node.value?.content &&
|
|
525
|
+
isChinese(node.value.content)) {
|
|
526
|
+
replacements.push({
|
|
527
|
+
...getSourceReplacePosition(nameLoc),
|
|
528
|
+
original: nameLoc.source,
|
|
529
|
+
replacement: `:${nameLoc.source}`,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
// 处理属性值中的中文
|
|
533
|
+
if (node.value) {
|
|
534
|
+
walk(node.value, (k) => `"$t('${k}')"`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// 指令
|
|
538
|
+
else if (node.type === NodeTypes.DIRECTIVE) {
|
|
539
|
+
if (!node.exp) {
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
walk(node.exp);
|
|
543
|
+
}
|
|
544
|
+
// 表达式
|
|
545
|
+
else if (node.type === NodeTypes.SIMPLE_EXPRESSION) {
|
|
546
|
+
const text = node.content.trim();
|
|
547
|
+
if (node.ast &&
|
|
548
|
+
text &&
|
|
549
|
+
isChinese(text)) {
|
|
550
|
+
if (node.ast.type === "StringLiteral") {
|
|
551
|
+
const key = getKeyByText(text, prefix);
|
|
552
|
+
replacements.push({
|
|
553
|
+
...getSourceReplacePosition(node.loc),
|
|
554
|
+
original: text,
|
|
555
|
+
source: node.loc.source,
|
|
556
|
+
replacement: replacement ? replacement(key) : `$t('${key}')`,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
else if (node.ast.type === 'ArrayExpression') {
|
|
560
|
+
// 数组表达式中的中文
|
|
561
|
+
replacements.push(...handleArrayExpression(node, node.ast, prefix));
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
else if (node.type === NodeTypes.COMPOUND_EXPRESSION) {
|
|
566
|
+
const compoundReplace = handleCompoundExpression(node, prefix);
|
|
567
|
+
if (compoundReplace) {
|
|
568
|
+
Array.isArray(compoundReplace)
|
|
569
|
+
? replacements.push(...compoundReplace)
|
|
570
|
+
: replacements.push(compoundReplace);
|
|
571
|
+
}
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
else if (node.type === NodeTypes.INTERPOLATION) {
|
|
575
|
+
walk(node.content);
|
|
576
|
+
}
|
|
577
|
+
// ParentNode
|
|
578
|
+
if ("children" in node && node.children) {
|
|
579
|
+
node.children.forEach((child) => {
|
|
580
|
+
if (typeof child === "object") {
|
|
581
|
+
walk(child);
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
walk(ast);
|
|
587
|
+
return replacements;
|
|
588
|
+
}
|
|
589
|
+
handleCompoundExpression(node, prefix) {
|
|
590
|
+
const children = node.children;
|
|
591
|
+
const needReplace = children.every(c => typeof c === 'string' || (typeof c === 'object' && (c.type === NodeTypes.TEXT || c.type === NodeTypes.INTERPOLATION)));
|
|
592
|
+
if (children.length && needReplace) {
|
|
593
|
+
const pos = { start: 0, end: 0 };
|
|
594
|
+
let combinedText = '';
|
|
595
|
+
let i = 0;
|
|
596
|
+
let tempList = [];
|
|
597
|
+
children.forEach((child) => {
|
|
598
|
+
if (typeof child === 'object') {
|
|
599
|
+
const childPos = getSourceReplacePosition(child.loc);
|
|
600
|
+
if (!pos.start) {
|
|
601
|
+
pos.start = childPos.start;
|
|
602
|
+
}
|
|
603
|
+
if (child.type === NodeTypes.TEXT) {
|
|
604
|
+
combinedText += child.content;
|
|
605
|
+
}
|
|
606
|
+
else if (child.type === NodeTypes.INTERPOLATION) {
|
|
607
|
+
combinedText += `{${i}}`;
|
|
608
|
+
tempList.push(child.content.loc.source);
|
|
609
|
+
i++;
|
|
610
|
+
}
|
|
611
|
+
pos.end = childPos.end;
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
if (!isChinese(combinedText)) {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
const key = getKeyByText(combinedText, prefix);
|
|
618
|
+
return {
|
|
619
|
+
start: pos.start,
|
|
620
|
+
end: pos.end,
|
|
621
|
+
original: node.loc.source,
|
|
622
|
+
source: node.loc.source,
|
|
623
|
+
// $t('', { 0: xxx })
|
|
624
|
+
replacement: `{{ $t('${key}', { ${tempList.map((_, index) => `${index}: ${tempList[index]}`).join(', ')} }) }}`
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
else {
|
|
628
|
+
if (/\$t\(.*\)$/.test(node.loc.source)) {
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
if (node.ast && node.ast.type === 'ConditionalExpression') {
|
|
632
|
+
return this.handleConditionalExpression(node, node.ast, prefix);
|
|
633
|
+
}
|
|
634
|
+
// 混合表达式暂不支持自动替换
|
|
635
|
+
if (isChinese(node.loc.source)) {
|
|
636
|
+
context.notReplaceFiles.push({
|
|
637
|
+
source: node.loc.source,
|
|
638
|
+
filePath: this.filePath,
|
|
639
|
+
reason: '混合表达式暂不支持自动替换',
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
handleConditionalExpression(node, ast, prefix) {
|
|
646
|
+
if (!ast || ast.type !== 'ConditionalExpression') {
|
|
647
|
+
return [];
|
|
648
|
+
}
|
|
649
|
+
const { consequent, alternate } = ast;
|
|
650
|
+
const res = [];
|
|
651
|
+
if (consequent.type === 'StringLiteral' && isChinese(consequent.value)) {
|
|
652
|
+
const key = getKeyByText(consequent.value, prefix);
|
|
653
|
+
res.push({
|
|
654
|
+
start: consequent.start + node.loc.start.offset - 1,
|
|
655
|
+
end: consequent.end + node.loc.start.offset,
|
|
656
|
+
original: consequent.value,
|
|
657
|
+
source: consequent.value,
|
|
658
|
+
replacement: `$t('${key}') `,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
res.push(...this.handleConditionalExpression(node, consequent, prefix));
|
|
663
|
+
}
|
|
664
|
+
if (alternate.type === 'StringLiteral' && isChinese(alternate.value)) {
|
|
665
|
+
const key = getKeyByText(alternate.value, prefix);
|
|
666
|
+
res.push({
|
|
667
|
+
start: alternate.start + node.loc.start.offset - 1,
|
|
668
|
+
end: alternate.end + node.loc.start.offset - 1,
|
|
669
|
+
original: alternate.value,
|
|
670
|
+
source: alternate.value,
|
|
671
|
+
replacement: `$t('${key}')`,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
res.push(...this.handleConditionalExpression(node, alternate, prefix));
|
|
676
|
+
}
|
|
677
|
+
return res;
|
|
678
|
+
}
|
|
679
|
+
handleArrayExpression(node, ast, prefix) {
|
|
680
|
+
const res = [];
|
|
681
|
+
if (ast && ast.type === 'ArrayExpression') {
|
|
682
|
+
const elements = ast.elements;
|
|
683
|
+
elements.forEach((el) => {
|
|
684
|
+
if (isObject(el) && el.type === 'StringLiteral' && isChinese(el.value)) {
|
|
685
|
+
const key = getKeyByText(el.value, prefix);
|
|
686
|
+
res.push({
|
|
687
|
+
start: el.start + node.loc.start.offset - 1,
|
|
688
|
+
end: el.end + node.loc.start.offset - 1,
|
|
689
|
+
original: el.value,
|
|
690
|
+
source: el.value,
|
|
691
|
+
replacement: `$t('${key}')`,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
else if (isObject(el) && el.type === 'ArrayExpression') {
|
|
695
|
+
res.push(...this.handleArrayExpression(node, el, prefix));
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
return res;
|
|
700
|
+
}
|
|
701
|
+
setScriptContent(scriptContent) {
|
|
702
|
+
this.scriptContent = scriptContent;
|
|
703
|
+
}
|
|
704
|
+
setTemplateContent(templateContent) {
|
|
705
|
+
this.templateContent = templateContent;
|
|
706
|
+
}
|
|
707
|
+
process() {
|
|
708
|
+
const templateReplacements = this.processTemplate(this.templateContent, this.filePath);
|
|
709
|
+
const scriptReplacements = ScriptParser.parseScript(this.scriptContent, this.filePath);
|
|
710
|
+
const templateReplacedContent = new Replacer(templateReplacements).replace(this.templateContent, this.filePath);
|
|
711
|
+
const scriptReplacedContent = new Replacer(scriptReplacements).replace(this.scriptContent, this.filePath);
|
|
712
|
+
const replacedContent = this.rawContent.replace(this.templateContent, templateReplacedContent).replace(this.scriptContent, scriptReplacedContent);
|
|
713
|
+
return replacedContent;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
class parserFactory {
|
|
718
|
+
static getParser(filePath) {
|
|
719
|
+
if (path.extname(filePath) === '.vue') {
|
|
720
|
+
return new VueParser(filePath);
|
|
721
|
+
}
|
|
722
|
+
else if (['.js', '.ts', '.jsx', '.tsx'].includes(path.extname(filePath))) {
|
|
723
|
+
return new ScriptParser(filePath);
|
|
724
|
+
}
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const chineseRegexp = /[\u4e00-\u9fa5]/g;
|
|
730
|
+
/**
|
|
731
|
+
* 判断文本是否已被翻译函数包裹
|
|
732
|
+
* t('xx')
|
|
733
|
+
* $t('xx')
|
|
734
|
+
* :title="$t('xx')"
|
|
735
|
+
* label: t('xx')
|
|
736
|
+
*/
|
|
737
|
+
function isWrappedByT(line) {
|
|
738
|
+
// t( 或 $t(
|
|
739
|
+
const tRegexp = /\$t\s*\(|\bt\s*\(/;
|
|
740
|
+
return tRegexp.test(line);
|
|
741
|
+
}
|
|
742
|
+
class Scanner {
|
|
743
|
+
sourceDir = "";
|
|
744
|
+
constructor(sourceDir) {
|
|
745
|
+
this.sourceDir = sourceDir;
|
|
746
|
+
}
|
|
747
|
+
async scan() {
|
|
748
|
+
const files = await fg([this.sourceDir + "/**/*.{vue,js,ts,jsx,tsx}"]);
|
|
749
|
+
return files;
|
|
750
|
+
}
|
|
751
|
+
scanFileForUntranslated(filePath) {
|
|
752
|
+
const lines = fs.readFileSync(filePath, "utf-8").split("\n");
|
|
753
|
+
const untranslated = [];
|
|
754
|
+
lines.forEach((line, index) => {
|
|
755
|
+
if (chineseRegexp.test(line) && !isWrappedByT(line)) {
|
|
756
|
+
untranslated.push({
|
|
757
|
+
file: filePath,
|
|
758
|
+
line: index + 1,
|
|
759
|
+
text: line.trim(),
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
return untranslated;
|
|
764
|
+
}
|
|
765
|
+
scanDirectoryForChinese(dir) {
|
|
766
|
+
let results = [];
|
|
767
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
768
|
+
entries.forEach((entry) => {
|
|
769
|
+
const fullPath = `${dir}/${entry.name}`;
|
|
770
|
+
if (entry.isDirectory()) {
|
|
771
|
+
const untranslated = this.scanDirectoryForChinese(fullPath);
|
|
772
|
+
if (untranslated.length > 0) {
|
|
773
|
+
results.push(...untranslated);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
else if (entry.isFile() && /\.(vue|js|ts)$/.test(entry.name)) {
|
|
777
|
+
const untranslated = this.scanFileForUntranslated(fullPath);
|
|
778
|
+
if (untranslated.length > 0) {
|
|
779
|
+
results.push(...untranslated);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
return results;
|
|
784
|
+
}
|
|
785
|
+
scanProject(rootDir) {
|
|
786
|
+
const results = this.scanDirectoryForChinese(rootDir || this.sourceDir);
|
|
787
|
+
if (results.length === 0) {
|
|
788
|
+
console.log("🎉 未发现未国际化的中文!");
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
console.log(`\n🚨 发现 ${results.length} 处未国际化中文:\n`);
|
|
792
|
+
results.forEach((r) => {
|
|
793
|
+
console.log(`📄 ${r.file}`);
|
|
794
|
+
console.log(` 👉 行 ${r.line}: ${r.text}`);
|
|
795
|
+
});
|
|
796
|
+
console.log("\n⚠️ 请手动处理或加入自动国际化流程。\n");
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function findProjectConfig(targetPath) {
|
|
801
|
+
if (!fs.existsSync(targetPath)) {
|
|
802
|
+
console.warn(`Path does not exist: ${targetPath}`);
|
|
803
|
+
return null;
|
|
804
|
+
}
|
|
805
|
+
let dir = fs.statSync(targetPath).isDirectory()
|
|
806
|
+
? targetPath
|
|
807
|
+
: path.dirname(targetPath);
|
|
808
|
+
const root = path.parse(dir).root;
|
|
809
|
+
while (dir !== root) {
|
|
810
|
+
const configPath = path.join(dir, 'i18n.config.js');
|
|
811
|
+
if (fs.existsSync(configPath)) {
|
|
812
|
+
return configPath;
|
|
813
|
+
}
|
|
814
|
+
dir = path.dirname(dir);
|
|
815
|
+
}
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
async function loadProjectConfig(configPath) {
|
|
819
|
+
if (!configPath) {
|
|
820
|
+
return {};
|
|
821
|
+
}
|
|
822
|
+
const configUrl = pathToFileURL(configPath).href;
|
|
823
|
+
const config = await import(configUrl);
|
|
824
|
+
return config.default || {};
|
|
825
|
+
}
|
|
826
|
+
async function initContext() {
|
|
827
|
+
console.log("Initializing context...");
|
|
828
|
+
const targetPath = context.configPath || path.resolve('./');
|
|
829
|
+
const userConfig = await loadProjectConfig(findProjectConfig(targetPath));
|
|
830
|
+
context.config = merge(context.config, userConfig);
|
|
831
|
+
context.existingJson = getExistingJson(context.config.output.json);
|
|
832
|
+
context.lastIds = getLastKeyId(context.existingJson);
|
|
833
|
+
context.existingKeys = initExistingKeys(context.existingJson);
|
|
834
|
+
return context;
|
|
835
|
+
}
|
|
836
|
+
function initOptions(options) {
|
|
837
|
+
if (options.config) {
|
|
838
|
+
context.configPath = path.resolve(options.config);
|
|
839
|
+
}
|
|
840
|
+
context.config = merge(context.config, omit(options, 'config'));
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
async function run(options) {
|
|
844
|
+
initOptions(options || {});
|
|
845
|
+
await initContext();
|
|
846
|
+
const scanner = new Scanner(context.config.sourceDir);
|
|
847
|
+
if (context.config.scan) {
|
|
848
|
+
return scanner.scanProject();
|
|
849
|
+
}
|
|
850
|
+
const files = await scanner.scan();
|
|
851
|
+
for (const filePath of files) {
|
|
852
|
+
const parser = parserFactory.getParser(filePath);
|
|
853
|
+
if (parser) {
|
|
854
|
+
const replacedContent = parser.process();
|
|
855
|
+
Replacer.updateFile(filePath, replacedContent);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
console.log(`----------------------------------------`);
|
|
859
|
+
context.notReplaceFiles.forEach(item => {
|
|
860
|
+
console.log(`⚠️ 未替换内容: ${item.source.trim()},原因: ${item.reason}, 位置: ${item.filePath}`);
|
|
861
|
+
});
|
|
862
|
+
writeJsonToFile();
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const program = new commander.Command();
|
|
866
|
+
program
|
|
867
|
+
.version(`${packageJson.name} ${packageJson.version}`)
|
|
868
|
+
.description(packageJson.description)
|
|
869
|
+
.option('-s, --scan', '仅扫描未国际化的中文')
|
|
870
|
+
.option('-c, --config <path>', '指定配置文件 (默认: i18n.config.js)', 'i18n.config.js')
|
|
871
|
+
.option('--dry-run', '只分析,不写文件')
|
|
872
|
+
.action((options) => {
|
|
873
|
+
const start = performance.now();
|
|
874
|
+
run(options).finally(() => {
|
|
875
|
+
console.log(`\n⏱️ 耗时: ${(performance.now() - start).toFixed(2)} ms`);
|
|
876
|
+
});
|
|
877
|
+
})
|
|
878
|
+
.parse();
|
|
879
|
+
//# sourceMappingURL=main.esm.js.map
|