@chaoswise/intl 3.1.1 → 3.1.3

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.
@@ -35,6 +35,73 @@ const log = require('./log');
35
35
  // Chinese character detection regex
36
36
  const ZH_REGEX = /[\u4e00-\u9fa5]/;
37
37
 
38
+ function escapeRegExp(text) {
39
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
40
+ }
41
+
42
+ function buildRawStringLiteral(value, quote) {
43
+ const q = quote === '"' ? '"' : '\'';
44
+ const escaped = String(value)
45
+ .replace(/\\/g, '\\\\')
46
+ .replace(/\r/g, '\\r')
47
+ .replace(/\n/g, '\\n')
48
+ .replace(/\u2028/g, '\\u2028')
49
+ .replace(/\u2029/g, '\\u2029')
50
+ .replace(new RegExp(escapeRegExp(q), 'g'), `\\${q}`);
51
+
52
+ return `${q}${escaped}${q}`;
53
+ }
54
+
55
+ function hasOwnTranslation(localeMap, key) {
56
+ return Object.prototype.hasOwnProperty.call(localeMap, key);
57
+ }
58
+
59
+ function replaceEmbeddedI18nDefaultInText(text, localeMap, opts, missing) {
60
+ const {
61
+ i18nObject = 'intl',
62
+ i18nMethod = 'get',
63
+ i18nDefaultFunctionKey = 'd',
64
+ } = opts || {};
65
+
66
+ const objectPrefix = i18nObject ? `${escapeRegExp(i18nObject)}\\s*\\.\\s*` : '';
67
+ const method = escapeRegExp(i18nMethod);
68
+ const dMethod = escapeRegExp(i18nDefaultFunctionKey);
69
+
70
+ const pattern = new RegExp(
71
+ `${objectPrefix}${method}\\s*\\(\\s*(["'])((?:\\\\.|(?!\\1).)*)\\1\\s*\\)\\s*\\.\\s*${dMethod}\\s*\\(\\s*(["'])((?:\\\\.|(?!\\3).)*)\\3\\s*\\)`,
72
+ 'g'
73
+ );
74
+
75
+ let replaced = 0;
76
+ const next = text.replace(pattern, (full, keyQuote, rawKey, valueQuote, rawValue) => {
77
+ const key = rawKey.replace(/\\(["'])/g, '$1');
78
+ const chinese = rawValue.replace(/\\(["'])/g, '$1');
79
+
80
+ if (!ZH_REGEX.test(chinese)) {
81
+ return full;
82
+ }
83
+
84
+ if (!hasOwnTranslation(localeMap, key)) {
85
+ missing.push({ key, chinese });
86
+ return full;
87
+ }
88
+ const translation = localeMap[key];
89
+
90
+ const escapedTranslation = String(translation)
91
+ .replace(/\\/g, '\\\\')
92
+ .replace(new RegExp(valueQuote, 'g'), `\\${valueQuote}`);
93
+
94
+ replaced += 1;
95
+
96
+ return full.replace(
97
+ new RegExp(`${escapeRegExp(valueQuote)}((?:\\\\.|(?!${escapeRegExp(valueQuote)}).)*)${escapeRegExp(valueQuote)}\\s*\\)$`),
98
+ `${valueQuote}${escapedTranslation}${valueQuote})`
99
+ );
100
+ });
101
+
102
+ return { text: next, replaced };
103
+ }
104
+
38
105
  /**
39
106
  * Extract the i18n key from the "inner" `.get('key')` call that wraps a `.d()`.
40
107
  *
@@ -145,12 +212,11 @@ module.exports = function fixI18nDefaultInFile(filePath, localeMap, opts) {
145
212
  const key = extractI18nKey(node, i18nMethod, i18nDefaultFunctionKey, i18nObject);
146
213
  if (!key) return;
147
214
 
148
- const translation = localeMap[key];
149
-
150
- if (!translation) {
215
+ if (!hasOwnTranslation(localeMap, key)) {
151
216
  missing.push({ key, chinese: arg.value });
152
217
  return;
153
218
  }
219
+ const translation = localeMap[key];
154
220
 
155
221
  // Replace the argument node with a new StringLiteral containing
156
222
  // the translation. We use Object.assign to preserve recast's
@@ -160,6 +226,38 @@ module.exports = function fixI18nDefaultInFile(filePath, localeMap, opts) {
160
226
  Object.assign(arg, t.stringLiteral(translation), { extra: undefined });
161
227
  fixed++;
162
228
  },
229
+ StringLiteral(nodePath) {
230
+ const node = nodePath.node;
231
+ if (!node || typeof node.value !== 'string') return;
232
+ if (!ZH_REGEX.test(node.value)) return;
233
+
234
+ const result = replaceEmbeddedI18nDefaultInText(node.value, localeMap, opts, missing);
235
+ if (result.replaced > 0 && result.text !== node.value) {
236
+ const raw = node.extra && typeof node.extra.raw === 'string' ? node.extra.raw : '';
237
+ const originalQuote = raw.startsWith('"') ? '"' : '\'';
238
+ node.value = result.text;
239
+ node.extra = {
240
+ ...(node.extra || {}),
241
+ rawValue: result.text,
242
+ raw: buildRawStringLiteral(result.text, originalQuote),
243
+ };
244
+ fixed += result.replaced;
245
+ }
246
+ },
247
+ TemplateElement(nodePath) {
248
+ const node = nodePath.node;
249
+ if (!node || !node.value || typeof node.value.cooked !== 'string') return;
250
+ if (!ZH_REGEX.test(node.value.cooked)) return;
251
+
252
+ const result = replaceEmbeddedI18nDefaultInText(node.value.cooked, localeMap, opts, missing);
253
+ if (result.replaced > 0 && result.text !== node.value.cooked) {
254
+ Object.assign(node, t.templateElement({
255
+ raw: result.text,
256
+ cooked: result.text,
257
+ }, node.tail), { extra: undefined });
258
+ fixed += result.replaced;
259
+ }
260
+ },
163
261
  });
164
262
 
165
263
  if (fixed === 0) {
@@ -167,7 +265,7 @@ module.exports = function fixI18nDefaultInFile(filePath, localeMap, opts) {
167
265
  }
168
266
 
169
267
  // ── Write back using recast (minimal diff) ───────────────────────────
170
- const newCode = recast.print(recastAst).code;
268
+ const newCode = recast.print(recastAst, { quote: 'auto' }).code;
171
269
  try {
172
270
  fs.writeFileSync(filePath, newCode, { encoding: 'utf-8' });
173
271
  } catch (err) {
@@ -215,3 +215,293 @@ module.exports = function fixZhCnInFile(filePath, findings, strategy = 'clean')
215
215
 
216
216
  return patchedCount;
217
217
  };
218
+
219
+ // ─── JSX SVG attribute fix ───────────────────────────────────────────────────
220
+
221
+ // Strip Chinese + CJK punctuation / fullwidth forms (design-tool residue)
222
+ const ZH_CLEAN_REGEX = /[\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef]/g;
223
+
224
+ /**
225
+ * Fix JSX SVG attribute findings (type 'jsx-svg-attr') by stripping Chinese
226
+ * characters from the string literal values.
227
+ *
228
+ * For each finding:
229
+ * - strategy 'clean' : strip Chinese (+ CJK punctuation) from the value.
230
+ * If the cleaned value is empty, remove the entire
231
+ * JSX attribute (name + '=' + value).
232
+ * - strategy 'remove': remove the entire JSX attribute unconditionally.
233
+ *
234
+ * Byte offsets on the finding (start / end) point to the StringLiteral node
235
+ * including its quote characters, as produced by findZhCnInFile.js.
236
+ *
237
+ * @param {string} filePath - absolute file path
238
+ * @param {Finding[]} findings - array of { type:'jsx-svg-attr', start, end, ... }
239
+ * @param {'clean'|'remove'} strategy
240
+ * @returns {number} count of attributes fixed
241
+ */
242
+ function fixJsxSvgAttrInFile(filePath, findings, strategy = 'clean') {
243
+ const attrFindings = findings.filter(
244
+ (f) => f.type === 'jsx-svg-attr' && typeof f.start === 'number' && typeof f.end === 'number'
245
+ );
246
+ if (!attrFindings.length) return 0;
247
+
248
+ // Defensive dedupe: duplicated ranges can cause second-pass patches to use
249
+ // stale offsets and accidentally trim following attributes.
250
+ const dedupMap = new Map();
251
+ attrFindings.forEach((f) => {
252
+ const key = `${f.start}:${f.end}`;
253
+ if (!dedupMap.has(key)) dedupMap.set(key, f);
254
+ });
255
+ const uniqueFindings = Array.from(dedupMap.values());
256
+
257
+ let source;
258
+ try {
259
+ source = fs.readFileSync(filePath, 'utf8');
260
+ } catch (err) {
261
+ log.error(`[nozhcn] Cannot read file for fixing: ${filePath} — ${err.message}`);
262
+ return 0;
263
+ }
264
+
265
+ // Sort descending by start so later patches don't invalidate earlier offsets
266
+ const sorted = [...uniqueFindings].sort((a, b) => b.start - a.start);
267
+
268
+ let patchedCount = 0;
269
+
270
+ for (const finding of sorted) {
271
+ const { start, end } = finding;
272
+ const original = source.slice(start, end); // e.g. "编组1" (with quotes)
273
+ if (!original || original.length < 2) continue;
274
+
275
+ const quote = original[0]; // '"' or "'"
276
+ // If boundaries are no longer a string literal, skip safely.
277
+ if ((quote !== '"' && quote !== "'") || original[original.length - 1] !== quote) {
278
+ continue;
279
+ }
280
+ const rawValue = original.slice(1, -1);
281
+ const cleaned = strategy === 'remove' ? '' : rawValue.replace(ZH_CLEAN_REGEX, '').trim();
282
+
283
+ if (cleaned) {
284
+ // Just replace the string value in-place
285
+ source = source.slice(0, start) + quote + cleaned + quote + source.slice(end);
286
+ } else {
287
+ // Cleaned value is empty — remove the whole JSX attr: `attrName="value"`
288
+ // Walk backwards from start to find the beginning of the attribute name.
289
+ // Source just before start looks like: `... attrName="`
290
+ let attrEnd = end;
291
+ let pos = start - 1; // skip the opening quote's predecessor: should be '='
292
+ if (pos >= 0 && source[pos] === '=') pos--;
293
+ // Walk backwards past the attribute name characters (word chars, '-', ':')
294
+ while (pos >= 0 && /[\w:_-]/.test(source[pos])) pos--;
295
+ // pos now points to the char before the attr name (usually a space/newline)
296
+ const attrStart = pos + 1;
297
+
298
+ // If the attribute is on its own line (only whitespace before it on that
299
+ // line), remove the entire line to avoid leaving a blank line.
300
+ let lineStart = pos;
301
+ while (lineStart > 0 && source[lineStart - 1] !== '\n') lineStart--;
302
+ const beforeOnLine = source.slice(lineStart, attrStart);
303
+ const isAloneOnLine = /^\s*$/.test(beforeOnLine);
304
+ // Also check nothing meaningful follows on the same line
305
+ let afterEnd = attrEnd;
306
+ while (afterEnd < source.length && source[afterEnd] !== '\n' && source[afterEnd] !== '\r') afterEnd++;
307
+ const afterOnLine = source.slice(attrEnd, afterEnd);
308
+ const nothingAfter = /^\s*$/.test(afterOnLine);
309
+
310
+ if (isAloneOnLine && nothingAfter) {
311
+ // Remove from the start of this line (after previous \n) through the trailing \n
312
+ const lineEnd = afterEnd < source.length ? afterEnd + 1 : afterEnd;
313
+ source = source.slice(0, lineStart) + source.slice(lineEnd);
314
+ } else {
315
+ // Attribute is inline — just remove attr + one leading whitespace
316
+ const removeFrom = pos >= 0 && /[\s]/.test(source[pos]) ? pos : attrStart;
317
+ source = source.slice(0, removeFrom) + source.slice(attrEnd);
318
+ }
319
+ }
320
+
321
+ patchedCount++;
322
+ }
323
+
324
+ if (patchedCount === 0) return 0;
325
+
326
+ try {
327
+ fs.writeFileSync(filePath, source, { encoding: 'utf-8' });
328
+ } catch (err) {
329
+ log.error(`[nozhcn] Cannot write fixed file: ${filePath} — ${err.message}`);
330
+ return 0;
331
+ }
332
+
333
+ return patchedCount;
334
+ }
335
+
336
+ /**
337
+ * Fix JSX SVG metadata text findings (type 'jsx-svg-metadata').
338
+ *
339
+ * Strategy:
340
+ * - 'empty' : clear only metadata text content (<title>中文</title> -> <title></title>)
341
+ * - 'remove' : remove the whole metadata element (<title>中文</title> -> '')
342
+ *
343
+ * @param {string} filePath
344
+ * @param {Finding[]} findings
345
+ * @param {'remove'|'empty'} strategy
346
+ * @returns {number}
347
+ */
348
+ function fixJsxSvgMetadataInFile(filePath, findings, strategy = 'remove') {
349
+ if (!strategy) return 0;
350
+ if (!['remove', 'empty'].includes(strategy)) {
351
+ log.warnToLog(`[nozhcn] Invalid JSX SVG metadata strategy: ${strategy}, skip auto-fix.`);
352
+ return 0;
353
+ }
354
+
355
+ const metadataFindings = findings.filter(
356
+ (f) => f.type === 'jsx-svg-metadata' && typeof f.start === 'number' && typeof f.end === 'number'
357
+ );
358
+ if (!metadataFindings.length) return 0;
359
+
360
+ let source;
361
+ try {
362
+ source = fs.readFileSync(filePath, 'utf8');
363
+ } catch (err) {
364
+ log.error(`[nozhcn] Cannot read file for fixing: ${filePath} — ${err.message}`);
365
+ return 0;
366
+ }
367
+
368
+ let patchedCount = 0;
369
+
370
+ if (strategy === 'empty') {
371
+ // Clear text content only, keep metadata tags.
372
+ const sorted = [...metadataFindings].sort((a, b) => b.start - a.start);
373
+ sorted.forEach((f) => {
374
+ source = source.slice(0, f.start) + source.slice(f.end);
375
+ patchedCount++;
376
+ });
377
+ } else {
378
+ // 'remove': remove whole metadata elements; dedupe by element range.
379
+ const ranges = [];
380
+ const seen = new Set();
381
+ metadataFindings.forEach((f) => {
382
+ if (typeof f.metaElementStart !== 'number' || typeof f.metaElementEnd !== 'number') return;
383
+ const key = `${f.metaElementStart}:${f.metaElementEnd}`;
384
+ if (seen.has(key)) return;
385
+ seen.add(key);
386
+ ranges.push({ start: f.metaElementStart, end: f.metaElementEnd });
387
+ });
388
+
389
+ if (!ranges.length) return 0;
390
+
391
+ ranges.sort((a, b) => b.start - a.start);
392
+ ranges.forEach(({ start, end }) => {
393
+ // If the metadata element occupies a whole line, remove that full line.
394
+ let lineStart = start;
395
+ while (lineStart > 0 && source[lineStart - 1] !== '\n') lineStart--;
396
+ const before = source.slice(lineStart, start);
397
+ const onlyWhitespaceBefore = /^\s*$/.test(before);
398
+
399
+ let lineEnd = end;
400
+ while (lineEnd < source.length && source[lineEnd] !== '\n') lineEnd++;
401
+ const after = source.slice(end, lineEnd);
402
+ const onlyWhitespaceAfter = /^\s*$/.test(after);
403
+
404
+ if (onlyWhitespaceBefore && onlyWhitespaceAfter) {
405
+ const removeEnd = lineEnd < source.length ? lineEnd + 1 : lineEnd;
406
+ source = source.slice(0, lineStart) + source.slice(removeEnd);
407
+ } else {
408
+ source = source.slice(0, start) + source.slice(end);
409
+ }
410
+ patchedCount++;
411
+ });
412
+ }
413
+
414
+ if (patchedCount === 0) return 0;
415
+
416
+ try {
417
+ fs.writeFileSync(filePath, source, { encoding: 'utf-8' });
418
+ } catch (err) {
419
+ log.error(`[nozhcn] Cannot write fixed file: ${filePath} — ${err.message}`);
420
+ return 0;
421
+ }
422
+
423
+ return patchedCount;
424
+ }
425
+
426
+ /**
427
+ * Remove console statement findings (type 'console') from a source file.
428
+ *
429
+ * We only auto-fix findings that carry ExpressionStatement ranges
430
+ * (`stmtStart` / `stmtEnd`). This keeps removal safe and predictable.
431
+ *
432
+ * @param {string} filePath
433
+ * @param {Finding[]} findings
434
+ * @param {'remove'|false} strategy
435
+ * @returns {number}
436
+ */
437
+ function fixConsoleZhCnInFile(filePath, findings, strategy = 'remove') {
438
+ if (!strategy) return 0;
439
+ if (strategy !== 'remove') {
440
+ log.warnToLog(`[nozhcn] Invalid console strategy: ${strategy}, skip auto-fix.`);
441
+ return 0;
442
+ }
443
+
444
+ const consoleFindings = findings.filter(
445
+ (f) =>
446
+ f.type === 'console' &&
447
+ typeof f.stmtStart === 'number' &&
448
+ typeof f.stmtEnd === 'number'
449
+ );
450
+ if (!consoleFindings.length) return 0;
451
+
452
+ let source;
453
+ try {
454
+ source = fs.readFileSync(filePath, 'utf8');
455
+ } catch (err) {
456
+ log.error(`[nozhcn] Cannot read file for fixing: ${filePath} — ${err.message}`);
457
+ return 0;
458
+ }
459
+
460
+ // Deduplicate statement ranges first (a single console stmt can produce multiple findings).
461
+ const rangeMap = new Map();
462
+ consoleFindings.forEach((f) => {
463
+ const key = `${f.stmtStart}:${f.stmtEnd}`;
464
+ if (!rangeMap.has(key)) {
465
+ rangeMap.set(key, { start: f.stmtStart, end: f.stmtEnd });
466
+ }
467
+ });
468
+
469
+ const ranges = Array.from(rangeMap.values()).sort((a, b) => b.start - a.start);
470
+ let patchedCount = 0;
471
+
472
+ ranges.forEach(({ start, end }) => {
473
+ let lineStart = start;
474
+ while (lineStart > 0 && source[lineStart - 1] !== '\n') lineStart--;
475
+ const before = source.slice(lineStart, start);
476
+ const onlyWhitespaceBefore = /^\s*$/.test(before);
477
+
478
+ let lineEnd = end;
479
+ while (lineEnd < source.length && source[lineEnd] !== '\n') lineEnd++;
480
+ const after = source.slice(end, lineEnd);
481
+ const onlyWhitespaceAfter = /^\s*$/.test(after);
482
+
483
+ if (onlyWhitespaceBefore && onlyWhitespaceAfter) {
484
+ const removeEnd = lineEnd < source.length ? lineEnd + 1 : lineEnd;
485
+ source = source.slice(0, lineStart) + source.slice(removeEnd);
486
+ } else {
487
+ source = source.slice(0, start) + source.slice(end);
488
+ }
489
+
490
+ patchedCount++;
491
+ });
492
+
493
+ if (patchedCount === 0) return 0;
494
+
495
+ try {
496
+ fs.writeFileSync(filePath, source, { encoding: 'utf-8' });
497
+ } catch (err) {
498
+ log.error(`[nozhcn] Cannot write fixed file: ${filePath} — ${err.message}`);
499
+ return 0;
500
+ }
501
+
502
+ return patchedCount;
503
+ }
504
+
505
+ module.exports.fixJsxSvgAttrInFile = fixJsxSvgAttrInFile;
506
+ module.exports.fixJsxSvgMetadataInFile = fixJsxSvgMetadataInFile;
507
+ module.exports.fixConsoleZhCnInFile = fixConsoleZhCnInFile;
@@ -0,0 +1,82 @@
1
+ const t = require('@babel/types');
2
+
3
+ /**
4
+ * makeVisitorDowngrade
5
+ *
6
+ * 将代码中所有孤立 id(missingIdSet 中的 id)的 intl 调用降级回原始字符串字面量,
7
+ * 便于后续重新执行 collect → update 完成国际化。
8
+ *
9
+ * 支持两种形态:
10
+ *
11
+ * 形态 A(有 .d() 兜底):
12
+ * intl.get('orphan-id').d('确认提交')
13
+ * → '确认提交'
14
+ *
15
+ * 形态 B(裸 id,无默认文案):
16
+ * intl.get('orphan-id')
17
+ * → 无法恢复文案,记录到 noDefaultIds,由调用方输出警告,代码不修改
18
+ *
19
+ * @param {{ i18nObject, i18nMethod, i18nDefaultFunctionKey }} opts 来自 conf
20
+ * @param {{ missingIdSet: Set<string>, noDefaultIds: string[] }} returns
21
+ */
22
+ module.exports = function makeVisitorDowngrade(
23
+ { i18nObject, i18nMethod, i18nDefaultFunctionKey },
24
+ returns
25
+ ) {
26
+ const { missingIdSet, noDefaultIds } = returns;
27
+
28
+ /**
29
+ * 判断一个节点是否是 intl.get('xxx') 调用,
30
+ * 返回第一个参数的字符串值,否则返回 null。
31
+ */
32
+ function matchIntlCall(node) {
33
+ if (
34
+ node.type === 'CallExpression' &&
35
+ ((node.callee.type === 'MemberExpression' &&
36
+ node.callee.object.name === i18nObject &&
37
+ node.callee.property.name === i18nMethod) ||
38
+ (!i18nObject &&
39
+ node.callee.type === 'Identifier' &&
40
+ node.callee.name === i18nMethod))
41
+ ) {
42
+ const firstArg = node.arguments[0];
43
+ if (firstArg && firstArg.type === 'StringLiteral') {
44
+ return firstArg.value;
45
+ }
46
+ }
47
+ return null;
48
+ }
49
+
50
+ return {
51
+ // 形态 A:intl.get('orphan-id').d('默认文案')
52
+ // 对应 AST:CallExpression { callee: MemberExpression('.d'), object: intl.get(...) }
53
+ CallExpression(path) {
54
+ const { node } = path;
55
+
56
+ if (
57
+ node.callee.type === 'MemberExpression' &&
58
+ node.callee.property.name === i18nDefaultFunctionKey &&
59
+ node.callee.object.type === 'CallExpression'
60
+ ) {
61
+ const id = matchIntlCall(node.callee.object);
62
+ if (id && missingIdSet.has(id)) {
63
+ // 取 .d() 的第一个参数作为恢复后的字符串
64
+ const defaultArg = node.arguments[0];
65
+ if (defaultArg && defaultArg.type === 'StringLiteral') {
66
+ returns.hasTouch = true;
67
+ path.replaceWith(t.StringLiteral(defaultArg.value));
68
+ path.skip();
69
+ return;
70
+ }
71
+ }
72
+ }
73
+
74
+ // 形态 B:裸 intl.get('orphan-id'),无 .d()
75
+ const id = matchIntlCall(node);
76
+ if (id && missingIdSet.has(id)) {
77
+ // 无法恢复文案,记录警告,不修改代码
78
+ noDefaultIds.push({ id, loc: node.loc });
79
+ }
80
+ },
81
+ };
82
+ };
@@ -17,6 +17,7 @@ const presetTypescript = require("@babel/preset-typescript").default;
17
17
 
18
18
  const makeVisitorCollect = require("./makeVisitorCollect");
19
19
  const makeVisitorUpdate = require("./makeVisitorUpdate");
20
+ const makeVisitorDowngrade = require("./makeVisitorDowngrade");
20
21
  const log = require("./log");
21
22
 
22
23
  // 获取文件中需要忽略转化通用国际化API规范的所有行号
@@ -84,7 +85,7 @@ function getIgnoreLines(ast) {
84
85
  return ignoreLines;
85
86
  }
86
87
 
87
- module.exports = function (type, files = [], conf = {}, replaceWords) {
88
+ module.exports = function (type, files = [], conf = {}, replaceWords, extraData) {
88
89
  const {
89
90
  entry,
90
91
  output,
@@ -152,6 +153,11 @@ module.exports = function (type, files = [], conf = {}, replaceWords) {
152
153
  hasImport: false, // 是否已经引入通用国际化API,import {init} from ...;
153
154
  hasTouch: false, // 是否存在需要替换的词条
154
155
  };
156
+ // downgrade 模式:注入孤立 id 集合和无默认文案收集数组
157
+ if (type === "downgrade") {
158
+ r.missingIdSet = replaceWords; // 复用 replaceWords 参数位传入 Set<string>
159
+ r.noDefaultIds = extraData; // 无法恢复的裸 id 收集数组
160
+ }
155
161
  const sourceCode = fs.readFileSync(filePath, "utf8");
156
162
 
157
163
  let ast;
@@ -178,6 +184,8 @@ module.exports = function (type, files = [], conf = {}, replaceWords) {
178
184
  let makeVisitor = makeVisitorCollect;
179
185
  if (type === "update") {
180
186
  makeVisitor = makeVisitorUpdate;
187
+ } else if (type === "downgrade") {
188
+ makeVisitor = makeVisitorDowngrade;
181
189
  }
182
190
 
183
191
  try {
@@ -192,7 +200,7 @@ module.exports = function (type, files = [], conf = {}, replaceWords) {
192
200
 
193
201
  // 不传入需要替换的文词条则不进行文件修改
194
202
  // 在只需要提取中文词条的情况下不传入replaceWords
195
- if (!replaceWords) return;
203
+ if (type !== "downgrade" && !replaceWords) return;
196
204
 
197
205
  // 使用 recast 输出代码,仅重新打印被修改的 AST 节点,保留原始格式
198
206
  let code = recast.print(recastAst).code;
@@ -200,6 +208,7 @@ module.exports = function (type, files = [], conf = {}, replaceWords) {
200
208
  if (!r.hasTouch) {
201
209
  code = sourceCode;
202
210
  } else if (
211
+ type !== "downgrade" &&
203
212
  !r.hasImport &&
204
213
  !file.special &&
205
214
  importCode &&