@chaoswise/intl 3.1.2 → 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.
@@ -4,6 +4,7 @@ const runUpdate = require('./scripts/update');
4
4
  const runInitConfig = require('./scripts/initConfig');
5
5
  const runAddLocale = require('./scripts/addLocale');
6
6
  const runNoZhCn = require('./scripts/nozhcn');
7
+ const runNoZhCnGuard = require('./scripts/nozhcnGuard');
7
8
  const runVerify = require('./scripts/verify');
8
9
 
9
10
  const SCRIPTS = ['intl', 'collect', 'update', 'nozhcn', 'addLocale', 'init', 'verify'];
@@ -45,9 +46,15 @@ if (script === 'verify') {
45
46
  // chaoswise-intl nozhcn check → same as above
46
47
  // chaoswise-intl nozhcn fix → auto-fix comments, report remaining issues
47
48
  // chaoswise-intl nozhcn report → non-blocking scan, output JSON report
49
+ // chaoswise-intl nozhcn guard → repository-level zero-Chinese guard (supports --report/--config/...)
48
50
  if (script === 'nozhcn') {
49
51
  // The sub-mode is the argument after 'nozhcn', default to 'check'
50
52
  const modeArg = args[scriptIndex + 1];
53
+ if (modeArg === 'guard') {
54
+ const guardArgs = args.slice(scriptIndex + 2);
55
+ runNoZhCnGuard(guardArgs);
56
+ return;
57
+ }
51
58
  const validModes = ['check', 'fix', 'report'];
52
59
  const mode = validModes.includes(modeArg) ? modeArg : 'check';
53
60
  runNoZhCn(mode);
@@ -104,8 +104,9 @@ module.exports = function (excludes = []) {
104
104
  // 'string' - 字符串字面量
105
105
  // 'template' - 模板字符串
106
106
  // 'jsx' - JSX 文本内容
107
+ // 'console' - console.* 日志中的中文(可自动清理)
107
108
  // 'identifier' - 标识符名称(极少出现,需手动修复)
108
- noZhCnCheckTypes: ['comment', 'string', 'template', 'jsx'],
109
+ noZhCnCheckTypes: ['comment', 'string', 'template', 'jsx', 'console'],
109
110
 
110
111
  // 注释中文修复策略(fix 模式生效):
111
112
  // 'clean' - 仅删除中文字符,保留非中文内容;若注释全为中文则整条删除
@@ -137,12 +138,25 @@ module.exports = function (excludes = []) {
137
138
  // 'empty' - 保留元素但清空内容:<title>搜索</title> → <title></title>
138
139
  noZhCnSvgMetadataStrategy: 'remove',
139
140
 
141
+ // JSX 内联 <svg> 的元数据元素(<title>、<desc>、<metadata>)修复策略(fix 模式生效):
142
+ // 'remove' - 删除整个元素
143
+ // 'empty' - 保留元素但清空内容
144
+ // false - 不自动修复 JSX 内联 SVG metadata(仅报告)
145
+ // undefined/null - 跟随 noZhCnSvgMetadataStrategy(向后兼容)
146
+ noZhCnJsxSvgMetadataStrategy: null,
147
+
140
148
  // SVG 属性中文修复策略(fix 模式生效):
141
149
  // 'clean' - 删除属性值中的中文字符(及 CJK 标点),清理后为空则移除整个属性;
142
150
  // 若修改了 id 属性,同步更新 SVG 内部引用(url(#…), href)
143
151
  // 'remove' - 移除包含中文的属性
144
152
  // false - 不自动修复属性中的中文
145
- noZhCnSvgAttrStrategy: 'clean', };
153
+ noZhCnSvgAttrStrategy: 'clean',
154
+
155
+ // console 中文日志修复策略(fix 模式生效):
156
+ // 'remove' - 删除包含中文的 console 语句(仅处理可安全定位的表达式语句)
157
+ // false - 不自动修复 console 中文日志(仅报告)
158
+ noZhCnConsoleStrategy: 'remove',
159
+ };
146
160
 
147
161
  excludes.forEach((key) => delete config[key]);
148
162
 
@@ -26,6 +26,9 @@ const getConf = require('./conf');
26
26
  const { targetEntryFiles } = require('./util/getTargetFiles');
27
27
  const findZhCnInFile = require('./util/findZhCnInFile');
28
28
  const fixZhCnInFile = require('./util/fixZhCnInFile');
29
+ const { fixJsxSvgAttrInFile } = require('./util/fixZhCnInFile');
30
+ const { fixJsxSvgMetadataInFile } = require('./util/fixZhCnInFile');
31
+ const { fixConsoleZhCnInFile } = require('./util/fixZhCnInFile');
29
32
  const fixI18nDefaultInFile = require('./util/fixI18nDefaultInFile');
30
33
  const findZhCnInSvgFile = require('./util/findZhCnInSvgFile');
31
34
  const fixZhCnInSvgFile = require('./util/fixZhCnInSvgFile');
@@ -35,12 +38,15 @@ const file = require('./util/file');
35
38
  // ─── Display labels ──────────────────────────────────────────────────────────
36
39
 
37
40
  const TYPE_LABEL = {
38
- comment: 'Comment ',
39
- string: 'String ',
40
- template: 'Template ',
41
- jsx: 'JSX Text ',
42
- 'jsx-svg': 'JSX SVG ',
43
- identifier: 'Identifier',
41
+ comment: 'Comment ',
42
+ string: 'String ',
43
+ template: 'Template ',
44
+ jsx: 'JSX Text ',
45
+ 'jsx-svg': 'JSX SVG ',
46
+ 'jsx-svg-attr': 'JSX SVG Attr',
47
+ 'jsx-svg-metadata': 'JSX SVG Meta',
48
+ console: 'Console Log ',
49
+ identifier: 'Identifier ',
44
50
  'svg-comment': 'SVG Comment ',
45
51
  'svg-metadata': 'SVG Metadata',
46
52
  'svg-text': 'SVG Text ',
@@ -53,6 +59,9 @@ const TYPE_COLOR = {
53
59
  template: chalk.yellow,
54
60
  jsx: chalk.cyan,
55
61
  'jsx-svg': chalk.magenta,
62
+ 'jsx-svg-attr': chalk.magenta,
63
+ 'jsx-svg-metadata': chalk.gray,
64
+ console: chalk.blue,
56
65
  identifier: chalk.red,
57
66
  'svg-comment': chalk.gray,
58
67
  'svg-metadata': chalk.gray,
@@ -61,8 +70,18 @@ const TYPE_COLOR = {
61
70
  };
62
71
 
63
72
  // Types that can be auto-fixed by nozhcn fix
64
- // Note: 'svg-attr' is conditionally fixable when noZhCnSvgAttrStrategy is set
65
- const AUTO_FIXABLE_TYPES = new Set(['comment', 'svg-comment', 'svg-metadata', 'svg-attr']);
73
+ // Note:
74
+ // - 'svg-attr' / 'jsx-svg-attr' are conditionally fixable when noZhCnSvgAttrStrategy is set
75
+ // - 'svg-metadata' / 'jsx-svg-metadata' are conditionally fixable when noZhCnSvgMetadataStrategy is set
76
+ const AUTO_FIXABLE_TYPES = new Set([
77
+ 'comment',
78
+ 'svg-comment',
79
+ 'svg-metadata',
80
+ 'svg-attr',
81
+ 'jsx-svg-attr',
82
+ 'jsx-svg-metadata',
83
+ 'console',
84
+ ]);
66
85
 
67
86
  // Types that `collect` script can handle
68
87
  const COLLECT_TYPES = new Set(['string', 'template', 'jsx']);
@@ -163,13 +182,23 @@ function printSummary(findings, mode, autoFixedCount, i18nDefaultFixed) {
163
182
  if (svgManualNeeded) {
164
183
  console.log(
165
184
  chalk.dim(
166
- ' Tip: SVG text/attribute occurrences require manual handling.\n' +
167
- ' For standalone .svg files, run fix to clean comments/metadata;\n' +
185
+ ' Tip: SVG visible text occurrences require manual handling.\n' +
186
+ ' For standalone .svg files, run fix to clean comments/metadata/attrs;\n' +
168
187
  ' for <text> content, consider replacing with a JS-driven label.'
169
188
  )
170
189
  );
171
190
  }
172
191
 
192
+ const jsxSvgAttrNeeded = findings.some((f) => f.type === 'jsx-svg-attr');
193
+ if (jsxSvgAttrNeeded) {
194
+ console.log(
195
+ chalk.dim(
196
+ " Tip: JSX SVG attribute values (e.g. id=\"编组1\") are design-tool layer name artifacts.\n" +
197
+ " Run 'chaoswise-intl nozhcn fix' to auto-strip Chinese characters from them."
198
+ )
199
+ );
200
+ }
201
+
173
202
  const identifierFound = findings.some((f) => f.type === 'identifier');
174
203
  if (identifierFound) {
175
204
  console.log(
@@ -201,7 +230,9 @@ module.exports = function noZhCn(mode) {
201
230
  noZhCnDefaultLang = null,
202
231
  noZhCnIncludeSvg = false,
203
232
  noZhCnSvgMetadataStrategy = 'remove',
233
+ noZhCnJsxSvgMetadataStrategy = null,
204
234
  noZhCnSvgAttrStrategy = 'clean',
235
+ noZhCnConsoleStrategy = 'remove',
205
236
  localeOutput = 'src/public',
206
237
  primaryRegx,
207
238
  babelPresets,
@@ -285,9 +316,9 @@ module.exports = function noZhCn(mode) {
285
316
 
286
317
  // ── Phase 2: Auto-fix (fix mode only) ────────────────────────────────────
287
318
  //
288
- // All fix sub-phases (2 / 2b / 2c) run first WITHOUT intermediate re-scans.
289
- // A single final re-scan at the end produces accurate remaining findings.
290
- // This avoids up to 3 redundant full-project AST parses on large codebases.
319
+ // Most fix sub-phases run without full-project intermediate re-scans.
320
+ // For offset-based JS/TS fixers (console / jsx-svg-attr / jsx-svg-metadata),
321
+ // we re-scan only affected files right before patching to avoid stale ranges.
291
322
 
292
323
  let autoFixedCount = 0;
293
324
  let i18nDefaultFixed = 0;
@@ -296,6 +327,15 @@ module.exports = function noZhCn(mode) {
296
327
 
297
328
  if (mode === 'fix') {
298
329
  let anyFixApplied = false;
330
+ function getLatestFindingsByType(filePath, type) {
331
+ const result = findZhCnInFile(filePath, scanOpts);
332
+ if (result.error) {
333
+ errorFiles.push({ filePath, error: result.error });
334
+ return [];
335
+ }
336
+ return result.findings.filter((f) => f.type === type);
337
+ }
338
+
299
339
  // ── Phase 2a: Fix comments in JS/TS files ─────────────────────────────
300
340
  const commentByFile = new Map();
301
341
  allFindings.forEach((f) => {
@@ -309,6 +349,24 @@ module.exports = function noZhCn(mode) {
309
349
  });
310
350
  if (autoFixedCount > 0) anyFixApplied = true;
311
351
 
352
+ // ── Phase 2a2: Remove Chinese console logs in JS/TS files ───────────
353
+ if (noZhCnConsoleStrategy) {
354
+ const consoleFiles = new Set();
355
+ allFindings.forEach((f) => {
356
+ if (f.type !== 'console') return;
357
+ consoleFiles.add(f.filePath);
358
+ });
359
+
360
+ let consoleFixedCount = 0;
361
+ consoleFiles.forEach((fp) => {
362
+ const findings = getLatestFindingsByType(fp, 'console');
363
+ if (!findings.length) return;
364
+ consoleFixedCount += fixConsoleZhCnInFile(fp, findings, noZhCnConsoleStrategy);
365
+ });
366
+ autoFixedCount += consoleFixedCount;
367
+ if (consoleFixedCount > 0) anyFixApplied = true;
368
+ }
369
+
312
370
  // ── Phase 2b: Replace .d('Chinese') with translations ─────────────────
313
371
  if (noZhCnDefaultLang) {
314
372
  const localePath = path.resolve(
@@ -333,10 +391,13 @@ module.exports = function noZhCn(mode) {
333
391
 
334
392
  if (localeMap) {
335
393
  const fixOpts = { i18nObject, i18nMethod, i18nDefaultFunctionKey, babelPresets, babelPlugins };
336
- // Collect JS/TS file paths from initial findings (exclude svg-* types)
337
- const filesToFix = new Set(
338
- allFindings.filter((f) => !f.type.startsWith('svg-')).map((f) => f.filePath)
339
- );
394
+ // Use ALL scanned files rather than only those with findings.
395
+ // When noZhCnIgnoreI18nDefault:true (the default), the initial Phase-1 scan
396
+ // blanks out .d('中文') before checking, so files whose ONLY Chinese text is
397
+ // inside .d() calls produce zero findings and would be silently skipped.
398
+ // fixI18nDefaultInFile has a cheap fast-path (no Chinese → early return),
399
+ // so scanning all files here causes no meaningful performance cost.
400
+ const filesToFix = new Set(files.map(({ filePath: fp }) => fp));
340
401
  filesToFix.forEach((fp) => {
341
402
  const result = fixI18nDefaultInFile(fp, localeMap, fixOpts);
342
403
  i18nDefaultFixed += result.fixed;
@@ -380,6 +441,53 @@ module.exports = function noZhCn(mode) {
380
441
  if (svgFixedCount > 0) anyFixApplied = true;
381
442
  }
382
443
 
444
+ // ── Phase 2d: Fix JSX SVG attribute strings in JS/TS files ───────────
445
+ // e.g. <svg id="编组1"> → <svg id="1"> (strip Chinese layer-name artifacts)
446
+ if (noZhCnSvgAttrStrategy) {
447
+ const jsxSvgAttrFiles = new Set();
448
+ allFindings.forEach((f) => {
449
+ if (f.type !== 'jsx-svg-attr') return;
450
+ jsxSvgAttrFiles.add(f.filePath);
451
+ });
452
+
453
+ jsxSvgAttrFiles.forEach((fp) => {
454
+ const findings = getLatestFindingsByType(fp, 'jsx-svg-attr');
455
+ if (!findings.length) return;
456
+ const fixed = fixJsxSvgAttrInFile(fp, findings, noZhCnSvgAttrStrategy);
457
+ svgFixedCount += fixed;
458
+ });
459
+ if (svgFixedCount > 0) anyFixApplied = true;
460
+ }
461
+
462
+ // ── Phase 2e: Fix JSX SVG metadata text in JS/TS files ───────────────
463
+ // e.g. <title>添加icon</title> (inside JSX <svg>)
464
+ // Strategy precedence:
465
+ // 1) noZhCnJsxSvgMetadataStrategy (if explicitly set, including false)
466
+ // 2) fallback to noZhCnSvgMetadataStrategy (backward-compat)
467
+ const hasExplicitJsxMetaStrategy = Object.prototype.hasOwnProperty.call(
468
+ conf,
469
+ 'noZhCnJsxSvgMetadataStrategy'
470
+ );
471
+ const jsxMetaStrategy = hasExplicitJsxMetaStrategy
472
+ ? noZhCnJsxSvgMetadataStrategy
473
+ : noZhCnSvgMetadataStrategy;
474
+
475
+ if (jsxMetaStrategy) {
476
+ const jsxSvgMetadataFiles = new Set();
477
+ allFindings.forEach((f) => {
478
+ if (f.type !== 'jsx-svg-metadata') return;
479
+ jsxSvgMetadataFiles.add(f.filePath);
480
+ });
481
+
482
+ jsxSvgMetadataFiles.forEach((fp) => {
483
+ const findings = getLatestFindingsByType(fp, 'jsx-svg-metadata');
484
+ if (!findings.length) return;
485
+ const fixed = fixJsxSvgMetadataInFile(fp, findings, jsxMetaStrategy);
486
+ svgFixedCount += fixed;
487
+ });
488
+ if (svgFixedCount > 0) anyFixApplied = true;
489
+ }
490
+
383
491
  // ── Single final re-scan after all fixes ──────────────────────────────
384
492
  if (anyFixApplied) {
385
493
  allFindings = [];
@@ -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) ─────────────
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chaoswise/intl",
3
- "version": "3.1.2",
3
+ "version": "3.1.3",
4
4
  "author": "cloudwiser",
5
5
  "description": "intl",
6
6
  "main": "lib/index.js",