@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.
@@ -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