vue-i18n-extract-plugin 1.0.47

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/lib/extract.js ADDED
@@ -0,0 +1,564 @@
1
+ const path = require("path");
2
+ const fs = require("fs-extra");
3
+ const glob = require("fast-glob");
4
+ const parser = require("@babel/parser");
5
+ const traverse = require("@babel/traverse").default;
6
+ const generate = require("@babel/generator").default;
7
+ const t = require("@babel/types");
8
+ const { parse: parseSFC } = require("@vue/compiler-sfc");
9
+ const {
10
+ parse: parseTemplate,
11
+ transform,
12
+ // generate: genTemplate,
13
+ getBaseTransformPreset,
14
+ NodeTypes
15
+ } = require("@vue/compiler-dom");
16
+ const MagicString = require("magic-string");
17
+ const prettier = require("prettier");
18
+ const { createI18nVisitor } = require("./visitors");
19
+ const {
20
+ relativeCWDPath,
21
+ shouldExtract,
22
+ trimEmptyLine,
23
+ padEmptyLine,
24
+ isEmptyObject,
25
+ getLangJsonPath,
26
+ readJsonWithDefault
27
+ } = require("./utils");
28
+ const { defaultOptions } = require("./options");
29
+ const { autoTranslate, cleanTranslate, cleanI18nMap } = require("./translate");
30
+ const { i18nImportAstTransform } = require("./import-i18n-transform");
31
+
32
+ let globalI18nMap = {};
33
+
34
+ function encodeToString(str) {
35
+ return str.indexOf("'") === -1 ? `'${str}'` : `"${str}"`;
36
+ }
37
+
38
+ function rebuildPattern(p, extensions) {
39
+ if (path.extname(p) !== "") {
40
+ return p;
41
+ }
42
+ if (p.endsWith("/")) {
43
+ p = p.slice(0, -1);
44
+ }
45
+ if (p.endsWith("**")) {
46
+ return `${p}/*.{${extensions}}`;
47
+ }
48
+ if (p.endsWith("*")) {
49
+ return `${p}.{${extensions}}`;
50
+ }
51
+ return `${p}/**/*.{${extensions}}`;
52
+ }
53
+
54
+ function wrapAsStatement(expression) {
55
+ return `const _expr = ${expression};`;
56
+ }
57
+
58
+ function unwrapTransformedCode(ast) {
59
+ let extractedNode = null;
60
+
61
+ traverse(ast, {
62
+ VariableDeclarator(path) {
63
+ if (path.node.id.name === "_expr") {
64
+ extractedNode = path.node.init;
65
+ path.stop();
66
+ }
67
+ }
68
+ });
69
+
70
+ if (extractedNode) {
71
+ const { code: exprCode } = generate(extractedNode);
72
+ return exprCode;
73
+ } else {
74
+ console.warn("未能提取到 _expr 表达式");
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function transformScriptExpression(expression, options, i18nMap) {
80
+ const wrapped = wrapAsStatement(expression);
81
+ const transformedAst = transformScript(
82
+ wrapped,
83
+ { ...options, autoImportI18n: false },
84
+ true,
85
+ i18nMap
86
+ );
87
+ const transformedCode = unwrapTransformedCode(transformedAst);
88
+
89
+ // if (useAst) {
90
+ // // 使用 transformScript 解析之后确保返回 CallExpression
91
+ // const ast = parser.parseExpression(transformedCode);
92
+
93
+ // // 判断是否是 CallExpression 类型,并返回
94
+ // if (t.isCallExpression(ast)) {
95
+ // return ast;
96
+ // } else {
97
+ // throw new Error(`Expected CallExpression but got: ${ast.type}`);
98
+ // }
99
+ // }
100
+ return transformedCode;
101
+ }
102
+
103
+ function extractTCallsFromInterpolation(code, options, i18nMap) {
104
+ let ast;
105
+ try {
106
+ // 这是一个 JS 表达式,不是完整语句,所以使用 parseExpression
107
+ ast = parser.parseExpression(code, {
108
+ plugins: ["typescript"]
109
+ });
110
+ } catch (e) {
111
+ console.error("插值解析失败:", e.message);
112
+ return;
113
+ }
114
+
115
+ // 包装成完整的 AST
116
+ const program = t.program([t.expressionStatement(ast)]);
117
+
118
+ traverse(program, createI18nVisitor(options, i18nMap));
119
+
120
+ if (options.rewrite) {
121
+ return generate(ast, { compact: true }).code;
122
+ }
123
+
124
+ // return ast;
125
+ }
126
+
127
+ function createInterpolationNode(content) {
128
+ const interpolationNode = {
129
+ type: NodeTypes.INTERPOLATION,
130
+ content: {
131
+ type: NodeTypes.SIMPLE_EXPRESSION,
132
+ content
133
+ }
134
+ };
135
+ return interpolationNode;
136
+ }
137
+
138
+ function createDirectiveFromProp(prop, content) {
139
+ function getDirectiveExpression() {
140
+ if (content) return content;
141
+ if (prop.value) {
142
+ return `'${prop.value.content}'`;
143
+ }
144
+
145
+ return "true";
146
+ }
147
+
148
+ return {
149
+ type: NodeTypes.DIRECTIVE,
150
+ name: "bind",
151
+ rawName: `:${prop.name}`,
152
+ exp: {
153
+ type: NodeTypes.SIMPLE_EXPRESSION,
154
+ content: getDirectiveExpression(),
155
+ isStatic: false
156
+ },
157
+ arg: {
158
+ type: NodeTypes.SIMPLE_EXPRESSION,
159
+ content: prop.name,
160
+ isStatic: true
161
+ },
162
+ modifiers: [],
163
+ loc: prop.loc
164
+ };
165
+ }
166
+
167
+ function addI18nImportIfNeeded(ast, options, generateCode) {
168
+ i18nImportAstTransform(ast, options.translateKey, options.i18nPkgImportPath);
169
+
170
+ if (generateCode) {
171
+ return generate(ast);
172
+ }
173
+
174
+ return ast;
175
+ }
176
+
177
+ async function writeI18nMapToFile(i18nMap, options, checkDiffs) {
178
+ const outputJSONPath = getLangJsonPath(options.fromLang, options);
179
+ let originalJson;
180
+
181
+ if (checkDiffs) {
182
+ // 检查是否有差异
183
+ originalJson = readJsonWithDefault(outputJSONPath, null);
184
+ if (originalJson) {
185
+ const i18nMapKeys = Object.keys(i18nMap);
186
+ let hasDiff = i18nMapKeys.length !== Object.keys(originalJson).length;
187
+ if (!hasDiff) {
188
+ hasDiff = i18nMapKeys.some(key => i18nMap[key] !== originalJson[key]);
189
+ }
190
+ if (!hasDiff) {
191
+ console.warn("新的 i18nMap 与源文件没有差异,跳过写入文件...");
192
+ return Promise.resolve({ hasDiff: false, data: i18nMap });
193
+ }
194
+ }
195
+ }
196
+
197
+ originalJson = originalJson ?? readJsonWithDefault(outputJSONPath, null);
198
+ if (originalJson) {
199
+ if (options.rewrite) {
200
+ i18nMap = Object.assign(originalJson, i18nMap);
201
+ } else {
202
+ i18nMap = Object.assign(cleanI18nMap(i18nMap, originalJson), i18nMap);
203
+ }
204
+ }
205
+
206
+ await fs.outputJson(outputJSONPath, i18nMap, {
207
+ spaces: 2
208
+ });
209
+
210
+ return Promise.resolve({ hasDiff: true, data: i18nMap });
211
+ }
212
+
213
+ async function handleFinalI18nMap(i18nMap, options, checkDiffs) {
214
+ const { hasDiff } = await writeI18nMapToFile(i18nMap, options, checkDiffs);
215
+
216
+ if (!hasDiff) return;
217
+
218
+ if (options.autoTranslate) {
219
+ await autoTranslate(i18nMap, options);
220
+ }
221
+
222
+ if (options.cleanTranslate) {
223
+ await cleanTranslate(options);
224
+ }
225
+ }
226
+
227
+ function transformScript(code, options, useAst, i18nMap) {
228
+ const innerI18nMap = i18nMap || {};
229
+ const ast = parser.parse(code, {
230
+ sourceType: "module",
231
+ plugins: ["typescript", "jsx"]
232
+ });
233
+
234
+ traverse(ast, createI18nVisitor(options, innerI18nMap));
235
+
236
+ const isEmpty = isEmptyObject(innerI18nMap);
237
+
238
+ if (!isEmpty) {
239
+ if (options.autoImportI18n && options.rewrite) {
240
+ addI18nImportIfNeeded(ast, options);
241
+ }
242
+ Object.assign(globalI18nMap, innerI18nMap);
243
+ }
244
+
245
+ if (useAst) {
246
+ return ast;
247
+ }
248
+
249
+ if (options.rewrite) {
250
+ if (isEmpty) {
251
+ return { changed: false, code };
252
+ }
253
+ return {
254
+ changed: true,
255
+ code: generate(ast, { retainLines: true }).code
256
+ };
257
+ }
258
+
259
+ return {
260
+ changed: false,
261
+ code
262
+ };
263
+ }
264
+
265
+ function transformTemplate(templateContent, options) {
266
+ const innerI18nMap = {};
267
+ let magicString;
268
+
269
+ if (options.rewrite) {
270
+ magicString = new MagicString(templateContent);
271
+ }
272
+
273
+ const ast = parseTemplate(templateContent, {
274
+ comments: true, // 保留注释
275
+ whitespace: "preserve" // 保留空白
276
+ });
277
+
278
+ const [nodeTransforms, directiveTransforms] = getBaseTransformPreset(true);
279
+
280
+ transform(ast, {
281
+ nodeTransforms: [
282
+ ...nodeTransforms,
283
+ node => {
284
+ if (node.type === NodeTypes.INTERPOLATION) {
285
+ const { content } = node;
286
+ if (!content) return;
287
+ const text = content.content.replace(/\n+/g, " ").trim();
288
+ if (!text) return;
289
+ try {
290
+ let newContent = "";
291
+ // {{ '中文' }} 不包含 $t
292
+ if (text.indexOf(options.translateKey) === -1) {
293
+ newContent = transformScriptExpression(
294
+ text,
295
+ options,
296
+ innerI18nMap
297
+ );
298
+ } else {
299
+ newContent = extractTCallsFromInterpolation(
300
+ text,
301
+ options,
302
+ innerI18nMap
303
+ );
304
+ }
305
+ if (newContent) {
306
+ content.content = newContent;
307
+ magicString?.overwrite(
308
+ node.loc.start.offset,
309
+ node.loc.end.offset,
310
+ `{{ ${newContent} }}`
311
+ );
312
+ }
313
+ } catch (e) {
314
+ console.error(`模板表达式解析失败: text: ${text}, err: ${e}`);
315
+ }
316
+ }
317
+ if (node.type === NodeTypes.TEXT) {
318
+ let { content: text } = node;
319
+ try {
320
+ text = text.replace(/\n+/g, " ").trim();
321
+ if (!text) return;
322
+ const newContent = transformScriptExpression(
323
+ encodeToString(text),
324
+ options,
325
+ innerI18nMap
326
+ );
327
+ if (newContent) {
328
+ // node.content = newContent;
329
+
330
+ // 创建一个新的 INTERPOLATION 节点
331
+ const interpolationNode = createInterpolationNode(newContent);
332
+
333
+ // 替换原来的 TEXT 节点为 INTERPOLATION 节点
334
+ Object.assign(node, interpolationNode);
335
+
336
+ magicString?.overwrite(
337
+ node.loc.start.offset,
338
+ node.loc.end.offset,
339
+ `{{ ${newContent} }}`
340
+ );
341
+ }
342
+ } catch (e) {
343
+ console.error(`文本解析失败: text: ${text}, err: ${e}`);
344
+ }
345
+ }
346
+ if (node.type === NodeTypes.ELEMENT) {
347
+ node.props.forEach((prop, index) => {
348
+ let text = "";
349
+ if (prop.type === NodeTypes.ATTRIBUTE) {
350
+ text = prop.value?.content;
351
+ if (
352
+ text &&
353
+ typeof text === "string" &&
354
+ shouldExtract(text, options.fromLang)
355
+ ) {
356
+ try {
357
+ const newValue = transformScriptExpression(
358
+ encodeToString(text.trim()),
359
+ options,
360
+ innerI18nMap
361
+ );
362
+ if (newValue) {
363
+ // prop.value.content = newValue;
364
+ const directive = createDirectiveFromProp(prop, newValue);
365
+ // 将原属性替换为指令
366
+ node.props[index] = directive;
367
+
368
+ magicString?.overwrite(
369
+ prop.loc.start.offset,
370
+ prop.loc.end.offset,
371
+ `:${prop.name}="${newValue.replace(/"/g, "'")}"`
372
+ );
373
+ }
374
+ } catch (e) {
375
+ console.error(`静态属性解析失败: text: ${text}, err: ${e}`);
376
+ }
377
+ }
378
+ }
379
+ if (prop.type === NodeTypes.DIRECTIVE && prop.name === "bind") {
380
+ const arg = prop.arg;
381
+ if (
382
+ arg?.type === NodeTypes.SIMPLE_EXPRESSION &&
383
+ arg.content === "key"
384
+ ) {
385
+ return; // 忽略 key
386
+ }
387
+ text = prop.exp?.content;
388
+ if (
389
+ text &&
390
+ typeof text === "string" &&
391
+ shouldExtract(text, options.fromLang)
392
+ ) {
393
+ try {
394
+ const newValue = extractTCallsFromInterpolation(
395
+ text.trim(),
396
+ options,
397
+ innerI18nMap
398
+ );
399
+ if (newValue) {
400
+ prop.exp.content = newValue;
401
+
402
+ magicString?.overwrite(
403
+ prop.loc.start.offset,
404
+ prop.loc.end.offset,
405
+ `${prop.rawName}="${newValue.replace(/"/g, "'")}"`
406
+ );
407
+ }
408
+ } catch (e) {
409
+ console.error(`动态指令解析失败: text: ${text}, err: ${e}`);
410
+ }
411
+ }
412
+ }
413
+ });
414
+ }
415
+ }
416
+ ],
417
+ directiveTransforms
418
+ });
419
+
420
+ const isEmpty = isEmptyObject(innerI18nMap);
421
+
422
+ if (!isEmpty) {
423
+ Object.assign(globalI18nMap, innerI18nMap);
424
+ }
425
+
426
+ if (options.rewrite) {
427
+ if (isEmpty) {
428
+ return {
429
+ changed: false,
430
+ code: templateContent
431
+ };
432
+ }
433
+
434
+ // if (options.sourcemap) {
435
+ // const map = magicString.generateMap({
436
+ // source: filePath,
437
+ // hires: true
438
+ // });
439
+
440
+ // return {
441
+ // changed: true,
442
+ // code: trimEmptyLine(magicString.toString()),
443
+ // map
444
+ // };
445
+ // }
446
+
447
+ return {
448
+ changed: true,
449
+ code: trimEmptyLine(magicString.toString())
450
+ };
451
+
452
+ // return {
453
+ // changed: true,
454
+ // code: genTemplate(ast).code
455
+ // };
456
+ }
457
+
458
+ return {
459
+ changed: false,
460
+ code: templateContent
461
+ };
462
+ }
463
+
464
+ function processVueFile(code, options) {
465
+ const sfc = parseSFC(code);
466
+
467
+ let transformedScript = "";
468
+ let transformedTemplate = "";
469
+ let scriptChanged = false;
470
+ let templateChanged = false;
471
+
472
+ const scriptContent =
473
+ sfc.descriptor.script?.content || sfc.descriptor.scriptSetup?.content || "";
474
+ const templateContent = sfc.descriptor.template?.content || "";
475
+
476
+ if (scriptContent) {
477
+ const scriptResult = transformScript(scriptContent, options);
478
+ scriptChanged = scriptResult.changed;
479
+ transformedScript = scriptResult.code;
480
+ }
481
+
482
+ if (templateContent) {
483
+ const templateResult = transformTemplate(templateContent, options);
484
+ templateChanged = templateResult.changed;
485
+ transformedTemplate = templateResult.code;
486
+ }
487
+
488
+ if (options.rewrite && (scriptChanged || templateChanged)) {
489
+ return {
490
+ changed: true,
491
+ code: code
492
+ .replace(scriptContent, padEmptyLine(transformedScript))
493
+ .replace(templateContent, padEmptyLine(transformedTemplate))
494
+ };
495
+ }
496
+
497
+ return { changed: false, code };
498
+ }
499
+
500
+ async function formatFile(code, filePath) {
501
+ const options = await prettier.resolveConfig(filePath);
502
+ return await prettier.format(code, {
503
+ ...options,
504
+ filepath: filePath
505
+ });
506
+ }
507
+
508
+ async function extractI18n(options) {
509
+ options = { ...defaultOptions, ...options };
510
+
511
+ let includePath = Array.isArray(options.includePath)
512
+ ? options.includePath
513
+ : [options.includePath];
514
+
515
+ includePath = includePath.map(p => {
516
+ if (p instanceof RegExp) {
517
+ p = p.source.replace(/\\/g, "").replace(/\/\//, "/");
518
+ }
519
+ return relativeCWDPath(p);
520
+ });
521
+
522
+ const extensions = options.allowedExtensions
523
+ .map(s => s.replace(/^\./, ""))
524
+ .join(",");
525
+ const globPattern = includePath.map(p => rebuildPattern(p, extensions));
526
+ const files = await glob(globPattern, { ignore: options.excludedPath });
527
+
528
+ for (const file of files) {
529
+ const content = await fs.readFile(file, "utf8");
530
+
531
+ let changed = false;
532
+ let code = "";
533
+
534
+ if (!content.trim()) continue;
535
+
536
+ try {
537
+ if (file.endsWith(".vue")) {
538
+ const vueContent = processVueFile(content, options);
539
+ changed = vueContent.changed;
540
+ code = vueContent.code;
541
+ } else {
542
+ const scriptContent = transformScript(content, options);
543
+ changed = scriptContent.changed;
544
+ code = scriptContent.code;
545
+ }
546
+ } catch (err) {
547
+ console.error(`processing error with file ${file}`, err);
548
+ }
549
+
550
+ if (options.rewrite && changed) {
551
+ code = await formatFile(code, file);
552
+ await fs.writeFile(file, code, "utf8");
553
+ }
554
+ }
555
+
556
+ await handleFinalI18nMap(globalI18nMap, options);
557
+ }
558
+
559
+ module.exports = {
560
+ extractI18n,
561
+ writeI18nMapToFile,
562
+ handleFinalI18nMap,
563
+ globalI18nMap
564
+ };
@@ -0,0 +1,146 @@
1
+ const parser = require("@babel/parser");
2
+ const traverse = require("@babel/traverse").default;
3
+ const generate = require("@babel/generator").default;
4
+ const t = require("@babel/types");
5
+
6
+ function i18nImportAstTransform(ast, importName, importPath) {
7
+ let hasI18nImport = false;
8
+ let lastImportNode = null;
9
+ let needTransform = false;
10
+ let conflictTDefined = false;
11
+
12
+ traverse(ast, {
13
+ ImportDeclaration(path) {
14
+ lastImportNode = path.node;
15
+
16
+ const sourcePath = path.node.source.value;
17
+
18
+ // 判断是否是其它路径导入了 $t
19
+ const importedTElsewhere = path.node.specifiers.some(spec => {
20
+ return (
21
+ (t.isImportSpecifier(spec) || t.isImportDefaultSpecifier(spec)) &&
22
+ spec.local.name === importName &&
23
+ sourcePath !== importPath
24
+ );
25
+ });
26
+
27
+ if (importedTElsewhere) {
28
+ conflictTDefined = true;
29
+ path.stop();
30
+ return;
31
+ }
32
+
33
+ // 检查是否已经导入目标路径
34
+ if (sourcePath === importPath) {
35
+ hasI18nImport = true;
36
+
37
+ // 情况1:已有 import { $t } from '@/i18n'
38
+ const existImport = path.node.specifiers.some(
39
+ spec => t.isImportSpecifier(spec) && spec.imported.name === importName
40
+ );
41
+
42
+ if (existImport) return;
43
+
44
+ // 添加 $t 到已有的 import
45
+ path.node.specifiers.push(
46
+ t.importSpecifier(t.identifier(importName), t.identifier(importName))
47
+ );
48
+ needTransform = true;
49
+ }
50
+ },
51
+ VariableDeclarator(path) {
52
+ if (t.isIdentifier(path.node.id) && path.node.id.name === importName) {
53
+ conflictTDefined = true;
54
+ path.stop();
55
+ }
56
+ },
57
+
58
+ FunctionDeclaration(path) {
59
+ if (t.isIdentifier(path.node.id) && path.node.id.name === importName) {
60
+ conflictTDefined = true;
61
+ path.stop();
62
+ }
63
+ }
64
+ });
65
+
66
+ // 跳过导入语句的生成
67
+ if (conflictTDefined) {
68
+ return {
69
+ ast,
70
+ needTransform: false
71
+ };
72
+ }
73
+
74
+ // 情况4:完全没有导入 @/i18n
75
+ if (!hasI18nImport) {
76
+ const importNode = t.importDeclaration(
77
+ [
78
+ // t.importDefaultSpecifier(t.identifier("i18n")),
79
+ t.importSpecifier(t.identifier(importName), t.identifier(importName))
80
+ ],
81
+ t.stringLiteral(importPath)
82
+ );
83
+
84
+ if (lastImportNode) {
85
+ ast.program.body.splice(
86
+ ast.program.body.indexOf(lastImportNode) + 1,
87
+ 0,
88
+ importNode
89
+ );
90
+ } else {
91
+ ast.program.body.unshift(importNode);
92
+ }
93
+ needTransform = true;
94
+ }
95
+
96
+ return {
97
+ ast,
98
+ needTransform
99
+ };
100
+ }
101
+
102
+ async function i18nImportTransform(code, path, importName, importPath) {
103
+ const scriptContent = extractScriptContent(code, path);
104
+ if (!scriptContent) return code;
105
+
106
+ try {
107
+ const ast = parser.parse(scriptContent, {
108
+ sourceType: "module",
109
+ plugins: [
110
+ "typescript",
111
+ "jsx",
112
+ path.endsWith(".vue") ? "topLevelAwait" : null
113
+ ].filter(Boolean)
114
+ });
115
+
116
+ const { needTransform } = i18nImportAstTransform(
117
+ ast,
118
+ importName,
119
+ importPath
120
+ );
121
+
122
+ // 只有当需要修改时才重新生成代码
123
+ if (needTransform) {
124
+ const { code: newScript } = generate(ast);
125
+ return path.endsWith(".vue")
126
+ ? code.replace(scriptContent, newScript)
127
+ : newScript;
128
+ }
129
+ return code;
130
+ } catch (err) {
131
+ console.warn(`[auto-import-i18n] Failed to parse ${path}:`, err);
132
+ return code;
133
+ }
134
+ }
135
+
136
+ function extractScriptContent(code, path) {
137
+ if (!path.endsWith(".vue")) return code;
138
+ const scriptMatch = code.match(/<script\b[^>]*>([\s\S]*?)<\/script>/);
139
+ return scriptMatch?.[1] || "";
140
+ }
141
+
142
+ module.exports = {
143
+ i18nImportAstTransform,
144
+ i18nImportTransform,
145
+ extractScriptContent
146
+ };