@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.
@@ -0,0 +1,444 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const ROOT = process.cwd();
5
+ const DEFAULT_CONFIG_FILE = '.nozhcn-guard.json';
6
+ const CJK_REGEXP = /[\u3400-\u9FFF]/g;
7
+
8
+ const DEFAULT_CONFIG = {
9
+ include: ['.'],
10
+ extensions: ['.js', '.jsx', '.ts', '.tsx', '.json', '.svg', '.html', '.yml', '.yaml'],
11
+ scanAllFiles: true,
12
+ skipBinaryFiles: true,
13
+ exclude: [
14
+ '**/.git/**',
15
+ '**/node_modules/**',
16
+ '**/portalWeb/**',
17
+ '**/publish/**',
18
+ '**/helpdesk-dashboard/**',
19
+ '**/.kiro/**',
20
+ '**/memories/**',
21
+ ],
22
+ allowChinesePaths: [
23
+ 'docs/**',
24
+ 'locales/**',
25
+ 'src/locales/**',
26
+ 'portalWeb.json',
27
+ 'NOZHCN.md',
28
+ ],
29
+ ignoreI18nDefault: false,
30
+ maxPrintPerFile: 20,
31
+ reportFile: 'nozhcn-guard-report.json',
32
+ };
33
+
34
+ function normalizePath(filePath) {
35
+ return filePath.split(path.sep).join('/').replace(/^\.\//, '');
36
+ }
37
+
38
+ function hasGlob(pattern) {
39
+ return /[*?]/.test(pattern);
40
+ }
41
+
42
+ function escapeRegExp(text) {
43
+ return text.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
44
+ }
45
+
46
+ function globToRegExp(glob) {
47
+ const normalized = normalizePath(glob);
48
+ if (normalized.startsWith('**/')) {
49
+ const rest = normalized.slice(3);
50
+ const restRegexp = globToRegExp(rest).source.replace(/^\^/, '').replace(/\$$/, '');
51
+ return new RegExp(`^(?:.*/)?${restRegexp}$`);
52
+ }
53
+
54
+ let source = '';
55
+ for (let i = 0; i < normalized.length; i += 1) {
56
+ const ch = normalized[i];
57
+ const next = normalized[i + 1];
58
+ if (ch === '*') {
59
+ if (next === '*') {
60
+ source += '.*';
61
+ i += 1;
62
+ } else {
63
+ source += '[^/]*';
64
+ }
65
+ } else if (ch === '?') {
66
+ source += '[^/]';
67
+ } else {
68
+ source += escapeRegExp(ch);
69
+ }
70
+ }
71
+
72
+ return new RegExp(`^${source}$`);
73
+ }
74
+
75
+ function loadConfig(configFile) {
76
+ const configPath = path.resolve(ROOT, configFile || DEFAULT_CONFIG_FILE);
77
+ if (!fs.existsSync(configPath)) {
78
+ return { configPath, config: { ...DEFAULT_CONFIG } };
79
+ }
80
+
81
+ const raw = fs.readFileSync(configPath, 'utf8');
82
+ const userConfig = JSON.parse(raw);
83
+ const config = {
84
+ ...DEFAULT_CONFIG,
85
+ ...userConfig,
86
+ include: Array.isArray(userConfig.include) ? userConfig.include : DEFAULT_CONFIG.include,
87
+ extensions: Array.isArray(userConfig.extensions) ? userConfig.extensions : DEFAULT_CONFIG.extensions,
88
+ exclude: Array.isArray(userConfig.exclude) ? userConfig.exclude : DEFAULT_CONFIG.exclude,
89
+ allowChinesePaths: Array.isArray(userConfig.allowChinesePaths)
90
+ ? userConfig.allowChinesePaths
91
+ : DEFAULT_CONFIG.allowChinesePaths,
92
+ };
93
+
94
+ return { configPath, config };
95
+ }
96
+
97
+ function buildMatchers(patterns) {
98
+ const regexps = [];
99
+ const literals = new Set();
100
+
101
+ for (const pattern of patterns) {
102
+ if (hasGlob(pattern)) {
103
+ regexps.push(globToRegExp(pattern));
104
+ } else {
105
+ literals.add(normalizePath(pattern));
106
+ }
107
+ }
108
+
109
+ return { regexps, literals };
110
+ }
111
+
112
+ function matchPath(relativePath, matchers) {
113
+ const normalized = normalizePath(relativePath);
114
+ if (matchers.literals.has(normalized)) {
115
+ return true;
116
+ }
117
+
118
+ for (const regexp of matchers.regexps) {
119
+ if (regexp.test(normalized)) {
120
+ return true;
121
+ }
122
+ }
123
+
124
+ return false;
125
+ }
126
+
127
+ function shouldScanFile(relativePath, config, excludeMatchers, allowMatchers) {
128
+ if (matchPath(relativePath, excludeMatchers)) {
129
+ return false;
130
+ }
131
+ if (matchPath(relativePath, allowMatchers)) {
132
+ return false;
133
+ }
134
+ if (config.scanAllFiles) {
135
+ return true;
136
+ }
137
+
138
+ const ext = path.extname(relativePath).toLowerCase();
139
+ return config.extensions.includes(ext);
140
+ }
141
+
142
+ function isLikelyBinaryBuffer(buffer) {
143
+ if (!buffer || buffer.length === 0) {
144
+ return false;
145
+ }
146
+
147
+ if (
148
+ (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) ||
149
+ (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) ||
150
+ (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff)
151
+ ) {
152
+ return false;
153
+ }
154
+
155
+ const sampleLength = Math.min(buffer.length, 8000);
156
+ let suspicious = 0;
157
+
158
+ for (let i = 0; i < sampleLength; i += 1) {
159
+ const byte = buffer[i];
160
+ if (byte === 0) {
161
+ return true;
162
+ }
163
+
164
+ const isTextControl = byte === 9 || byte === 10 || byte === 13;
165
+ const isPrintableAscii = byte >= 32 && byte <= 126;
166
+ const isHighByte = byte >= 128;
167
+ if (!isTextControl && !isPrintableAscii && !isHighByte) {
168
+ suspicious += 1;
169
+ }
170
+ }
171
+
172
+ return suspicious / sampleLength > 0.3;
173
+ }
174
+
175
+ function decodeUtf16LE(buffer, offset) {
176
+ const chars = [];
177
+ for (let i = offset || 0; i + 1 < buffer.length; i += 2) {
178
+ chars.push(String.fromCharCode(buffer[i] | (buffer[i + 1] << 8)));
179
+ }
180
+ return chars.join('');
181
+ }
182
+
183
+ function decodeUtf16BE(buffer, offset) {
184
+ const chars = [];
185
+ for (let i = offset || 0; i + 1 < buffer.length; i += 2) {
186
+ chars.push(String.fromCharCode((buffer[i] << 8) | buffer[i + 1]));
187
+ }
188
+ return chars.join('');
189
+ }
190
+
191
+ function looksLikeUtf16WithoutBom(buffer) {
192
+ const sampleLength = Math.min(buffer.length, 8000);
193
+ if (sampleLength < 4) {
194
+ return null;
195
+ }
196
+
197
+ let evenZero = 0;
198
+ let oddZero = 0;
199
+ let pairs = 0;
200
+ for (let i = 0; i + 1 < sampleLength; i += 2) {
201
+ if (buffer[i] === 0) {
202
+ evenZero += 1;
203
+ }
204
+ if (buffer[i + 1] === 0) {
205
+ oddZero += 1;
206
+ }
207
+ pairs += 1;
208
+ }
209
+
210
+ if (pairs === 0) {
211
+ return null;
212
+ }
213
+
214
+ const evenRatio = evenZero / pairs;
215
+ const oddRatio = oddZero / pairs;
216
+
217
+ if (oddRatio > 0.35 && oddRatio > evenRatio * 1.5) {
218
+ return 'utf16le';
219
+ }
220
+ if (evenRatio > 0.35 && evenRatio > oddRatio * 1.5) {
221
+ return 'utf16be';
222
+ }
223
+
224
+ return null;
225
+ }
226
+
227
+ function decodeTextBuffer(buffer) {
228
+ if (!buffer || buffer.length === 0) {
229
+ return { text: '', isBinary: false };
230
+ }
231
+
232
+ if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) {
233
+ return { text: buffer.toString('utf8', 3), isBinary: false };
234
+ }
235
+
236
+ if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
237
+ return { text: decodeUtf16LE(buffer, 2), isBinary: false };
238
+ }
239
+
240
+ if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) {
241
+ return { text: decodeUtf16BE(buffer, 2), isBinary: false };
242
+ }
243
+
244
+ const inferredUtf16 = looksLikeUtf16WithoutBom(buffer);
245
+ if (inferredUtf16 === 'utf16le') {
246
+ return { text: decodeUtf16LE(buffer, 0), isBinary: false };
247
+ }
248
+ if (inferredUtf16 === 'utf16be') {
249
+ return { text: decodeUtf16BE(buffer, 0), isBinary: false };
250
+ }
251
+
252
+ if (isLikelyBinaryBuffer(buffer)) {
253
+ return { text: '', isBinary: true };
254
+ }
255
+
256
+ return { text: buffer.toString('utf8'), isBinary: false };
257
+ }
258
+
259
+ function eraseRangeWithSpaces(input, start, end) {
260
+ let output = '';
261
+ for (let i = start; i < end; i += 1) {
262
+ output += input[i] === '\n' ? '\n' : ' ';
263
+ }
264
+ return output;
265
+ }
266
+
267
+ function blankI18nDefaultText(content) {
268
+ const dCallRegexp = /\.d\(\s*(?:'[^'\\]*(?:\\.[^'\\]*)*'|"[^"\\]*(?:\\.[^"\\]*)*"|`[\s\S]*?`)\s*\)/g;
269
+ let transformed = '';
270
+ let cursor = 0;
271
+ let match = dCallRegexp.exec(content);
272
+
273
+ while (match) {
274
+ const start = match.index;
275
+ const end = start + match[0].length;
276
+ transformed += content.slice(cursor, start);
277
+ transformed += eraseRangeWithSpaces(content, start, end);
278
+ cursor = end;
279
+ match = dCallRegexp.exec(content);
280
+ }
281
+
282
+ transformed += content.slice(cursor);
283
+ return transformed;
284
+ }
285
+
286
+ function collectChineseLines(content, maxPrintPerFile) {
287
+ const lines = content.split('\n');
288
+ const hits = [];
289
+
290
+ for (let i = 0; i < lines.length; i += 1) {
291
+ const line = lines[i];
292
+ const match = line.match(CJK_REGEXP);
293
+ if (!match) {
294
+ continue;
295
+ }
296
+
297
+ hits.push({
298
+ line: i + 1,
299
+ sample: line.trim().slice(0, 200),
300
+ hitCountInLine: match.length,
301
+ });
302
+
303
+ if (hits.length >= maxPrintPerFile) {
304
+ break;
305
+ }
306
+ }
307
+
308
+ return hits;
309
+ }
310
+
311
+ function walkTarget(absTargetPath, files, excludeMatchers) {
312
+ if (!fs.existsSync(absTargetPath)) {
313
+ return;
314
+ }
315
+
316
+ const relativePath = normalizePath(path.relative(ROOT, absTargetPath));
317
+ if (relativePath && !relativePath.startsWith('..') && matchPath(relativePath, excludeMatchers)) {
318
+ return;
319
+ }
320
+
321
+ const stat = fs.statSync(absTargetPath);
322
+ if (stat.isFile()) {
323
+ files.push(absTargetPath);
324
+ return;
325
+ }
326
+
327
+ const entries = fs.readdirSync(absTargetPath);
328
+ for (const entry of entries) {
329
+ walkTarget(path.join(absTargetPath, entry), files, excludeMatchers);
330
+ }
331
+ }
332
+
333
+ function parseArgs(cliArgs) {
334
+ const args = cliArgs || process.argv.slice(2);
335
+ const getArgValue = (name) => {
336
+ const index = args.indexOf(name);
337
+ if (index === -1 || index === args.length - 1) {
338
+ return null;
339
+ }
340
+ return args[index + 1];
341
+ };
342
+
343
+ return {
344
+ reportOnly: args.includes('--report'),
345
+ silent: args.includes('--silent'),
346
+ configFile: getArgValue('--config') || DEFAULT_CONFIG_FILE,
347
+ reportFile: getArgValue('--report-file'),
348
+ };
349
+ }
350
+
351
+ function runNoZhCnGuard(cliArgs) {
352
+ const options = parseArgs(cliArgs);
353
+ const loaded = loadConfig(options.configFile);
354
+ const config = loaded.config;
355
+ const excludeMatchers = buildMatchers(config.exclude);
356
+ const allowMatchers = buildMatchers(config.allowChinesePaths);
357
+
358
+ const allFiles = [];
359
+ for (const target of config.include) {
360
+ walkTarget(path.resolve(ROOT, target), allFiles, excludeMatchers);
361
+ }
362
+
363
+ const reports = [];
364
+ let scannedFiles = 0;
365
+ let totalHitLines = 0;
366
+ let skippedBinaryFiles = 0;
367
+
368
+ for (const absFilePath of allFiles) {
369
+ const relativePath = normalizePath(path.relative(ROOT, absFilePath));
370
+ if (!relativePath || relativePath.startsWith('..')) {
371
+ continue;
372
+ }
373
+ if (!shouldScanFile(relativePath, config, excludeMatchers, allowMatchers)) {
374
+ continue;
375
+ }
376
+
377
+ const rawBuffer = fs.readFileSync(absFilePath);
378
+ const decoded = decodeTextBuffer(rawBuffer);
379
+ if (config.skipBinaryFiles && decoded.isBinary) {
380
+ skippedBinaryFiles += 1;
381
+ continue;
382
+ }
383
+
384
+ const scanSource = config.ignoreI18nDefault
385
+ ? blankI18nDefaultText(decoded.text)
386
+ : decoded.text;
387
+ const hitLines = collectChineseLines(scanSource, config.maxPrintPerFile);
388
+
389
+ scannedFiles += 1;
390
+ if (hitLines.length === 0) {
391
+ continue;
392
+ }
393
+
394
+ const fullHitLineCount = (scanSource.match(/[^\n]*[\u3400-\u9FFF][^\n]*/g) || []).length;
395
+ totalHitLines += fullHitLineCount;
396
+
397
+ reports.push({
398
+ file: relativePath,
399
+ hitLineCount: fullHitLineCount,
400
+ examples: hitLines,
401
+ });
402
+ }
403
+
404
+ reports.sort((a, b) => b.hitLineCount - a.hitLineCount);
405
+
406
+ const result = {
407
+ generatedAt: new Date().toISOString(),
408
+ scannedFiles,
409
+ problemFiles: reports.length,
410
+ totalHitLines,
411
+ skippedBinaryFiles,
412
+ ignoreI18nDefault: Boolean(config.ignoreI18nDefault),
413
+ reports,
414
+ };
415
+
416
+ const reportPath = path.resolve(ROOT, options.reportFile || config.reportFile);
417
+ fs.writeFileSync(reportPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
418
+
419
+ if (!options.silent) {
420
+ console.log(`[guard] config: ${normalizePath(path.relative(ROOT, loaded.configPath))}`);
421
+ console.log(`[guard] scanned files: ${scannedFiles}`);
422
+ console.log(`[guard] files with chinese: ${reports.length}`);
423
+ console.log(`[guard] chinese hit lines: ${totalHitLines}`);
424
+ if (config.skipBinaryFiles) {
425
+ console.log(`[guard] skipped binary files: ${skippedBinaryFiles}`);
426
+ }
427
+ console.log(`[guard] report: ${normalizePath(path.relative(ROOT, reportPath))}`);
428
+
429
+ if (reports.length > 0) {
430
+ console.log('\n[guard] top files:');
431
+ for (const item of reports.slice(0, 20)) {
432
+ console.log(` - ${item.file} (${item.hitLineCount} line hits)`);
433
+ }
434
+ }
435
+ }
436
+
437
+ if (options.reportOnly) {
438
+ process.exit(0);
439
+ }
440
+
441
+ process.exit(reports.length > 0 ? 1 : 0);
442
+ }
443
+
444
+ module.exports = runNoZhCnGuard;
@@ -8,6 +8,9 @@
8
8
  * - jsx : JSX text content (outside ignored components)
9
9
  * - jsx-svg : JSX text content INSIDE an ignored component (e.g. <svg>)
10
10
  * NOT auto-fixable via intl.get() — needs manual SVG handling
11
+ * - jsx-svg-metadata : JSX text content inside <svg><title|desc|metadata>
12
+ * auto-fixable by nozhcn fix metadata strategy
13
+ * - console : console.* call text containing Chinese (can be auto-removed)
11
14
  * - identifier : identifier names (rare, manual fix needed)
12
15
  *
13
16
  * Returns an array of Finding objects:
@@ -152,6 +155,17 @@ function collectCommentTokens(ast) {
152
155
  return comments;
153
156
  }
154
157
 
158
+ function isConsoleCall(nodePath) {
159
+ const callee = nodePath.node && nodePath.node.callee;
160
+ if (!callee || callee.type !== 'MemberExpression') return false;
161
+ if (callee.computed) return false;
162
+ return callee.object && callee.object.type === 'Identifier' && callee.object.name === 'console';
163
+ }
164
+
165
+ function isInsideConsoleCall(nodePath) {
166
+ return !!nodePath.findParent((p) => p.isCallExpression() && isConsoleCall(p));
167
+ }
168
+
155
169
  /**
156
170
  * Main export: scan a single file and return all Chinese findings.
157
171
  *
@@ -265,16 +279,65 @@ module.exports = function findZhCnInFile(filePath, opts) {
265
279
  checkTypes.includes('template') ||
266
280
  checkTypes.includes('jsx') ||
267
281
  checkTypes.includes('jsx-svg') ||
282
+ checkTypes.includes('jsx-svg-metadata') ||
283
+ checkTypes.includes('console') ||
268
284
  checkTypes.includes('identifier');
269
285
 
270
286
  if (needsAstScan) {
271
287
  traverse(ast, {
272
288
  // ── String literals ─────────────────────────────────────────────
273
289
  StringLiteral(nodePath) {
274
- if (!checkTypes.includes('string')) return;
275
290
  const node = nodePath.node;
276
291
  if (!primaryRegx.test(node.value)) return;
277
292
  if (isIgnoredLine(node.loc && node.loc.start.line)) return;
293
+ if (checkTypes.includes('console') && isInsideConsoleCall(nodePath)) return;
294
+
295
+ // ── JSX SVG attribute detection ──────────────────────────────
296
+ // If this string is a JSX attribute value inside an SVG element
297
+ // (or other ignored component), tag it as 'jsx-svg-attr' so it
298
+ // can be auto-fixed via the svg attr strategy rather than being
299
+ // treated as a regular string literal requiring internationalisation.
300
+ if (nodePath.parent && nodePath.parent.type === 'JSXAttribute') {
301
+ let insideIgnored = false;
302
+ nodePath.findParent((p) => {
303
+ if (!p.isJSXElement()) return false;
304
+ const openingName = p.node.openingElement && p.node.openingElement.name;
305
+ if (!openingName) return false;
306
+ const componentName =
307
+ openingName.name ||
308
+ (openingName.namespace && openingName.namespace.name) ||
309
+ '';
310
+ if (ignoreComponents.includes(componentName)) {
311
+ insideIgnored = true;
312
+ return true;
313
+ }
314
+ return false;
315
+ });
316
+
317
+ if (insideIgnored) {
318
+ if (checkTypes.includes('string') || checkTypes.includes('jsx-svg-attr')) {
319
+ const attrName =
320
+ nodePath.parent.name && nodePath.parent.name.name
321
+ ? nodePath.parent.name.name
322
+ : '';
323
+ findings.push({
324
+ filePath,
325
+ type: 'jsx-svg-attr',
326
+ line: node.loc && node.loc.start.line,
327
+ col: node.loc && node.loc.start.column,
328
+ content: node.value,
329
+ attrName,
330
+ // Byte offsets (includes quote chars) — used by fixJsxSvgAttrInFile
331
+ start: node.start,
332
+ end: node.end,
333
+ });
334
+ }
335
+ return; // do NOT also report as 'string'
336
+ }
337
+ }
338
+
339
+ // ── Normal string literal ─────────────────────────────────────
340
+ if (!checkTypes.includes('string')) return;
278
341
  if (
279
342
  ignoreI18nDefault &&
280
343
  isI18nDefaultValue(nodePath, i18nMethod, i18nDefaultFunctionKey, i18nObject)
@@ -300,6 +363,7 @@ module.exports = function findZhCnInFile(filePath, opts) {
300
363
  ) {
301
364
  return;
302
365
  }
366
+ if (checkTypes.includes('console') && isInsideConsoleCall(nodePath)) return;
303
367
  nodePath.node.quasis.forEach((quasi) => {
304
368
  if (!primaryRegx.test(quasi.value.raw)) return;
305
369
  if (isIgnoredLine(quasi.loc && quasi.loc.start.line)) return;
@@ -313,9 +377,37 @@ module.exports = function findZhCnInFile(filePath, opts) {
313
377
  });
314
378
  },
315
379
 
380
+ CallExpression(nodePath) {
381
+ if (!checkTypes.includes('console')) return;
382
+ if (!isConsoleCall(nodePath)) return;
383
+ const node = nodePath.node;
384
+ const line = node.loc && node.loc.start.line;
385
+ if (isIgnoredLine(line)) return;
386
+ const rawCall = source.slice(node.start, node.end);
387
+ if (!primaryRegx.test(rawCall)) return;
388
+
389
+ const stmt = nodePath.parentPath && nodePath.parentPath.isExpressionStatement()
390
+ ? nodePath.parentPath.node
391
+ : null;
392
+
393
+ findings.push({
394
+ filePath,
395
+ type: 'console',
396
+ line: (stmt && stmt.loc && stmt.loc.start.line) || line,
397
+ col: (stmt && stmt.loc && stmt.loc.start.column) || (node.loc && node.loc.start.column),
398
+ content: rawCall,
399
+ stmtStart: stmt && typeof stmt.start === 'number' ? stmt.start : null,
400
+ stmtEnd: stmt && typeof stmt.end === 'number' ? stmt.end : null,
401
+ });
402
+ },
403
+
316
404
  // ── JSX text ────────────────────────────────────────────────────
317
405
  JSXText(nodePath) {
318
- if (!checkTypes.includes('jsx') && !checkTypes.includes('jsx-svg')) return;
406
+ const wantsAnyJsx =
407
+ checkTypes.includes('jsx') ||
408
+ checkTypes.includes('jsx-svg') ||
409
+ checkTypes.includes('jsx-svg-metadata');
410
+ if (!wantsAnyJsx) return;
319
411
  const node = nodePath.node;
320
412
  const text = node.value.trim();
321
413
  if (!text || !primaryRegx.test(text)) return;
@@ -325,6 +417,8 @@ module.exports = function findZhCnInFile(filePath, opts) {
325
417
  // component (e.g. <svg>, <style>). If so, tag as 'jsx-svg' so
326
418
  // the caller knows this cannot be fixed via intl.get().
327
419
  let insideIgnored = false;
420
+ let insideSvgMetadata = false;
421
+ let metadataElement = null;
328
422
  nodePath.findParent((p) => {
329
423
  if (!p.isJSXElement()) return false;
330
424
  const openingName = p.node.openingElement && p.node.openingElement.name;
@@ -334,6 +428,12 @@ module.exports = function findZhCnInFile(filePath, opts) {
334
428
  openingName.name ||
335
429
  (openingName.namespace && openingName.namespace.name) ||
336
430
  '';
431
+
432
+ if (!insideSvgMetadata && ['title', 'desc', 'metadata'].includes(componentName)) {
433
+ insideSvgMetadata = true;
434
+ metadataElement = p.node;
435
+ }
436
+
337
437
  if (ignoreComponents.includes(componentName)) {
338
438
  insideIgnored = true;
339
439
  return true; // stop traversal
@@ -341,16 +441,37 @@ module.exports = function findZhCnInFile(filePath, opts) {
341
441
  return false;
342
442
  });
343
443
 
344
- const type = insideIgnored ? 'jsx-svg' : 'jsx';
345
- if (!checkTypes.includes(type)) return;
444
+ let type = 'jsx';
445
+ if (insideIgnored && insideSvgMetadata) {
446
+ type = 'jsx-svg-metadata';
447
+ if (!(checkTypes.includes('jsx') || checkTypes.includes('jsx-svg') || checkTypes.includes('jsx-svg-metadata'))) {
448
+ return;
449
+ }
450
+ } else if (insideIgnored) {
451
+ type = 'jsx-svg';
452
+ if (!checkTypes.includes('jsx-svg')) return;
453
+ } else {
454
+ if (!checkTypes.includes('jsx')) return;
455
+ }
346
456
 
347
- findings.push({
457
+ const finding = {
348
458
  filePath,
349
459
  type,
350
460
  line: node.loc && node.loc.start.line,
351
461
  col: node.loc && node.loc.start.column,
352
462
  content: text,
353
- });
463
+ };
464
+
465
+ if (type === 'jsx-svg-metadata') {
466
+ finding.start = node.start;
467
+ finding.end = node.end;
468
+ if (metadataElement) {
469
+ finding.metaElementStart = metadataElement.start;
470
+ finding.metaElementEnd = metadataElement.end;
471
+ }
472
+ }
473
+
474
+ findings.push(finding);
354
475
  },
355
476
 
356
477
  // ── Identifiers (variable / function / class names) ─────────────