@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.
- package/bin/chaoswise-intl.js +20 -12
- package/bin/scripts/conf/default.js +49 -1
- package/bin/scripts/nozhcn.js +440 -0
- package/bin/scripts/util/babelOptions.js +49 -0
- package/bin/scripts/util/findZhCnInFile.js +377 -0
- package/bin/scripts/util/findZhCnInSvgFile.js +139 -0
- package/bin/scripts/util/fixI18nDefaultInFile.js +179 -0
- package/bin/scripts/util/fixZhCnInFile.js +217 -0
- package/bin/scripts/util/fixZhCnInSvgFile.js +206 -0
- package/package.json +3 -2
|
@@ -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
|
+
};
|