@chaoswise/intl 3.0.0 → 3.1.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,377 @@
1
+ /**
2
+ * findZhCnInFile.js
3
+ *
4
+ * Scans a single source file for Chinese characters across all node types:
5
+ * - comment : inline / block comments
6
+ * - string : string literals
7
+ * - template : template literal quasis
8
+ * - jsx : JSX text content (outside ignored components)
9
+ * - jsx-svg : JSX text content INSIDE an ignored component (e.g. <svg>)
10
+ * NOT auto-fixable via intl.get() — needs manual SVG handling
11
+ * - identifier : identifier names (rare, manual fix needed)
12
+ *
13
+ * Returns an array of Finding objects:
14
+ * { filePath, type, line, col, content, start?, end? }
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const babel = require('@babel/core');
19
+ const traverse = require('@babel/traverse').default;
20
+ const buildTransformOptions = require('./babelOptions');
21
+ // Note: recast is intentionally NOT imported here. findZhCnInFile is a read-only
22
+ // scanner: it only needs babel.parseSync + @babel/traverse, which give accurate
23
+ // char-index positions for the original source. Importing recast would cause
24
+ // it to expand \t to spaces before parsing, shifting all token positions after
25
+ // any tab character and corrupting the start/end offsets used by fixZhCnInFile.
26
+
27
+ // Default Chinese character range (same as primaryRegx default)
28
+ const DEFAULT_ZH_REGEX = /[\u4e00-\u9fa5]/;
29
+
30
+ /**
31
+ * Parse nozhcn-disable / nozhcn-enable block comments and
32
+ * nozhcn-disable-line single-line disable directives.
33
+ * Returns a Set of line numbers that should be skipped.
34
+ */
35
+ function getNoZhCnIgnoreLines(ast) {
36
+ const ignoreBlocks = [];
37
+
38
+ function processComment(comment) {
39
+ const { type, value, loc } = comment;
40
+ const last = ignoreBlocks.length - 1;
41
+
42
+ if (type === 'CommentLine' && value.trim() === 'nozhcn-disable-line') {
43
+ ignoreBlocks.push({ start: loc.start.line, end: loc.start.line });
44
+ return;
45
+ }
46
+ if (type === 'CommentBlock' && value.trim() === 'nozhcn-disable') {
47
+ if (last < 0 || ignoreBlocks[last].end !== undefined) {
48
+ ignoreBlocks.push({ start: loc.start.line });
49
+ }
50
+ return;
51
+ }
52
+ if (type === 'CommentBlock' && value.trim() === 'nozhcn-enable') {
53
+ if (last >= 0 && ignoreBlocks[last].end === undefined) {
54
+ ignoreBlocks[last].end = loc.start.line;
55
+ }
56
+ }
57
+ }
58
+
59
+ if (ast.comments && Array.isArray(ast.comments)) {
60
+ ast.comments.forEach(processComment);
61
+ }
62
+ if (ast.tokens && Array.isArray(ast.tokens)) {
63
+ ast.tokens.forEach((token) => {
64
+ if (token.type === 'CommentLine' || token.type === 'CommentBlock') {
65
+ processComment(token);
66
+ }
67
+ });
68
+ }
69
+
70
+ // Unclosed disable block extends to end of file
71
+ const len = ignoreBlocks.length;
72
+ if (len > 0 && ignoreBlocks[len - 1].end === undefined && ast.loc && ast.loc.end) {
73
+ ignoreBlocks[len - 1].end = ast.loc.end.line;
74
+ }
75
+
76
+ const ignoreLines = new Set();
77
+ for (const block of ignoreBlocks) {
78
+ for (let i = block.start; i <= (block.end || block.start); i++) {
79
+ ignoreLines.add(i);
80
+ }
81
+ }
82
+ return ignoreLines;
83
+ }
84
+
85
+ /**
86
+ * Check whether a StringLiteral/TemplateLiteral is an i18n .d() default value.
87
+ * Pattern: intl.get('key').d('中文') or i18nMethod('key').d('中文')
88
+ *
89
+ * @param {NodePath} nodePath - the babel NodePath of the literal node
90
+ * @param {string} i18nMethod - e.g. 'get'
91
+ * @param {string} i18nDefaultFunctionKey - e.g. 'd'
92
+ * @param {string} i18nObject - e.g. 'intl'
93
+ */
94
+ function isI18nDefaultValue(nodePath, i18nMethod, i18nDefaultFunctionKey, i18nObject) {
95
+ const parent = nodePath.parent;
96
+ if (!parent || parent.type !== 'CallExpression') return false;
97
+
98
+ const callee = parent.callee;
99
+ if (!callee || callee.type !== 'MemberExpression') return false;
100
+ if (callee.property.name !== i18nDefaultFunctionKey) return false;
101
+
102
+ // callee.object should be a call to i18nObject.i18nMethod(...)
103
+ const innerCall = callee.object;
104
+ if (!innerCall || innerCall.type !== 'CallExpression') return false;
105
+
106
+ const innerCallee = innerCall.callee;
107
+ if (!innerCallee) return false;
108
+
109
+ if (innerCallee.type === 'MemberExpression') {
110
+ // intl.get(...)
111
+ return (
112
+ innerCallee.property.name === i18nMethod &&
113
+ (!i18nObject || innerCallee.object.name === i18nObject)
114
+ );
115
+ }
116
+ if (innerCallee.type === 'Identifier') {
117
+ // get(...) without i18nObject prefix
118
+ return innerCallee.name === i18nMethod && !i18nObject;
119
+ }
120
+ return false;
121
+ }
122
+
123
+ /**
124
+ * Collect all comment tokens from the AST.
125
+ * We use ast.tokens (with parserOpts.tokens:true) as the primary source
126
+ * because it carries reliable start/end byte offsets needed for fixing.
127
+ * We deduplicate using start position.
128
+ */
129
+ function collectCommentTokens(ast) {
130
+ const seen = new Set();
131
+ const comments = [];
132
+
133
+ function add(c) {
134
+ if (seen.has(c.start)) return;
135
+ seen.add(c.start);
136
+ comments.push(c);
137
+ }
138
+
139
+ // From ast.comments (Babel Program node)
140
+ if (ast.comments && Array.isArray(ast.comments)) {
141
+ ast.comments.forEach(add);
142
+ }
143
+ // From ast.tokens (recast / Babel with tokens:true)
144
+ if (ast.tokens && Array.isArray(ast.tokens)) {
145
+ ast.tokens.forEach((token) => {
146
+ if (token.type === 'CommentLine' || token.type === 'CommentBlock') {
147
+ add(token);
148
+ }
149
+ });
150
+ }
151
+
152
+ return comments;
153
+ }
154
+
155
+ /**
156
+ * Main export: scan a single file and return all Chinese findings.
157
+ *
158
+ * @param {string} filePath - absolute path to the file
159
+ * @param {object} opts
160
+ * - checkTypes string[] - which types to scan
161
+ * - babelPresets array
162
+ * - babelPlugins array
163
+ * - primaryRegx RegExp - Chinese detection regex
164
+ * - i18nObject string
165
+ * - i18nMethod string
166
+ * - i18nDefaultFunctionKey string
167
+ * - ignoreI18nDefault boolean - skip .d('中文') default values
168
+ * - ignoreComponents string[] - JSX component names whose content is
169
+ * tagged as 'jsx-svg' instead of 'jsx'
170
+ * (e.g. ['svg', 'style'])
171
+ *
172
+ * @returns {{ findings: Finding[], error: string|null }}
173
+ */
174
+ module.exports = function findZhCnInFile(filePath, opts) {
175
+ const {
176
+ checkTypes = ['comment', 'string', 'template', 'jsx'],
177
+ babelPresets = [],
178
+ babelPlugins = [],
179
+ primaryRegx = DEFAULT_ZH_REGEX,
180
+ i18nObject = 'intl',
181
+ i18nMethod = 'get',
182
+ i18nDefaultFunctionKey = 'd',
183
+ ignoreI18nDefault = true,
184
+ ignoreComponents = ['svg', 'style'],
185
+ } = opts || {};
186
+
187
+ const findings = [];
188
+
189
+ // ── Read source ──────────────────────────────────────────────────────
190
+ let source;
191
+ try {
192
+ source = fs.readFileSync(filePath, 'utf8');
193
+ } catch (err) {
194
+ return { findings, error: `Cannot read file: ${err.message}` };
195
+ }
196
+
197
+ // Fast-path: skip files with no Chinese at all
198
+ if (!primaryRegx.test(source)) {
199
+ return { findings, error: null };
200
+ }
201
+
202
+ // ── Parse AST ────────────────────────────────────────────────────────
203
+ // Parse with babel.parseSync directly on the original source string.
204
+ // We intentionally do NOT go through recast here: recast expands \t to spaces
205
+ // before passing the source to the parser, which shifts all token start/end
206
+ // positions after each tab by (tabWidth - 1) characters. Those shifted
207
+ // positions would then be wrong when used by fixZhCnInFile to patch the
208
+ // original (non-expanded) file on disk.
209
+ const transformOptions = buildTransformOptions(babelPresets, babelPlugins);
210
+ let ast;
211
+ try {
212
+ ast = babel.parseSync(source, transformOptions);
213
+ } catch (err) {
214
+ return { findings, error: `Parse error: ${err.message}` };
215
+ }
216
+
217
+ const ignoreLines = getNoZhCnIgnoreLines(ast);
218
+
219
+ function isIgnoredLine(line) {
220
+ return ignoreLines.has(line);
221
+ }
222
+
223
+ // ── Scan comments ────────────────────────────────────────────────────
224
+ if (checkTypes.includes('comment')) {
225
+ const commentTokens = collectCommentTokens(ast);
226
+ commentTokens.forEach((comment) => {
227
+ const line = comment.loc && comment.loc.start.line;
228
+
229
+ // Skip nozhcn control directives themselves
230
+ const trimmedValue = comment.value.trim();
231
+ if (
232
+ trimmedValue === 'nozhcn-disable-line' ||
233
+ trimmedValue === 'nozhcn-disable' ||
234
+ trimmedValue === 'nozhcn-enable' ||
235
+ trimmedValue === 'cw-i18n-disable-line' ||
236
+ trimmedValue === 'cw-i18n-disable' ||
237
+ trimmedValue === 'cw-i18n-enable'
238
+ ) {
239
+ return;
240
+ }
241
+
242
+ if (isIgnoredLine(line)) return;
243
+ if (!primaryRegx.test(comment.value)) return;
244
+
245
+ const delimiter =
246
+ comment.type === 'CommentLine' ? `//${comment.value}` : `/*${comment.value}*/`;
247
+
248
+ findings.push({
249
+ filePath,
250
+ type: 'comment',
251
+ line,
252
+ col: comment.loc ? comment.loc.start.column : 0,
253
+ content: delimiter,
254
+ commentType: comment.type,
255
+ // Byte offsets for fixZhCnInFile
256
+ start: comment.start,
257
+ end: comment.end,
258
+ });
259
+ });
260
+ }
261
+
262
+ // ── Traverse AST nodes ───────────────────────────────────────────────
263
+ const needsAstScan =
264
+ checkTypes.includes('string') ||
265
+ checkTypes.includes('template') ||
266
+ checkTypes.includes('jsx') ||
267
+ checkTypes.includes('jsx-svg') ||
268
+ checkTypes.includes('identifier');
269
+
270
+ if (needsAstScan) {
271
+ traverse(ast, {
272
+ // ── String literals ─────────────────────────────────────────────
273
+ StringLiteral(nodePath) {
274
+ if (!checkTypes.includes('string')) return;
275
+ const node = nodePath.node;
276
+ if (!primaryRegx.test(node.value)) return;
277
+ if (isIgnoredLine(node.loc && node.loc.start.line)) return;
278
+ if (
279
+ ignoreI18nDefault &&
280
+ isI18nDefaultValue(nodePath, i18nMethod, i18nDefaultFunctionKey, i18nObject)
281
+ ) {
282
+ return;
283
+ }
284
+ findings.push({
285
+ filePath,
286
+ type: 'string',
287
+ line: node.loc && node.loc.start.line,
288
+ col: node.loc && node.loc.start.column,
289
+ content: node.value,
290
+ });
291
+ },
292
+
293
+ // ── Template literal quasis ─────────────────────────────────────
294
+ TemplateLiteral(nodePath) {
295
+ if (!checkTypes.includes('template')) return;
296
+ // Skip `intl.get('key').d(\`中文\`)` default values when ignoreI18nDefault is on
297
+ if (
298
+ ignoreI18nDefault &&
299
+ isI18nDefaultValue(nodePath, i18nMethod, i18nDefaultFunctionKey, i18nObject)
300
+ ) {
301
+ return;
302
+ }
303
+ nodePath.node.quasis.forEach((quasi) => {
304
+ if (!primaryRegx.test(quasi.value.raw)) return;
305
+ if (isIgnoredLine(quasi.loc && quasi.loc.start.line)) return;
306
+ findings.push({
307
+ filePath,
308
+ type: 'template',
309
+ line: quasi.loc && quasi.loc.start.line,
310
+ col: quasi.loc && quasi.loc.start.column,
311
+ content: quasi.value.raw,
312
+ });
313
+ });
314
+ },
315
+
316
+ // ── JSX text ────────────────────────────────────────────────────
317
+ JSXText(nodePath) {
318
+ if (!checkTypes.includes('jsx') && !checkTypes.includes('jsx-svg')) return;
319
+ const node = nodePath.node;
320
+ const text = node.value.trim();
321
+ if (!text || !primaryRegx.test(text)) return;
322
+ if (isIgnoredLine(node.loc && node.loc.start.line)) return;
323
+
324
+ // Walk up the ancestor chain to see if we are inside an ignored
325
+ // component (e.g. <svg>, <style>). If so, tag as 'jsx-svg' so
326
+ // the caller knows this cannot be fixed via intl.get().
327
+ let insideIgnored = false;
328
+ nodePath.findParent((p) => {
329
+ if (!p.isJSXElement()) return false;
330
+ const openingName = p.node.openingElement && p.node.openingElement.name;
331
+ if (!openingName) return false;
332
+ // Handle plain tags (name.name) and namespaced tags (name.namespace.name)
333
+ const componentName =
334
+ openingName.name ||
335
+ (openingName.namespace && openingName.namespace.name) ||
336
+ '';
337
+ if (ignoreComponents.includes(componentName)) {
338
+ insideIgnored = true;
339
+ return true; // stop traversal
340
+ }
341
+ return false;
342
+ });
343
+
344
+ const type = insideIgnored ? 'jsx-svg' : 'jsx';
345
+ if (!checkTypes.includes(type)) return;
346
+
347
+ findings.push({
348
+ filePath,
349
+ type,
350
+ line: node.loc && node.loc.start.line,
351
+ col: node.loc && node.loc.start.column,
352
+ content: text,
353
+ });
354
+ },
355
+
356
+ // ── Identifiers (variable / function / class names) ─────────────
357
+ Identifier(nodePath) {
358
+ if (!checkTypes.includes('identifier')) return;
359
+ const node = nodePath.node;
360
+ if (!primaryRegx.test(node.name)) return;
361
+ if (isIgnoredLine(node.loc && node.loc.start.line)) return;
362
+ findings.push({
363
+ filePath,
364
+ type: 'identifier',
365
+ line: node.loc && node.loc.start.line,
366
+ col: node.loc && node.loc.start.column,
367
+ content: node.name,
368
+ });
369
+ },
370
+ });
371
+ }
372
+
373
+ // Sort findings by line number for consistent output
374
+ findings.sort((a, b) => (a.line || 0) - (b.line || 0));
375
+
376
+ return { findings, error: null };
377
+ };
@@ -0,0 +1,139 @@
1
+ /**
2
+ * findZhCnInSvgFile.js
3
+ *
4
+ * Scans a standalone SVG (XML) file for Chinese characters.
5
+ * Uses regex-based parsing rather than a full XML AST to keep the
6
+ * dependency footprint minimal.
7
+ *
8
+ * SVG finding types
9
+ * ─────────────────
10
+ * svg-comment : <!-- 中文注释 -->
11
+ * svg-metadata : content inside <title>, <desc>, <metadata> elements
12
+ * (usually design-tool exports; safe to auto-fix)
13
+ * svg-text : content inside <text> elements (visible output)
14
+ * (NOT auto-fixable — requires design decision)
15
+ * svg-attr : attribute value containing Chinese
16
+ * (NOT auto-fixable — requires semantic decision)
17
+ *
18
+ * Ignore directive
19
+ * ────────────────
20
+ * Any line containing `nozhcn-disable-line` anywhere in it is skipped.
21
+ * Block ignore is not supported in SVG (XML comments only).
22
+ */
23
+
24
+ const fs = require('fs');
25
+
26
+ const ZH_REGEX = /[\u4e00-\u9fa5]/;
27
+ const ZH_REGEX_GLOBAL = /[\u4e00-\u9fa5]/g;
28
+
29
+ /**
30
+ * Compute the 1-based line number for a given byte offset in `source`.
31
+ */
32
+ function lineOf(source, index) {
33
+ let line = 1;
34
+ for (let i = 0; i < index; i++) {
35
+ if (source[i] === '\n') line++;
36
+ }
37
+ return line;
38
+ }
39
+
40
+ /**
41
+ * Compute the 0-based column for a given byte offset in `source`.
42
+ */
43
+ function colOf(source, index) {
44
+ let col = 0;
45
+ for (let i = index - 1; i >= 0 && source[i] !== '\n'; i--) {
46
+ col++;
47
+ }
48
+ return col;
49
+ }
50
+
51
+ /**
52
+ * Get the set of line numbers that contain a `nozhcn-disable-line` directive in
53
+ * SVG: we look for the directive in XML comments on the same line.
54
+ */
55
+ function getIgnoredLines(source) {
56
+ const ignored = new Set();
57
+ const lines = source.split('\n');
58
+ lines.forEach((line, idx) => {
59
+ if (/nozhcn-disable-line/.test(line)) {
60
+ ignored.add(idx + 1);
61
+ }
62
+ });
63
+ return ignored;
64
+ }
65
+
66
+ /**
67
+ * Scan a standalone SVG file for Chinese characters.
68
+ *
69
+ * @param {string} filePath - absolute path to the .svg file
70
+ * @returns {{ findings: Finding[], error: string|null }}
71
+ */
72
+ module.exports = function findZhCnInSvgFile(filePath) {
73
+ let source;
74
+ try {
75
+ source = fs.readFileSync(filePath, 'utf8');
76
+ } catch (err) {
77
+ return { findings: [], error: `Cannot read file: ${err.message}` };
78
+ }
79
+
80
+ // Fast-path: skip files with no Chinese at all
81
+ if (!ZH_REGEX.test(source)) {
82
+ return { findings: [], error: null };
83
+ }
84
+
85
+ const findings = [];
86
+ const ignoredLines = getIgnoredLines(source);
87
+
88
+ /**
89
+ * Add a finding only if it contains Chinese and its line is not ignored.
90
+ * `matchIndex` is the byte offset of the START of the full regex match.
91
+ * We point the line/col at the FIRST Chinese character in the match.
92
+ */
93
+ function addFinding(type, matchStr, matchIndex) {
94
+ if (!ZH_REGEX.test(matchStr)) return;
95
+ // Find the offset of the first Chinese char inside this match
96
+ const zhOffset = matchStr.search(ZH_REGEX);
97
+ const zhAbsIndex = matchIndex + zhOffset;
98
+ const line = lineOf(source, zhAbsIndex);
99
+ if (ignoredLines.has(line)) return;
100
+ const col = colOf(source, zhAbsIndex);
101
+ const content = matchStr.replace(/\s+/g, ' ').trim().slice(0, 100);
102
+ findings.push({ filePath, type, line, col, content });
103
+ }
104
+
105
+ // ── 1. XML comments <!--...--> ─────────────────────────────────────────
106
+ const commentReg = /<!--([\s\S]*?)-->/g;
107
+ let m;
108
+ while ((m = commentReg.exec(source)) !== null) {
109
+ addFinding('svg-comment', m[0], m.index);
110
+ }
111
+
112
+ // ── 2. Metadata elements <title>, <desc>, <metadata> ───────────────────
113
+ // These are typically generated by design tools (Figma, Sketch, Illustrator)
114
+ // and are safe to remove or empty.
115
+ const metaReg = /<(title|desc|metadata)(\s[^>]*)?>[\s\S]*?<\/\1>/gi;
116
+ while ((m = metaReg.exec(source)) !== null) {
117
+ addFinding('svg-metadata', m[0], m.index);
118
+ }
119
+
120
+ // ── 3. <text> elements (visible rendered text in SVG) ───────────────────
121
+ // Can be nested: <text><tspan>中文</tspan></text>
122
+ // NOT auto-fixable.
123
+ const textReg = /<text[\s>][\s\S]*?<\/text>/gi;
124
+ while ((m = textReg.exec(source)) !== null) {
125
+ addFinding('svg-text', m[0], m.index);
126
+ }
127
+
128
+ // ── 4. Attribute values containing Chinese ───────────────────────────────
129
+ // Matches: attr="中文" or attr='中文'
130
+ // NOT auto-fixable.
131
+ const attrReg = /\s[\w:_-]+=["']([^"']*[\u4e00-\u9fa5][^"']*)["']/g;
132
+ while ((m = attrReg.exec(source)) !== null) {
133
+ addFinding('svg-attr', m[0].trim(), m.index);
134
+ }
135
+
136
+ findings.sort((a, b) => a.line - b.line);
137
+
138
+ return { findings, error: null };
139
+ };
@@ -0,0 +1,179 @@
1
+ /**
2
+ * fixI18nDefaultInFile.js
3
+ *
4
+ * Replaces Chinese text inside .d() default value calls with a translation
5
+ * loaded from the project's locale JSON file.
6
+ *
7
+ * Target pattern (AST):
8
+ * intl.get('some-uuid-key').d('中文回退值')
9
+ * └── i18nObject.i18nMethod(key).i18nDefaultFunctionKey(chineseText)
10
+ *
11
+ * How it works:
12
+ * 1. Read the locale JSON for `targetLang`
13
+ * from `{localeOutput}/locales/{targetLang}.json`
14
+ * 2. Parse the file with Babel + recast (preserving original formatting)
15
+ * 3. Traverse AST: find CallExpression nodes matching the .d() pattern
16
+ * 4. For each match, extract the i18n key from the inner .get() call,
17
+ * look up the translation in the locale map
18
+ * 5. If a translation exists: replace the StringLiteral argument of .d()
19
+ * using recast (only the changed node is reprinted)
20
+ * 6. Write back to file
21
+ *
22
+ * Returns { fixed: number, missing: Array<{filePath, key, chinese}> }
23
+ * fixed - number of .d() values successfully replaced
24
+ * missing - keys where no translation was found in the locale file
25
+ */
26
+
27
+ const fs = require('fs');
28
+ const recast = require('recast');
29
+ const babel = require('@babel/core');
30
+ const traverse = require('@babel/traverse').default;
31
+ const t = require('@babel/types');
32
+ const buildTransformOptions = require('./babelOptions');
33
+ const log = require('./log');
34
+
35
+ // Chinese character detection regex
36
+ const ZH_REGEX = /[\u4e00-\u9fa5]/;
37
+
38
+ /**
39
+ * Extract the i18n key from the "inner" `.get('key')` call that wraps a `.d()`.
40
+ *
41
+ * Given the CallExpression node of `.d('中文')`:
42
+ * node.callee = MemberExpression { object: CallExpression(.get('key')), property: 'd' }
43
+ *
44
+ * @param {ASTNode} callNode - the outer CallExpression (.d(...))
45
+ * @param {string} i18nMethod - e.g. 'get'
46
+ * @param {string} i18nDefaultFunctionKey - e.g. 'd'
47
+ * @param {string} i18nObject - e.g. 'intl' (may be empty)
48
+ * @returns {string|null} the key string, or null if not matched
49
+ */
50
+ function extractI18nKey(callNode, i18nMethod, i18nDefaultFunctionKey, i18nObject) {
51
+ const callee = callNode.callee;
52
+ if (!callee || callee.type !== 'MemberExpression') return null;
53
+ if (callee.property.name !== i18nDefaultFunctionKey) return null;
54
+
55
+ const innerCall = callee.object;
56
+ if (!innerCall || innerCall.type !== 'CallExpression') return null;
57
+
58
+ const innerCallee = innerCall.callee;
59
+ if (!innerCallee) return null;
60
+
61
+ const methodMatches =
62
+ (innerCallee.type === 'MemberExpression' &&
63
+ innerCallee.property.name === i18nMethod &&
64
+ (!i18nObject || innerCallee.object.name === i18nObject)) ||
65
+ (innerCallee.type === 'Identifier' &&
66
+ innerCallee.name === i18nMethod &&
67
+ !i18nObject);
68
+
69
+ if (!methodMatches) return null;
70
+
71
+ // The first argument of the inner call must be a string literal (the key)
72
+ const keyArg = innerCall.arguments && innerCall.arguments[0];
73
+ if (!keyArg || keyArg.type !== 'StringLiteral') return null;
74
+
75
+ return keyArg.value;
76
+ }
77
+
78
+ /**
79
+ * Fix .d('中文') calls in a single file by replacing Chinese default values
80
+ * with translations from the locale map.
81
+ *
82
+ * @param {string} filePath - absolute file path
83
+ * @param {Object} localeMap - { key: translationText }
84
+ * @param {Object} opts
85
+ * - i18nObject string e.g. 'intl'
86
+ * - i18nMethod string e.g. 'get'
87
+ * - i18nDefaultFunctionKey string e.g. 'd'
88
+ * - babelPresets array
89
+ * - babelPlugins array
90
+ *
91
+ * @returns {{ fixed: number, missing: Array<{key, chinese}> }}
92
+ */
93
+ module.exports = function fixI18nDefaultInFile(filePath, localeMap, opts) {
94
+ const {
95
+ i18nObject = 'intl',
96
+ i18nMethod = 'get',
97
+ i18nDefaultFunctionKey = 'd',
98
+ babelPresets = [],
99
+ babelPlugins = [],
100
+ } = opts || {};
101
+
102
+ let fixed = 0;
103
+ const missing = [];
104
+
105
+ // ── Read source ──────────────────────────────────────────────────────
106
+ let source;
107
+ try {
108
+ source = fs.readFileSync(filePath, 'utf8');
109
+ } catch (err) {
110
+ log.error(`[nozhcn] Cannot read file: ${filePath} — ${err.message}`);
111
+ return { fixed, missing };
112
+ }
113
+
114
+ // Fast-path: skip files with no Chinese and no i18nMethod calls at all
115
+ if (!ZH_REGEX.test(source)) {
116
+ return { fixed, missing };
117
+ }
118
+
119
+ // ── Parse AST ────────────────────────────────────────────────────────
120
+ const transformOptions = buildTransformOptions(babelPresets, babelPlugins);
121
+ let recastAst;
122
+ try {
123
+ recastAst = recast.parse(source, {
124
+ parser: {
125
+ parse(src) {
126
+ return babel.parseSync(src, transformOptions);
127
+ },
128
+ },
129
+ });
130
+ } catch (err) {
131
+ log.error(`[nozhcn] Parse error in ${filePath}: ${err.message}`);
132
+ return { fixed, missing };
133
+ }
134
+
135
+ // ── Traverse and patch ───────────────────────────────────────────────
136
+ traverse(recastAst, {
137
+ CallExpression(nodePath) {
138
+ const node = nodePath.node;
139
+
140
+ // Only process nodes whose single argument is a Chinese StringLiteral
141
+ const arg = node.arguments && node.arguments[0];
142
+ if (!arg || arg.type !== 'StringLiteral') return;
143
+ if (!ZH_REGEX.test(arg.value)) return;
144
+
145
+ const key = extractI18nKey(node, i18nMethod, i18nDefaultFunctionKey, i18nObject);
146
+ if (!key) return;
147
+
148
+ const translation = localeMap[key];
149
+
150
+ if (!translation) {
151
+ missing.push({ key, chinese: arg.value });
152
+ return;
153
+ }
154
+
155
+ // Replace the argument node with a new StringLiteral containing
156
+ // the translation. We use Object.assign to preserve recast's
157
+ // original node metadata while updating the value fields.
158
+ // Setting `extra` to undefined forces recast to re-print using
159
+ // the new value rather than the cached raw source.
160
+ Object.assign(arg, t.stringLiteral(translation), { extra: undefined });
161
+ fixed++;
162
+ },
163
+ });
164
+
165
+ if (fixed === 0) {
166
+ return { fixed, missing };
167
+ }
168
+
169
+ // ── Write back using recast (minimal diff) ───────────────────────────
170
+ const newCode = recast.print(recastAst).code;
171
+ try {
172
+ fs.writeFileSync(filePath, newCode, { encoding: 'utf-8' });
173
+ } catch (err) {
174
+ log.error(`[nozhcn] Cannot write file: ${filePath} — ${err.message}`);
175
+ return { fixed: 0, missing };
176
+ }
177
+
178
+ return { fixed, missing };
179
+ };