babaofan-translate-cli 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.
Files changed (4) hide show
  1. package/README.md +728 -0
  2. package/bin/i18n.js +1450 -0
  3. package/config.js +1 -0
  4. package/package.json +41 -0
package/bin/i18n.js ADDED
@@ -0,0 +1,1450 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Cool Unix 国际化翻译脚本
5
+ * 用于扫描项目中的 i18n 调用,自动生成多语言翻译文件
6
+ */
7
+
8
+ import { program } from "commander";
9
+ import chalk from "chalk";
10
+ import path from "path";
11
+ import fs from "fs";
12
+ import { globSync } from "glob";
13
+ import { parse as babelParse } from "@babel/parser";
14
+ import { GoogleGenAI } from "@google/genai";
15
+ import { isEmpty } from "lodash-es";
16
+ import { parse as parseSfc } from "@vue/compiler-sfc";
17
+ import { defaultGoogleApiKey } from "../config.js";
18
+ import { getProjectRoot } from "../utils.js";
19
+
20
+ // ============================================================================
21
+ // 配置常量
22
+ // ============================================================================
23
+
24
+ const PROJECT_ROOT = getProjectRoot();
25
+ const DEFAULT_BATCH_SIZE = 20;
26
+ const DEFAULT_GEMINI_MODEL = "gemini-2.5-flash";
27
+ const SOURCE_LOCALE = "zh-cn";
28
+ const DEFAULT_OUTPUT_DIR = "locales";
29
+ const SUPPORTED_EXTENSIONS = new Set([
30
+ ".js",
31
+ ".jsx",
32
+ ".mjs",
33
+ ".cjs",
34
+ ".ts",
35
+ ".tsx",
36
+ ".mts",
37
+ ".cts",
38
+ ".vue",
39
+ ".nvue",
40
+ ".uvue",
41
+ ".uts"
42
+ ]);
43
+ const BABEL_PLUGINS = ["typescript", "jsx"];
44
+ const COMMON_SCAN_EXTENSIONS = "js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,nvue,uvue,uts";
45
+ const LOCALE_CONFIG_PATTERNS = [
46
+ "plugins/locale.{js,ts,mjs,cjs}",
47
+ "src/plugins/locale.{js,ts,mjs,cjs}",
48
+ "src/i18n/**/*.{js,ts,mjs,cjs,json}",
49
+ "i18n/**/*.{js,ts,mjs,cjs,json}",
50
+ "config/**/*locale*.{js,ts,mjs,cjs,json}",
51
+ "locales/**/*.{js,ts,mjs,cjs,json}"
52
+ ];
53
+ const LANGUAGE_KEYS = ["languages", "locales", "supportedLocales", "availableLocales", "supportedLngs"];
54
+ const LOCALE_OUTPUT_FORMATS = new Set(["auto", "pair-array", "object"]);
55
+ const PREVIEW_LIMIT = 10;
56
+ const DEFAULT_IGNORED_DIRS = [
57
+ "node_modules",
58
+ "dist",
59
+ "build",
60
+ "coverage",
61
+ ".next",
62
+ ".nuxt",
63
+ ".output",
64
+ ".turbo",
65
+ ".git",
66
+ DEFAULT_OUTPUT_DIR
67
+ ];
68
+
69
+ const LANGUAGE_NAMES = {
70
+ "zh-cn": "简体中文",
71
+ "zh-tw": "繁體中文",
72
+ en: "English",
73
+ es: "Español",
74
+ ja: "日本語",
75
+ ko: "한국어",
76
+ fr: "Français",
77
+ de: "Deutsch",
78
+ pt: "Português",
79
+ ru: "Русский",
80
+ ar: "العربية",
81
+ th: "ไทย",
82
+ vi: "Tiếng Việt"
83
+ };
84
+
85
+ // ============================================================================
86
+ // 终端日志工具
87
+ // ============================================================================
88
+
89
+ const logger = {
90
+ title(text) {
91
+ console.log();
92
+ console.log(chalk.white.bold(text));
93
+ console.log(chalk.gray("─".repeat(50)));
94
+ },
95
+
96
+ info(label, value) {
97
+ console.log(chalk.gray(" ●"), chalk.white(label), chalk.cyan(value));
98
+ },
99
+
100
+ success(text) {
101
+ console.log(chalk.green(" ✓"), chalk.white(text));
102
+ },
103
+
104
+ warn(text) {
105
+ console.log(chalk.yellow(" ⚠"), chalk.white(text));
106
+ },
107
+
108
+ error(text) {
109
+ console.log(chalk.red(" ✗"), chalk.white(text));
110
+ },
111
+
112
+ languages(langs) {
113
+ const formatted = langs
114
+ .map((lang) => {
115
+ const name = LANGUAGE_NAMES[lang] || lang;
116
+ return chalk.cyan(name) + chalk.gray(` (${lang})`);
117
+ })
118
+ .join(chalk.gray(" · "));
119
+ console.log(chalk.gray(" ●"), chalk.white("语言"), formatted);
120
+ },
121
+
122
+ translating(locale, current, total) {
123
+ const name = LANGUAGE_NAMES[locale] || locale;
124
+ const percent = total === 0 ? 100 : Math.round((current / total) * 100);
125
+ const filled = Math.floor(percent / 5);
126
+ const bar = chalk.cyan("█".repeat(filled)) + chalk.gray("░".repeat(20 - filled));
127
+
128
+ process.stdout.clearLine?.(0);
129
+ process.stdout.cursorTo?.(0);
130
+ process.stdout.write(
131
+ ` ${chalk.cyan("⟳")} ${chalk.white("翻译中")} ${bar} ${chalk.green(percent + "%")} ${chalk.gray("→")} ${chalk.cyan(name)}`
132
+ );
133
+ },
134
+
135
+ translateDone() {
136
+ process.stdout.clearLine?.(0);
137
+ process.stdout.cursorTo?.(0);
138
+ console.log(` ${chalk.green("✓")} ${chalk.white("翻译完成")}`);
139
+ },
140
+
141
+ file(filePath) {
142
+ const relativePath = filePath.replace(PROJECT_ROOT, "").replace(/^[\\/]/, "");
143
+ console.log(chalk.gray(" +"), chalk.green(relativePath));
144
+ },
145
+
146
+ divider() {
147
+ console.log(chalk.gray("─".repeat(50)));
148
+ },
149
+
150
+ newline() {
151
+ console.log();
152
+ },
153
+
154
+ progress(label, current, total, size) {
155
+ console.log(
156
+ chalk.gray(" ⟳"),
157
+ chalk.white(label),
158
+ chalk.cyan(`第 ${current}/${total} 批`),
159
+ chalk.gray(`(${size} 条)`)
160
+ );
161
+ }
162
+ };
163
+
164
+ // ============================================================================
165
+ // 工具函数
166
+ // ============================================================================
167
+
168
+ function getProjectLanguages() {
169
+ return [];
170
+ }
171
+
172
+ function ensureDirectoryExists(filePath) {
173
+ const dirPath = path.dirname(filePath);
174
+ if (!fs.existsSync(dirPath)) {
175
+ fs.mkdirSync(dirPath, { recursive: true });
176
+ }
177
+ }
178
+
179
+ function normalizeContentText(text) {
180
+ return String(text || "").replace(/^page\./, "");
181
+ }
182
+
183
+ function hashString(text) {
184
+ let hash = 2166136261;
185
+
186
+ for (let i = 0; i < text.length; i++) {
187
+ hash ^= text.charCodeAt(i);
188
+ hash = Math.imul(hash, 16777619);
189
+ }
190
+
191
+ return (hash >>> 0).toString(36);
192
+ }
193
+
194
+ function normalizeKeySegment(value) {
195
+ return String(value || "")
196
+ .toLowerCase()
197
+ .replace(/\.[^.]+$/, "")
198
+ .replace(/[^a-z0-9-]+/g, "-")
199
+ .replace(/^-+|-+$/g, "")
200
+ .replace(/-{2,}/g, "-");
201
+ }
202
+
203
+ function buildKeyNamespace(filePath) {
204
+ const relativePath = path.relative(PROJECT_ROOT, filePath).replace(/\\/g, "/");
205
+ const rawSegments = relativePath.split("/").filter(Boolean);
206
+ const ignoredSegments = new Set([
207
+ "src",
208
+ "app",
209
+ "pages",
210
+ "page",
211
+ "components",
212
+ "component",
213
+ "views",
214
+ "view",
215
+ "locales",
216
+ "tests",
217
+ "test",
218
+ "spec",
219
+ "__tests__"
220
+ ]);
221
+ const compressibleSegmentPatterns = [
222
+ /^tmp(?:-.+)?$/,
223
+ /^temp(?:-.+)?$/,
224
+ /^test(?:-.+)?$/,
225
+ /^spec(?:-.+)?$/,
226
+ /^playground(?:-.+)?$/,
227
+ /^sandbox(?:-.+)?$/,
228
+ /^example(?:s)?(?:-.+)?$/,
229
+ /^demo(?:-.+)?$/
230
+ ];
231
+
232
+ const normalizedSegments = rawSegments
233
+ .map((segment, index) => {
234
+ const value = index === rawSegments.length - 1 ? segment.replace(/\.[^.]+$/, "") : segment;
235
+ return normalizeKeySegment(value);
236
+ })
237
+ .filter(Boolean)
238
+ .filter((segment) => !ignoredSegments.has(segment))
239
+ .filter((segment, index, segments) => {
240
+ if (segments.length <= 1) {
241
+ return true;
242
+ }
243
+ return !compressibleSegmentPatterns.some((pattern) => pattern.test(segment));
244
+ });
245
+
246
+ return normalizedSegments.slice(-2).join(".") || "common";
247
+ }
248
+
249
+ function normalizeGeneratedKey(value, namespace, sourceText, usedKeys) {
250
+ const normalized = String(value || "")
251
+ .toLowerCase()
252
+ .replace(/[^a-z0-9]+/g, ".")
253
+ .replace(/^\.+|\.+$/g, "")
254
+ .replace(/\.{2,}/g, ".");
255
+ const baseKey = normalized
256
+ ? normalized.startsWith(`${namespace}.`) || normalized === namespace
257
+ ? normalized
258
+ : `${namespace}.${normalized}`
259
+ : `${namespace}.text`;
260
+ const textHash = hashString(sourceText).slice(0, 6);
261
+ let key = baseKey;
262
+
263
+ if (usedKeys.has(key) && usedKeys.get(key) !== sourceText) {
264
+ key = `${baseKey}.${textHash}`;
265
+ }
266
+
267
+ let suffix = 1;
268
+ while (usedKeys.has(key) && usedKeys.get(key) !== sourceText) {
269
+ suffix += 1;
270
+ key = `${baseKey}.${textHash}.${suffix}`;
271
+ }
272
+
273
+ usedKeys.set(key, sourceText);
274
+ return key;
275
+ }
276
+
277
+ function parseJsonArrayResponse(responseText) {
278
+ const cleanedText = String(responseText || "")
279
+ .replace(/```json/gi, "```")
280
+ .replace(/```/g, "")
281
+ .trim();
282
+
283
+ const parsed = JSON.parse(cleanedText);
284
+
285
+ if (!Array.isArray(parsed)) {
286
+ throw new Error("模型返回结果不是 JSON 数组");
287
+ }
288
+
289
+ return parsed;
290
+ }
291
+
292
+ async function generateSemanticKeysWithGemini(client, items, model) {
293
+ const prompt = [
294
+ "你是一个专业的 i18n key 命名助手。",
295
+ "请根据给定的文件命名空间和中文文案,为每一项生成一个英文小写 dot.case key。",
296
+ "要求:",
297
+ "1. 只返回 JSON 数组字符串,不要 Markdown,不要解释。",
298
+ "2. 数组长度必须和输入一致,并保持顺序。",
299
+ "3. key 必须使用英文语义命名,不能包含中文、空格、连字符或下划线。",
300
+ "4. key 必须以 namespace 开头。",
301
+ "5. key 要简洁可读,优先表达 UI 含义,例如 button.title、empty.state.title、form.submit.success。",
302
+ "6. 不要使用拼音,不要直接音译中文。",
303
+ `输入内容:${JSON.stringify(items)}`
304
+ ].join("\n");
305
+
306
+ const response = await client.models.generateContent({
307
+ model,
308
+ contents: prompt
309
+ });
310
+
311
+ const responseText =
312
+ typeof response.text === "function" ? response.text() : response.text || "";
313
+ const keys = parseJsonArrayResponse(responseText);
314
+
315
+ if (keys.length !== items.length) {
316
+ throw new Error(`模型返回 key 数量异常,期望 ${items.length} 条,实际 ${keys.length} 条`);
317
+ }
318
+
319
+ return keys.map((key) => String(key ?? ""));
320
+ }
321
+
322
+ function resolveApiKey(cliApiKey) {
323
+ return (
324
+ cliApiKey ||
325
+ defaultGoogleApiKey ||
326
+ process.env.GEMINI_API_KEY ||
327
+ process.env.GOOGLE_AI_STUDIO_API_KEY ||
328
+ process.env.GOOGLE_API_KEY ||
329
+ ""
330
+ ).trim();
331
+ }
332
+
333
+ function parseBatchSize(value) {
334
+ const batchSize = Number.parseInt(value, 10);
335
+ if (!Number.isInteger(batchSize) || batchSize <= 0) {
336
+ throw new Error("batch-size 必须是大于 0 的整数");
337
+ }
338
+ return batchSize;
339
+ }
340
+
341
+ function parseFilePatterns(value) {
342
+ return value
343
+ .split(",")
344
+ .map((item) => item.trim())
345
+ .filter(Boolean);
346
+ }
347
+
348
+ function parseLanguages(value) {
349
+ return value
350
+ .split(",")
351
+ .map((item) => item.trim())
352
+ .filter(Boolean);
353
+ }
354
+
355
+ function parseOutputFormat(value) {
356
+ const format = String(value || "auto").trim().toLowerCase();
357
+ if (!LOCALE_OUTPUT_FORMATS.has(format)) {
358
+ throw new Error(`output-format 仅支持 ${[...LOCALE_OUTPUT_FORMATS].join(" / ")}`);
359
+ }
360
+ return format;
361
+ }
362
+
363
+ function safeReadJsonFile(filePath) {
364
+ if (!fs.existsSync(filePath)) {
365
+ return null;
366
+ }
367
+
368
+ try {
369
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
370
+ } catch (error) {
371
+ logger.warn(`JSON 解析失败,已忽略 ${path.relative(PROJECT_ROOT, filePath)}: ${error.message}`);
372
+ return null;
373
+ }
374
+ }
375
+
376
+ function resolveScanTargets(modulePath, filePatterns = []) {
377
+ if (!filePatterns.length) {
378
+ const baseDir = path.join(PROJECT_ROOT, modulePath || "");
379
+ return [baseDir];
380
+ }
381
+
382
+ return filePatterns.map((pattern) => {
383
+ if (path.isAbsolute(pattern)) {
384
+ return pattern;
385
+ }
386
+ return path.join(PROJECT_ROOT, pattern);
387
+ });
388
+ }
389
+
390
+ function shouldIgnoreFile(filePath, includeUniModules = false) {
391
+ const relativePath = path.relative(PROJECT_ROOT, filePath).replace(/\\/g, "/");
392
+ const segments = relativePath.split("/").filter(Boolean);
393
+
394
+ if (!includeUniModules && segments.includes("uni_modules")) {
395
+ return true;
396
+ }
397
+
398
+ return DEFAULT_IGNORED_DIRS.some((dirName) => segments.includes(dirName));
399
+ }
400
+
401
+ function expandScanFiles(targets, includeUniModules = false) {
402
+ const files = new Set();
403
+
404
+ for (const target of targets) {
405
+ const resolvedTarget = path.resolve(target);
406
+
407
+ if (fs.existsSync(resolvedTarget)) {
408
+ const stat = fs.statSync(resolvedTarget);
409
+
410
+ if (stat.isDirectory()) {
411
+ const matchedFiles = globSync(
412
+ `${resolvedTarget.replace(/\\/g, "/")}/**/*.{${COMMON_SCAN_EXTENSIONS}}`,
413
+ {
414
+ nodir: true,
415
+ ignore: DEFAULT_IGNORED_DIRS.map((dirName) => `**/${dirName}/**`)
416
+ }
417
+ );
418
+ matchedFiles.forEach((file) => files.add(path.resolve(file)));
419
+ continue;
420
+ }
421
+
422
+ if (stat.isFile() && SUPPORTED_EXTENSIONS.has(path.extname(resolvedTarget))) {
423
+ files.add(resolvedTarget);
424
+ }
425
+ continue;
426
+ }
427
+
428
+ const matchedFiles = globSync(target.replace(/\\/g, "/"), {
429
+ nodir: true,
430
+ absolute: true,
431
+ ignore: DEFAULT_IGNORED_DIRS.map((dirName) => `**/${dirName}/**`)
432
+ });
433
+
434
+ for (const file of matchedFiles) {
435
+ if (SUPPORTED_EXTENSIONS.has(path.extname(file))) {
436
+ files.add(path.resolve(file));
437
+ }
438
+ }
439
+ }
440
+
441
+ return [...files].filter((file) => !shouldIgnoreFile(file, includeUniModules));
442
+ }
443
+
444
+ function walkAstNode(node, visitor) {
445
+ if (!node || typeof node !== "object") {
446
+ return;
447
+ }
448
+
449
+ visitor(node);
450
+
451
+ if (Array.isArray(node)) {
452
+ node.forEach((item) => walkAstNode(item, visitor));
453
+ return;
454
+ }
455
+
456
+ for (const value of Object.values(node)) {
457
+ if (!value || typeof value !== "object") {
458
+ continue;
459
+ }
460
+ walkAstNode(value, visitor);
461
+ }
462
+ }
463
+
464
+ function getStaticCallArgument(node) {
465
+ if (!node) {
466
+ return null;
467
+ }
468
+
469
+ if (node.type === "StringLiteral") {
470
+ return node.value;
471
+ }
472
+
473
+ if (node.type === "TemplateLiteral" && node.expressions.length === 0) {
474
+ return node.quasis.map((item) => item.value.cooked || "").join("");
475
+ }
476
+
477
+ return null;
478
+ }
479
+
480
+ function isTargetI18nCallee(node) {
481
+ if (!node) {
482
+ return false;
483
+ }
484
+
485
+ if (node.type === "Identifier") {
486
+ return node.name === "t" || node.name === "$t";
487
+ }
488
+
489
+ if (
490
+ node.type === "MemberExpression" &&
491
+ !node.computed &&
492
+ node.property?.type === "Identifier" &&
493
+ (node.property.name === "t" || node.property.name === "$t")
494
+ ) {
495
+ return true;
496
+ }
497
+
498
+ return false;
499
+ }
500
+
501
+ function collectI18nKeysFromJavaScriptAst(ast) {
502
+ const keys = [];
503
+
504
+ walkAstNode(ast, (node) => {
505
+ if (node.type !== "CallExpression" || !isTargetI18nCallee(node.callee)) {
506
+ return;
507
+ }
508
+
509
+ const key = getStaticCallArgument(node.arguments?.[0]);
510
+ if (key) {
511
+ keys.push(key);
512
+ }
513
+ });
514
+
515
+ return keys;
516
+ }
517
+
518
+ function collectI18nCallsFromJavaScriptAst(ast, filePath, baseOffset = 0, indexShift = 0) {
519
+ const calls = [];
520
+
521
+ walkAstNode(ast, (node) => {
522
+ if (node.type !== "CallExpression" || !isTargetI18nCallee(node.callee)) {
523
+ return;
524
+ }
525
+
526
+ const argumentNode = node.arguments?.[0];
527
+ const sourceText = getStaticCallArgument(argumentNode);
528
+
529
+ if (!sourceText || typeof argumentNode?.start !== "number" || typeof argumentNode?.end !== "number") {
530
+ return;
531
+ }
532
+
533
+ calls.push({
534
+ filePath,
535
+ sourceText,
536
+ start: baseOffset + argumentNode.start - indexShift,
537
+ end: baseOffset + argumentNode.end - indexShift
538
+ });
539
+ });
540
+
541
+ return calls;
542
+ }
543
+
544
+ function parseJavaScriptSource(source, filePath, sourceType = "module") {
545
+ return babelParse(source, {
546
+ sourceType,
547
+ errorRecovery: true,
548
+ plugins: BABEL_PLUGINS,
549
+ sourceFilename: filePath
550
+ });
551
+ }
552
+
553
+ function collectI18nKeysFromScriptContent(source, filePath, sourceType = "module") {
554
+ if (!source?.trim()) {
555
+ return [];
556
+ }
557
+
558
+ try {
559
+ const ast = parseJavaScriptSource(source, filePath, sourceType);
560
+ return collectI18nKeysFromJavaScriptAst(ast);
561
+ } catch (error) {
562
+ logger.warn(`AST 解析失败,已跳过 ${path.relative(PROJECT_ROOT, filePath)}: ${error.message}`);
563
+ return [];
564
+ }
565
+ }
566
+
567
+ function collectI18nCallsFromScriptContent(source, filePath, baseOffset = 0, sourceType = "module") {
568
+ if (!source?.trim()) {
569
+ return [];
570
+ }
571
+
572
+ try {
573
+ const ast = parseJavaScriptSource(source, filePath, sourceType);
574
+ return collectI18nCallsFromJavaScriptAst(ast, filePath, baseOffset);
575
+ } catch (error) {
576
+ logger.warn(`AST 解析失败,已跳过 ${path.relative(PROJECT_ROOT, filePath)}: ${error.message}`);
577
+ return [];
578
+ }
579
+ }
580
+
581
+ function parseTemplateExpression(expression, filePath) {
582
+ try {
583
+ return {
584
+ ast: parseJavaScriptSource(`(${expression})`, filePath, "module"),
585
+ indexShift: 1
586
+ };
587
+ } catch {
588
+ try {
589
+ return {
590
+ ast: parseJavaScriptSource(expression, filePath, "module"),
591
+ indexShift: 0
592
+ };
593
+ } catch (error) {
594
+ logger.warn(`模板表达式解析失败,已跳过 ${path.relative(PROJECT_ROOT, filePath)}: ${error.message}`);
595
+ return null;
596
+ }
597
+ }
598
+ }
599
+
600
+ function collectI18nKeysFromVueTemplateAst(templateAst, filePath) {
601
+ const keys = [];
602
+
603
+ walkAstNode(templateAst, (node) => {
604
+ const isInterpolation = node?.type === 5 && typeof node.content?.content === "string";
605
+ const isDirectiveExpression =
606
+ node?.type === 7 && typeof node.exp?.content === "string" && node.exp.content.trim();
607
+
608
+ if (!isInterpolation && !isDirectiveExpression) {
609
+ return;
610
+ }
611
+
612
+ const expression = isInterpolation ? node.content.content : node.exp.content;
613
+ const expressionAst = parseTemplateExpression(expression, filePath);
614
+
615
+ if (!expressionAst) {
616
+ return;
617
+ }
618
+
619
+ keys.push(...collectI18nKeysFromJavaScriptAst(expressionAst));
620
+ });
621
+
622
+ return keys;
623
+ }
624
+
625
+ function collectI18nCallsFromVueTemplateAst(templateAst, filePath) {
626
+ const calls = [];
627
+
628
+ walkAstNode(templateAst, (node) => {
629
+ const isInterpolation = node?.type === 5 && typeof node.content?.content === "string";
630
+ const isDirectiveExpression =
631
+ node?.type === 7 && typeof node.exp?.content === "string" && node.exp.content.trim();
632
+
633
+ if (!isInterpolation && !isDirectiveExpression) {
634
+ return;
635
+ }
636
+
637
+ const expressionNode = isInterpolation ? node.content : node.exp;
638
+ const expression = expressionNode.content;
639
+ const parsedExpression = parseTemplateExpression(expression, filePath);
640
+
641
+ if (!parsedExpression) {
642
+ return;
643
+ }
644
+
645
+ calls.push(
646
+ ...collectI18nCallsFromJavaScriptAst(
647
+ parsedExpression.ast,
648
+ filePath,
649
+ expressionNode.loc.start.offset,
650
+ parsedExpression.indexShift
651
+ )
652
+ );
653
+ });
654
+
655
+ return calls;
656
+ }
657
+
658
+ function collectI18nKeysFromFile(filePath) {
659
+ const extension = path.extname(filePath);
660
+ const source = fs.readFileSync(filePath, "utf8");
661
+
662
+ if (extension === ".vue" || extension === ".nvue" || extension === ".uvue") {
663
+ try {
664
+ const { descriptor } = parseSfc(source, { filename: filePath });
665
+ return [
666
+ ...collectI18nKeysFromScriptContent(descriptor.script?.content || "", filePath),
667
+ ...collectI18nKeysFromScriptContent(descriptor.scriptSetup?.content || "", filePath),
668
+ ...collectI18nKeysFromVueTemplateAst(descriptor.template?.ast, filePath)
669
+ ];
670
+ } catch (error) {
671
+ logger.warn(`SFC 解析失败,已跳过 ${path.relative(PROJECT_ROOT, filePath)}: ${error.message}`);
672
+ return [];
673
+ }
674
+ }
675
+
676
+ return collectI18nKeysFromScriptContent(source, filePath);
677
+ }
678
+
679
+ function collectI18nCallsFromFile(filePath) {
680
+ const extension = path.extname(filePath);
681
+ const source = fs.readFileSync(filePath, "utf8");
682
+
683
+ if (extension === ".vue" || extension === ".nvue" || extension === ".uvue") {
684
+ try {
685
+ const { descriptor } = parseSfc(source, { filename: filePath });
686
+ return [
687
+ ...collectI18nCallsFromScriptContent(
688
+ descriptor.script?.content || "",
689
+ filePath,
690
+ descriptor.script?.loc.start.offset || 0
691
+ ),
692
+ ...collectI18nCallsFromScriptContent(
693
+ descriptor.scriptSetup?.content || "",
694
+ filePath,
695
+ descriptor.scriptSetup?.loc.start.offset || 0
696
+ ),
697
+ ...collectI18nCallsFromVueTemplateAst(descriptor.template?.ast, filePath)
698
+ ];
699
+ } catch (error) {
700
+ logger.warn(`SFC 解析失败,已跳过 ${path.relative(PROJECT_ROOT, filePath)}: ${error.message}`);
701
+ return [];
702
+ }
703
+ }
704
+
705
+ return collectI18nCallsFromScriptContent(source, filePath);
706
+ }
707
+
708
+ async function buildRewritePlan(calls, options, progressLabel = "语义命名") {
709
+ const usedKeys = new Map();
710
+ const textToKey = new Map();
711
+ const rewritesByFile = new Map();
712
+ const entries = [];
713
+ const uniqueCalls = [];
714
+
715
+ for (const call of calls) {
716
+ if (!textToKey.has(call.sourceText)) {
717
+ uniqueCalls.push(call);
718
+ }
719
+ }
720
+
721
+ const keyInputs = uniqueCalls.map((call) => ({
722
+ namespace: buildKeyNamespace(call.filePath),
723
+ text: normalizeContentText(call.sourceText),
724
+ filePath: path.relative(PROJECT_ROOT, call.filePath).replace(/\\/g, "/")
725
+ }));
726
+ const generatedKeys = [];
727
+ const totalBatches = Math.ceil(keyInputs.length / options.batchSize);
728
+
729
+ for (let i = 0; i < keyInputs.length; i += options.batchSize) {
730
+ const batchInputs = keyInputs.slice(i, i + options.batchSize);
731
+ if (!batchInputs.length) {
732
+ continue;
733
+ }
734
+ const batchIndex = Math.floor(i / options.batchSize) + 1;
735
+ logger.progress(progressLabel, batchIndex, totalBatches, batchInputs.length);
736
+ let batchKeys;
737
+ try {
738
+ batchKeys = await generateSemanticKeysWithGemini(
739
+ options.client,
740
+ batchInputs,
741
+ options.model
742
+ );
743
+ } catch (error) {
744
+ throw new Error(
745
+ `${progressLabel}失败:第 ${batchIndex}/${totalBatches} 批(${batchInputs.length} 条)执行失败。${error.message || error}`
746
+ );
747
+ }
748
+ generatedKeys.push(...batchKeys);
749
+ }
750
+
751
+ uniqueCalls.forEach((call, index) => {
752
+ const normalizedText = normalizeContentText(call.sourceText);
753
+ const namespace = buildKeyNamespace(call.filePath);
754
+ const key = normalizeGeneratedKey(generatedKeys[index], namespace, normalizedText, usedKeys);
755
+ textToKey.set(call.sourceText, key);
756
+ entries.push({
757
+ key,
758
+ sourceText: normalizedText
759
+ });
760
+ });
761
+
762
+ for (const call of calls) {
763
+ const key = textToKey.get(call.sourceText);
764
+
765
+ if (!rewritesByFile.has(call.filePath)) {
766
+ rewritesByFile.set(call.filePath, []);
767
+ }
768
+
769
+ rewritesByFile.get(call.filePath).push({
770
+ start: call.start,
771
+ end: call.end,
772
+ replacement: JSON.stringify(key)
773
+ });
774
+ }
775
+
776
+ return {
777
+ entries,
778
+ rewritesByFile
779
+ };
780
+ }
781
+
782
+ function applySourceRewrites(rewritesByFile) {
783
+ for (const [filePath, rewrites] of rewritesByFile.entries()) {
784
+ if (!rewrites.length) {
785
+ continue;
786
+ }
787
+
788
+ const source = fs.readFileSync(filePath, "utf8");
789
+ const sortedRewrites = [...rewrites].sort((a, b) => b.start - a.start);
790
+ let nextSource = source;
791
+
792
+ for (const rewrite of sortedRewrites) {
793
+ nextSource =
794
+ nextSource.slice(0, rewrite.start) +
795
+ rewrite.replacement +
796
+ nextSource.slice(rewrite.end);
797
+ }
798
+
799
+ if (nextSource !== source) {
800
+ fs.writeFileSync(filePath, nextSource, "utf8");
801
+ logger.file(filePath);
802
+ }
803
+ }
804
+ }
805
+
806
+ function extractLanguageArrays(content) {
807
+ const languages = new Set();
808
+
809
+ for (const key of LANGUAGE_KEYS) {
810
+ const pattern = new RegExp(`${key}\\s*[:=]\\s*\\[([\\s\\S]*?)\\]`, "g");
811
+ let match;
812
+
813
+ while ((match = pattern.exec(content)) !== null) {
814
+ const values = match[1].match(/['"`]([a-z]{2,3}(?:-[a-z0-9]{2,8})?)['"`]/gi) || [];
815
+ values.forEach((value) => {
816
+ const normalized = value.replace(/['"`]/g, "").toLowerCase();
817
+ languages.add(normalized);
818
+ });
819
+ }
820
+ }
821
+
822
+ return [...languages];
823
+ }
824
+
825
+ function detectProjectLanguages() {
826
+ const found = new Set();
827
+
828
+ for (const pattern of LOCALE_CONFIG_PATTERNS) {
829
+ const files = globSync(path.join(PROJECT_ROOT, pattern).replace(/\\/g, "/"), {
830
+ nodir: true,
831
+ absolute: true
832
+ });
833
+
834
+ for (const file of files) {
835
+ const content = fs.readFileSync(file, "utf8");
836
+ extractLanguageArrays(content).forEach((language) => found.add(language));
837
+ }
838
+ }
839
+
840
+ return [...found];
841
+ }
842
+
843
+ function resolveLanguages(options) {
844
+ const languages = options.languages.length ? options.languages : detectProjectLanguages();
845
+
846
+ if (!languages.length) {
847
+ logger.newline();
848
+ logger.error("未检测到项目语言配置,请使用 --languages 手动传入,例如 zh-cn,en,ja");
849
+ logger.newline();
850
+ process.exit(1);
851
+ }
852
+
853
+ if (!languages.includes(options.sourceLocale)) {
854
+ return [options.sourceLocale, ...languages];
855
+ }
856
+
857
+ return languages;
858
+ }
859
+
860
+ function detectProjectMode(modulePath) {
861
+ if (modulePath) {
862
+ return "uni-module";
863
+ }
864
+
865
+ if (fs.existsSync(path.join(PROJECT_ROOT, "plugins", "locale.ts"))) {
866
+ return "uniapp";
867
+ }
868
+
869
+ return "generic";
870
+ }
871
+
872
+ function resolveOutputFormat(options, projectMode) {
873
+ if (options.outputFormat !== "auto") {
874
+ return options.outputFormat;
875
+ }
876
+
877
+ return projectMode === "uniapp" || projectMode === "uni-module" ? "pair-array" : "object";
878
+ }
879
+
880
+ function serializeLocaleEntries(translations, langCode, outputFormat, sourceLocale) {
881
+ if (outputFormat === "pair-array") {
882
+ return Object.entries(translations).map(([key, value]) => {
883
+ return [key, value || ""];
884
+ });
885
+ }
886
+
887
+ return Object.fromEntries(Object.entries(translations).map(([key, value]) => [key, value || ""]));
888
+ }
889
+
890
+ function parseLocaleFileToObject(filePath) {
891
+ const data = safeReadJsonFile(filePath);
892
+
893
+ if (!data) {
894
+ return {};
895
+ }
896
+
897
+ if (Array.isArray(data)) {
898
+ return Object.fromEntries(
899
+ data.filter((item) => Array.isArray(item) && item.length >= 2).map(([key, value]) => [key, value || ""])
900
+ );
901
+ }
902
+
903
+ if (typeof data === "object") {
904
+ return Object.fromEntries(Object.entries(data).map(([key, value]) => [key, value || ""]));
905
+ }
906
+
907
+ return {};
908
+ }
909
+
910
+ function mergeLocaleTranslations(filePath, translations) {
911
+ return {
912
+ ...parseLocaleFileToObject(filePath),
913
+ ...translations
914
+ };
915
+ }
916
+
917
+ function logDryRunPreview(translationData, options, languages) {
918
+ const entries = Object.values(translationData.data).flat();
919
+ const rewriteFiles = [...translationData.rewritesByFile.keys()].map((filePath) =>
920
+ path.relative(PROJECT_ROOT, filePath).replace(/\\/g, "/")
921
+ );
922
+
923
+ logger.title("预览结果");
924
+ logger.info("改写文件", String(rewriteFiles.length));
925
+ logger.info("新增词条", String(entries.length));
926
+
927
+ if (rewriteFiles.length) {
928
+ rewriteFiles.slice(0, PREVIEW_LIMIT).forEach((filePath) => {
929
+ console.log(chalk.gray(" ·"), chalk.green(filePath));
930
+ });
931
+ if (rewriteFiles.length > PREVIEW_LIMIT) {
932
+ console.log(chalk.gray(" ·"), chalk.white(`还有 ${rewriteFiles.length - PREVIEW_LIMIT} 个文件未展开`));
933
+ }
934
+ }
935
+
936
+ if (entries.length) {
937
+ logger.newline();
938
+ logger.info("Key 预览", `${Math.min(entries.length, PREVIEW_LIMIT)}/${entries.length}`);
939
+ entries.slice(0, PREVIEW_LIMIT).forEach((entry) => {
940
+ console.log(chalk.gray(" ·"), chalk.cyan(entry.key), chalk.gray("←"), chalk.white(entry.sourceText));
941
+ });
942
+ }
943
+
944
+ logger.newline();
945
+ logger.info("Locale 文件", "");
946
+ languages.forEach((langCode) => {
947
+ const filePath = path.join(PROJECT_ROOT, options.outputDir, `${langCode}.json`);
948
+ const status = fs.existsSync(filePath) ? "合并已有文件" : "新建文件";
949
+ console.log(
950
+ chalk.gray(" ·"),
951
+ chalk.green(path.relative(PROJECT_ROOT, filePath).replace(/\\/g, "/")),
952
+ chalk.gray(`(${status})`)
953
+ );
954
+ });
955
+
956
+ logger.newline();
957
+ logger.warn("dry-run 模式不会翻译或写文件;为预览语义 key,仍可能调用 Google 生成 key");
958
+ logger.newline();
959
+ }
960
+
961
+ function createPageCalls(pagesKeys) {
962
+ return pagesKeys.map((text) => ({
963
+ filePath: path.join(PROJECT_ROOT, "pages.json"),
964
+ sourceText: text,
965
+ start: 0,
966
+ end: 0
967
+ }));
968
+ }
969
+
970
+ // ============================================================================
971
+ // Gemini 翻译
972
+ // ============================================================================
973
+
974
+ function createGeminiClient(apiKey) {
975
+ return new GoogleGenAI({ apiKey });
976
+ }
977
+
978
+ async function translateTextsWithGemini(client, locale, texts, model) {
979
+ const prompt = [
980
+ "你是一个专业的 i18n 翻译助手。",
981
+ `请将 JSON 数组中的简体中文翻译成 ${locale}。`,
982
+ "行业背景:3D 打印、MJP、SaaS 平台。",
983
+ "要求:",
984
+ "1. 只返回 JSON 数组字符串,不要 Markdown,不要解释。",
985
+ "2. 返回数组长度必须和输入一致,并保持顺序。",
986
+ "3. 保留占位符、变量名、HTML 标签、换行符、URL、代码片段、大小写和数字。",
987
+ "4. 遇到品牌名、产品名、模块名时优先保持原文或做最小必要翻译。",
988
+ `输入内容:${JSON.stringify(texts)}`
989
+ ].join("\n");
990
+
991
+ const response = await client.models.generateContent({
992
+ model,
993
+ contents: prompt
994
+ });
995
+
996
+ const responseText =
997
+ typeof response.text === "function" ? response.text() : response.text || "";
998
+ const translations = parseJsonArrayResponse(responseText);
999
+
1000
+ if (translations.length !== texts.length) {
1001
+ throw new Error(`模型返回数量异常,期望 ${texts.length} 条,实际 ${translations.length} 条`);
1002
+ }
1003
+
1004
+ return translations.map((text) => String(text ?? ""));
1005
+ }
1006
+
1007
+ async function translateEntries(locale, entries, options) {
1008
+ const texts = entries.map((entry) => entry.sourceText);
1009
+ const translations = await translateTextsWithGemini(
1010
+ options.client,
1011
+ locale,
1012
+ texts,
1013
+ options.model
1014
+ );
1015
+
1016
+ return entries.reduce((result, entry, index) => {
1017
+ result[entry.key] = translations[index];
1018
+ return result;
1019
+ }, {});
1020
+ }
1021
+
1022
+ async function translateInBatches(locale, entries, options) {
1023
+ const result = {};
1024
+ const tasks = [];
1025
+
1026
+ for (let i = 0; i < entries.length; i += options.batchSize) {
1027
+ const batchEntries = entries.slice(i, i + options.batchSize);
1028
+ if (!isEmpty(batchEntries)) {
1029
+ tasks.push(
1030
+ translateEntries(locale, batchEntries, options).then((translations) => {
1031
+ Object.assign(result, translations);
1032
+ })
1033
+ );
1034
+ }
1035
+ }
1036
+
1037
+ await Promise.all(tasks);
1038
+ return result;
1039
+ }
1040
+
1041
+ // ============================================================================
1042
+ // 翻译流程处理
1043
+ // ============================================================================
1044
+
1045
+ async function invokeFlow(params, onProgress) {
1046
+ const result = {
1047
+ app: {},
1048
+ modules: {},
1049
+ plugins: {},
1050
+ uniappx: {},
1051
+ uni_modules: {}
1052
+ };
1053
+
1054
+ const targetLanguages = params.languages.filter((lang) => lang !== params.sourceLocale);
1055
+ const tasks = [];
1056
+ let completed = 0;
1057
+ const totalTasks = Object.keys(params.data).length * targetLanguages.length;
1058
+
1059
+ for (const key in params.data) {
1060
+ const [type, module] = key.split(".");
1061
+ const rawEntries = params.data[key];
1062
+ const entries = rawEntries.map((entry) => {
1063
+ if (entry && typeof entry === "object" && "key" in entry && "sourceText" in entry) {
1064
+ return {
1065
+ key: String(entry.key),
1066
+ sourceText: normalizeContentText(entry.sourceText)
1067
+ };
1068
+ }
1069
+
1070
+ return {
1071
+ key: String(entry),
1072
+ sourceText: normalizeContentText(entry)
1073
+ };
1074
+ });
1075
+
1076
+ if (!result[type][module]) {
1077
+ result[type][module] = { [params.sourceLocale]: {} };
1078
+ entries.forEach((entry) => {
1079
+ result[type][module][params.sourceLocale][entry.key] = entry.sourceText;
1080
+ });
1081
+ }
1082
+
1083
+ for (const locale of targetLanguages) {
1084
+ const task = translateInBatches(locale, entries, params).then((translations) => {
1085
+ result[type][module][locale] = translations;
1086
+ completed++;
1087
+ onProgress?.(locale, completed, totalTasks);
1088
+ });
1089
+ tasks.push(task);
1090
+ }
1091
+ }
1092
+
1093
+ await Promise.all(tasks);
1094
+ return result;
1095
+ }
1096
+
1097
+ // ============================================================================
1098
+ // 文件扫描与解析
1099
+ // ============================================================================
1100
+
1101
+ function scanI18nCalls(targets, includeUniModules = false) {
1102
+ const files = expandScanFiles(targets, includeUniModules);
1103
+ const i18nKeys = [];
1104
+
1105
+ files.forEach((file) => {
1106
+ i18nKeys.push(...collectI18nKeysFromFile(file));
1107
+ });
1108
+
1109
+ return i18nKeys;
1110
+ }
1111
+
1112
+ function scanI18nUsages(targets, includeUniModules = false) {
1113
+ const files = expandScanFiles(targets, includeUniModules);
1114
+ const calls = [];
1115
+
1116
+ files.forEach((file) => {
1117
+ calls.push(...collectI18nCallsFromFile(file));
1118
+ });
1119
+
1120
+ return calls;
1121
+ }
1122
+
1123
+ function extractFromPagesJson() {
1124
+ const pagesPath = path.join(PROJECT_ROOT, "pages.json");
1125
+
1126
+ if (!fs.existsSync(pagesPath)) {
1127
+ return [];
1128
+ }
1129
+
1130
+ const content = fs.readFileSync(pagesPath, "utf8");
1131
+ const keys = [];
1132
+
1133
+ const percentPattern = /%([^%]+)%/g;
1134
+ let match;
1135
+ while ((match = percentPattern.exec(content)) !== null) {
1136
+ keys.push(match[1]);
1137
+ }
1138
+
1139
+ const navTitlePattern = /"navigationBarTitleText":\s*["'`]([^"'`]+)["'`]/g;
1140
+ while ((match = navTitlePattern.exec(content)) !== null) {
1141
+ if (match[1]?.trim()) {
1142
+ keys.push(match[1]);
1143
+ }
1144
+ }
1145
+
1146
+ const textPattern = /"text":\s*["'`]([^"'`]+)["'`]/g;
1147
+ while ((match = textPattern.exec(content)) !== null) {
1148
+ if (match[1]?.trim()) {
1149
+ keys.push(match[1]);
1150
+ }
1151
+ }
1152
+
1153
+ return keys;
1154
+ }
1155
+
1156
+ function scanTranslationSources(modulePath = null, filePatterns = []) {
1157
+ const scanTargets = resolveScanTargets(modulePath, filePatterns);
1158
+ const i18nCalls = scanI18nUsages(scanTargets, !!modulePath);
1159
+ const pagesKeys = filePatterns.length ? [] : extractFromPagesJson();
1160
+
1161
+ if (modulePath) {
1162
+ const normalizedModulePath = modulePath.replace(/^[/\\]+/, "");
1163
+ const moduleKey = normalizedModulePath.replace(/[\\/]/g, ".");
1164
+ return {
1165
+ moduleKey,
1166
+ i18nCalls,
1167
+ pagesKeys: []
1168
+ };
1169
+ }
1170
+
1171
+ return {
1172
+ moduleKey: "app.default",
1173
+ i18nCalls,
1174
+ pagesKeys
1175
+ };
1176
+ }
1177
+
1178
+ async function collectTranslationData(scanResult, options) {
1179
+ const rewritePlan = await buildRewritePlan(scanResult.i18nCalls, options, "语义命名");
1180
+ const pageEntries = scanResult.pagesKeys.length
1181
+ ? (await buildRewritePlan(createPageCalls(scanResult.pagesKeys), options, "页面词条命名")).entries
1182
+ : [];
1183
+
1184
+ return {
1185
+ data: {
1186
+ [scanResult.moduleKey]: [
1187
+ ...rewritePlan.entries,
1188
+ ...pageEntries
1189
+ ]
1190
+ },
1191
+ rewritesByFile: rewritePlan.rewritesByFile
1192
+ };
1193
+ }
1194
+
1195
+ // ============================================================================
1196
+ // 翻译文件生成
1197
+ // ============================================================================
1198
+
1199
+ function writeUniModulesLocales(modulesData, options) {
1200
+ for (const moduleName in modulesData) {
1201
+ const moduleTranslations = modulesData[moduleName];
1202
+
1203
+ for (const langCode in moduleTranslations) {
1204
+ const translations = moduleTranslations[langCode];
1205
+ const fileName = langCode.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
1206
+ const filePath = path.join(
1207
+ PROJECT_ROOT,
1208
+ "uni_modules",
1209
+ moduleName,
1210
+ options.outputDir,
1211
+ `${fileName}.json`
1212
+ );
1213
+ const mergedTranslations = mergeLocaleTranslations(filePath, translations);
1214
+ const data = serializeLocaleEntries(
1215
+ mergedTranslations,
1216
+ langCode,
1217
+ options.outputFormat,
1218
+ options.sourceLocale
1219
+ );
1220
+
1221
+ ensureDirectoryExists(filePath);
1222
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf8");
1223
+ logger.file(filePath);
1224
+ }
1225
+ }
1226
+ }
1227
+
1228
+ function writeBusinessLocales(businessData, options) {
1229
+ for (const langCode in businessData) {
1230
+ const translations = businessData[langCode];
1231
+ const fileName = langCode.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
1232
+ const filePath = path.join(PROJECT_ROOT, options.outputDir, `${fileName}.json`);
1233
+ const mergedTranslations = mergeLocaleTranslations(filePath, translations);
1234
+ const data = serializeLocaleEntries(
1235
+ mergedTranslations,
1236
+ langCode,
1237
+ options.outputFormat,
1238
+ options.sourceLocale
1239
+ );
1240
+ ensureDirectoryExists(filePath);
1241
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf8");
1242
+ logger.file(filePath);
1243
+ }
1244
+ }
1245
+
1246
+ async function generateLocaleFiles(scanResult, options) {
1247
+ const languages = resolveLanguages(options);
1248
+ const pendingCallsCount = scanResult.i18nCalls.length + scanResult.pagesKeys.length;
1249
+
1250
+ logger.title("开始翻译");
1251
+ logger.info("项目", PROJECT_ROOT);
1252
+ logger.info("提供方", "Google AI Studio");
1253
+ logger.info("模型", options.model);
1254
+ logger.info("批次", String(options.batchSize));
1255
+ logger.info("输出目录", options.outputDir);
1256
+ logger.info("输出格式", options.outputFormat);
1257
+ logger.info("源码回写", options.rewrite ? "开启" : "关闭");
1258
+ logger.info("预览模式", options.dryRun ? "开启" : "关闭");
1259
+ if (options.filePatterns.length) {
1260
+ logger.info("扫描范围", options.filePatterns.join(", "));
1261
+ }
1262
+ logger.languages(languages);
1263
+ logger.info("待处理调用", chalk.yellow(pendingCallsCount) + " 个");
1264
+ logger.divider();
1265
+ logger.newline();
1266
+
1267
+ if (pendingCallsCount === 0) {
1268
+ logger.warn("未扫描到任何 $t()/t() 词条");
1269
+ logger.newline();
1270
+ return;
1271
+ }
1272
+
1273
+ logger.info("语义命名", "正在调用 Google AI 生成语义 key...");
1274
+ logger.newline();
1275
+
1276
+ const translationData = await collectTranslationData(scanResult, options);
1277
+
1278
+ for (const key in translationData.data) {
1279
+ const seen = new Set();
1280
+ translationData.data[key] = translationData.data[key].filter((entry) => {
1281
+ if (!entry?.key || seen.has(entry.key)) {
1282
+ return false;
1283
+ }
1284
+ seen.add(entry.key);
1285
+ return true;
1286
+ });
1287
+ }
1288
+
1289
+ const totalKeys = Object.values(translationData.data).flat().length;
1290
+ logger.success(`语义 key 生成完成,共 ${totalKeys} 个`);
1291
+ logger.newline();
1292
+
1293
+ if (options.dryRun) {
1294
+ logDryRunPreview(translationData, options, languages);
1295
+ return;
1296
+ }
1297
+
1298
+ try {
1299
+ const startTime = Date.now();
1300
+
1301
+ const result = await invokeFlow(
1302
+ {
1303
+ languages,
1304
+ data: translationData.data,
1305
+ client: options.client,
1306
+ model: options.model,
1307
+ batchSize: options.batchSize,
1308
+ sourceLocale: options.sourceLocale
1309
+ },
1310
+ (locale, current, total) => {
1311
+ logger.translating(locale, current, total);
1312
+ }
1313
+ );
1314
+
1315
+ logger.translateDone();
1316
+ logger.newline();
1317
+
1318
+ if (options.rewrite) {
1319
+ logger.title("回写源码");
1320
+ applySourceRewrites(translationData.rewritesByFile);
1321
+ logger.newline();
1322
+ }
1323
+
1324
+ logger.title("生成文件");
1325
+
1326
+ if (result.uni_modules && Object.keys(result.uni_modules).length > 0) {
1327
+ writeUniModulesLocales(result.uni_modules, options);
1328
+ }
1329
+
1330
+ if (result.app?.default) {
1331
+ writeBusinessLocales(result.app.default, options);
1332
+ }
1333
+
1334
+ const duration = ((Date.now() - startTime) / 1000).toFixed(1);
1335
+ logger.newline();
1336
+ logger.divider();
1337
+ console.log(chalk.green.bold("✓ 翻译完成"), chalk.gray(`耗时 ${duration}s`));
1338
+ logger.newline();
1339
+ } catch (err) {
1340
+ logger.newline();
1341
+ logger.error(err.message || "翻译失败");
1342
+ process.exit(1);
1343
+ }
1344
+ }
1345
+
1346
+ // ============================================================================
1347
+ // 命令处理
1348
+ // ============================================================================
1349
+
1350
+ function buildCommandOptions(commandOptions) {
1351
+ const apiKey = resolveApiKey(commandOptions.apiKey);
1352
+
1353
+ if (!apiKey) {
1354
+ logger.newline();
1355
+ logger.error("缺少 Google AI Studio API Key,请在 config.js 中填写默认值,或使用 --apiKey / --api-key");
1356
+ logger.newline();
1357
+ process.exit(1);
1358
+ }
1359
+
1360
+ return {
1361
+ apiKey,
1362
+ model: commandOptions.model || DEFAULT_GEMINI_MODEL,
1363
+ batchSize: parseBatchSize(commandOptions.batchSize || String(DEFAULT_BATCH_SIZE)),
1364
+ filePatterns: commandOptions.files ? parseFilePatterns(commandOptions.files) : [],
1365
+ languages: commandOptions.languages ? parseLanguages(commandOptions.languages) : [],
1366
+ outputDir: commandOptions.outputDir || DEFAULT_OUTPUT_DIR,
1367
+ outputFormat: parseOutputFormat(commandOptions.outputFormat || "auto"),
1368
+ sourceLocale: (commandOptions.sourceLocale || SOURCE_LOCALE).toLowerCase(),
1369
+ rewrite: commandOptions.rewrite !== false,
1370
+ dryRun: Boolean(commandOptions.dryRun)
1371
+ };
1372
+ }
1373
+
1374
+ async function createCommand(modulePath = null, commandOptions = {}) {
1375
+ const options = buildCommandOptions(commandOptions);
1376
+ options.client = createGeminiClient(options.apiKey);
1377
+ const projectMode = detectProjectMode(modulePath);
1378
+ options.outputFormat = resolveOutputFormat(options, projectMode);
1379
+ if (projectMode === "uni-module" && options.outputDir === DEFAULT_OUTPUT_DIR) {
1380
+ options.outputDir = "locales";
1381
+ }
1382
+ const scanResult = scanTranslationSources(modulePath, options.filePatterns);
1383
+ await generateLocaleFiles(scanResult, options);
1384
+ }
1385
+
1386
+ async function addCommand(moduleName, commandOptions) {
1387
+ if (!moduleName?.startsWith("uni_modules/")) {
1388
+ logger.newline();
1389
+ logger.error("格式错误,请输入 uni_modules/[name]");
1390
+ logger.newline();
1391
+ process.exit(1);
1392
+ }
1393
+
1394
+ await createCommand(`/${moduleName}`, commandOptions);
1395
+ }
1396
+
1397
+ function clearCommand() {
1398
+ logger.title("清除翻译文件");
1399
+
1400
+ const localesDir = path.join(PROJECT_ROOT, DEFAULT_OUTPUT_DIR);
1401
+
1402
+ if (fs.existsSync(localesDir)) {
1403
+ fs.rmSync(localesDir, { recursive: true, force: true });
1404
+ logger.file(localesDir);
1405
+ } else {
1406
+ logger.warn("未找到 locales 目录,无需清理");
1407
+ }
1408
+
1409
+ logger.newline();
1410
+ logger.success("清除完成");
1411
+ logger.newline();
1412
+ }
1413
+
1414
+ function applySharedOptions(command) {
1415
+ return command
1416
+ .option("-k, --api-key <key>", "Google AI Studio API Key")
1417
+ .option("--apiKey <key>", "Google AI Studio API Key,兼容 npm run xxx -- --apiKey=***")
1418
+ .option("-m, --model <model>", "Gemini 模型名称", DEFAULT_GEMINI_MODEL)
1419
+ .option("-b, --batch-size <size>", "单批翻译数量", String(DEFAULT_BATCH_SIZE))
1420
+ .option("-f, --files <paths>", "只扫描指定文件或 glob,多个值用英文逗号分隔")
1421
+ .option("-l, --languages <codes>", "目标语言列表,多个值用英文逗号分隔,例如 zh-cn,en,ja")
1422
+ .option("-o, --output-dir <dir>", "翻译文件输出目录", DEFAULT_OUTPUT_DIR)
1423
+ .option("--output-format <format>", "输出格式:auto | pair-array | object", "auto")
1424
+ .option("--source-locale <code>", "源语言代码", SOURCE_LOCALE)
1425
+ .option("--no-rewrite", "只生成 locale 文件,不改写源码")
1426
+ .option("--dry-run", "预览将要生成的 key、改写文件和 locale 输出,不真正写入");
1427
+ }
1428
+
1429
+ // ============================================================================
1430
+ // CLI 命令注册
1431
+ // ============================================================================
1432
+
1433
+ program
1434
+ .name("babaofan-translate")
1435
+ .description(chalk.cyan("本地 i18n 翻译与语义 key 生成工具"))
1436
+ .version("1.0.0");
1437
+
1438
+ applySharedOptions(program.command("create").description("创建项目翻译文件")).action(async (options) => {
1439
+ await createCommand(null, options);
1440
+ });
1441
+
1442
+ program.command("clear").description("清除翻译文件").action(clearCommand);
1443
+
1444
+ applySharedOptions(
1445
+ program.command("add <uni_modules/[name]>").description("为指定模块生成翻译文件")
1446
+ ).action(async (moduleName, options) => {
1447
+ await addCommand(moduleName, options);
1448
+ });
1449
+
1450
+ program.parseAsync(process.argv);