@chaoswise/intl 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,217 @@
1
+ /**
2
+ * fixZhCnInFile.js
3
+ *
4
+ * Fixes Chinese characters found in COMMENT nodes by patching the raw source
5
+ * using byte-offset positions from AST tokens.
6
+ *
7
+ * Strategy options:
8
+ * 'clean' - Remove only Chinese characters from comment value.
9
+ * If the cleaned value is whitespace-only, remove the entire
10
+ * comment token (including delimiters // or /* ... *\/).
11
+ * 'remove' - Remove the entire comment token unconditionally.
12
+ *
13
+ * Important: This function operates on raw source bytes:
14
+ * - It sorts findings by `start` in descending order so that later
15
+ * replacements do not invalidate earlier offsets.
16
+ * - Only `comment` type findings (which carry `start` and `end` byte
17
+ * offsets) are accepted.
18
+ *
19
+ * Returns the number of comment tokens patched (0 if nothing was modified).
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const log = require('./log');
24
+
25
+ // Chinese character range, same as primaryRegx default
26
+ const ZH_REGEX = /[\u4e00-\u9fa5]/g;
27
+
28
+ /**
29
+ * Compute the replacement string for a single comment token.
30
+ *
31
+ * In Babel AST (with tokens:true):
32
+ * CommentLine : value = text after '//' (no newline)
33
+ * CommentBlock : value = text between '/*' and '*\/'
34
+ *
35
+ * source.slice(start, end) gives the full token including delimiters.
36
+ *
37
+ * @param {{ type: string, value: string, start: number, end: number }} comment
38
+ * @param {'clean'|'remove'} strategy
39
+ * @returns {string|null} null means "delete the token entirely"
40
+ */
41
+ function computeReplacement(comment, strategy) {
42
+ const { type, value } = comment;
43
+
44
+ if (strategy === 'remove') {
45
+ return null;
46
+ }
47
+
48
+ // strategy === 'clean': strip Chinese characters from value
49
+ const cleanedValue = value.replace(ZH_REGEX, '');
50
+
51
+ if (!cleanedValue.trim()) {
52
+ // Comment content is now empty → remove the token
53
+ return null;
54
+ }
55
+
56
+ if (type === 'CommentLine') {
57
+ return `//${cleanedValue}`;
58
+ }
59
+ // CommentBlock
60
+ return `/*${cleanedValue}*/`;
61
+ }
62
+
63
+ /**
64
+ * After removing a comment token, if the rest of the line is blank
65
+ * (only whitespace between the last newline and the token start),
66
+ * also remove the trailing newline so we don't leave empty lines.
67
+ *
68
+ * Special case – JSX expression-container comments { /‌* ... *‌/ } (where
69
+ * the comment is the sole content of a JSX expression): the surrounding
70
+ * { } braces must also be removed, otherwise an orphan {} expression is left.
71
+ * We detect this by checking whether the characters directly flanking the
72
+ * comment token (past optional spaces) are { and }, and if so extend the
73
+ * deletion range to cover them as well.
74
+ *
75
+ * @param {string} source
76
+ * @param {number} tokenStart - char index of the comment token start
77
+ * @param {number} tokenEnd - char index just past the comment token end
78
+ * @returns {{ patchStart: number, patchEnd: number }}
79
+ */
80
+ function calcDeletionRange(source, tokenStart, tokenEnd) {
81
+ // ── Determine the effective deletion boundaries ───────────────────────────
82
+ // For JSX expression-container comments `{ /* ... */ }`, we want to delete
83
+ // the wrapping braces as well, otherwise we leave an orphan `{}` expression.
84
+ let effectiveStart = tokenStart;
85
+ let effectiveEnd = tokenEnd;
86
+
87
+ // Walk backwards past optional spaces to find a potential `{`
88
+ let braceOpen = tokenStart - 1;
89
+ while (braceOpen >= 0 && source[braceOpen] === ' ') braceOpen--;
90
+
91
+ // Walk forwards past optional spaces to find a potential `}`
92
+ let braceClose = tokenEnd;
93
+ while (braceClose < source.length && source[braceClose] === ' ') braceClose++;
94
+
95
+ if (
96
+ braceOpen >= 0 && source[braceOpen] === '{' &&
97
+ braceClose < source.length && source[braceClose] === '}'
98
+ ) {
99
+ // The comment is the sole content of a JSX `{...}` expression — include the braces.
100
+ effectiveStart = braceOpen;
101
+ effectiveEnd = braceClose + 1;
102
+ }
103
+
104
+ // ── Guard: if tokenEnd somehow overshot a newline, clamp back ────────────
105
+ // Babel's comment.end should never include the trailing \n of a CommentLine,
106
+ // but be defensive: if the character just before effectiveEnd is a newline,
107
+ // we are already at the start of the next line — step back to the \n itself
108
+ // so the lineEnd walk below stays on the correct line.
109
+ let adjustedEnd = effectiveEnd;
110
+ if (adjustedEnd > effectiveStart && adjustedEnd > 0 &&
111
+ source[adjustedEnd - 1] === '\n' &&
112
+ (adjustedEnd >= source.length || source[adjustedEnd] !== '\n')) {
113
+ adjustedEnd--;
114
+ }
115
+
116
+ // ── Decide whether to remove the whole line or just the token ────────────
117
+ let lineStart = effectiveStart;
118
+ while (lineStart > 0 && source[lineStart - 1] !== '\n') {
119
+ lineStart--;
120
+ }
121
+ const before = source.slice(lineStart, effectiveStart);
122
+ const onlyWhitespaceBefore = /^\s*$/.test(before);
123
+
124
+ let lineEnd = adjustedEnd;
125
+ while (lineEnd < source.length && source[lineEnd] !== '\n') {
126
+ lineEnd++;
127
+ }
128
+ const after = source.slice(adjustedEnd, lineEnd);
129
+ const onlyWhitespaceAfter = /^\s*$/.test(after);
130
+
131
+ if (onlyWhitespaceBefore && onlyWhitespaceAfter) {
132
+ // Remove the entire line including the trailing newline (if present)
133
+ const end = lineEnd < source.length ? lineEnd + 1 : lineEnd;
134
+ return { patchStart: lineStart, patchEnd: end };
135
+ }
136
+
137
+ // Comment is inline on a code line — just remove the effective range
138
+ return { patchStart: effectiveStart, patchEnd: effectiveEnd };
139
+ }
140
+
141
+ /**
142
+ * Fix all comment findings in a single file.
143
+ *
144
+ * @param {string} filePath - absolute file path
145
+ * @param {Finding[]} findings - array of { type:'comment', start, end, commentType, ... }
146
+ * @param {'clean'|'remove'} strategy
147
+ * @returns {number} count of comment findings actually patched (0 if none)
148
+ */
149
+ module.exports = function fixZhCnInFile(filePath, findings, strategy = 'clean') {
150
+ // Filter to comment-type findings that have byte offsets
151
+ const commentFindings = findings.filter(
152
+ (f) => f.type === 'comment' && typeof f.start === 'number' && typeof f.end === 'number'
153
+ );
154
+
155
+ if (!commentFindings.length) return 0;
156
+
157
+ let source;
158
+ try {
159
+ source = fs.readFileSync(filePath, 'utf8');
160
+ } catch (err) {
161
+ log.error(`[nozhcn] Cannot read file for fixing: ${filePath} — ${err.message}`);
162
+ return 0;
163
+ }
164
+
165
+ // Sort descending by start so we patch from the end backwards,
166
+ // preserving the validity of earlier byte offsets.
167
+ const sorted = [...commentFindings].sort((a, b) => b.start - a.start);
168
+
169
+ let patchedCount = 0;
170
+
171
+ for (const finding of sorted) {
172
+ const { start, end, commentType } = finding;
173
+
174
+ // Reconstruct the original comment object expected by computeReplacement
175
+ const originalSource = source.slice(start, end);
176
+
177
+ // Extract the raw value (without delimiters) from the source token
178
+ // so that computeReplacement works even if the finding.content is
179
+ // already formatted with delimiters.
180
+ let rawValue;
181
+ if (commentType === 'CommentLine') {
182
+ // source token = '//' + value
183
+ rawValue = originalSource.startsWith('//') ? originalSource.slice(2) : originalSource;
184
+ } else {
185
+ // source token = '/*' + value + '*/'
186
+ if (originalSource.startsWith('/*') && originalSource.endsWith('*/')) {
187
+ rawValue = originalSource.slice(2, -2);
188
+ } else {
189
+ rawValue = originalSource;
190
+ }
191
+ }
192
+
193
+ const pseudoComment = { type: commentType, value: rawValue, start, end };
194
+ const replacement = computeReplacement(pseudoComment, strategy);
195
+
196
+ if (replacement === null) {
197
+ // Delete the token (and possibly the whole line if it becomes empty)
198
+ const { patchStart, patchEnd } = calcDeletionRange(source, start, end);
199
+ source = source.slice(0, patchStart) + source.slice(patchEnd);
200
+ } else {
201
+ source = source.slice(0, start) + replacement + source.slice(end);
202
+ }
203
+
204
+ patchedCount++;
205
+ }
206
+
207
+ if (patchedCount > 0) {
208
+ try {
209
+ fs.writeFileSync(filePath, source, { encoding: 'utf-8' });
210
+ } catch (err) {
211
+ log.error(`[nozhcn] Cannot write file: ${filePath} — ${err.message}`);
212
+ return 0;
213
+ }
214
+ }
215
+
216
+ return patchedCount;
217
+ };
@@ -0,0 +1,206 @@
1
+ /**
2
+ * fixZhCnInSvgFile.js
3
+ *
4
+ * Auto-fixes Chinese characters in standalone SVG files.
5
+ *
6
+ * Fixable types (from findZhCnInSvgFile.js):
7
+ * svg-comment ─ auto-fixable via comment clean/remove strategy
8
+ * svg-metadata ─ auto-fixable: remove element or empty its content
9
+ *
10
+ * Conditionally fixable types:
11
+ * svg-attr ─ attribute values (e.g. id="矩形", id="编组-11")
12
+ * typically design-tool layer-name artifacts; fixable
13
+ * when attrStrategy is set to 'clean' or 'remove'
14
+ *
15
+ * Non-fixable types (reported but not touched):
16
+ * svg-text ─ visible rendered text, requires design/i18n decision
17
+ *
18
+ * Strategies
19
+ * ──────────────────────────────────────────────────────────────────────
20
+ * commentStrategy ('clean' | 'remove')
21
+ * 'clean' : Remove only Chinese characters from the comment value.
22
+ * If the cleaned value is whitespace-only, delete the comment.
23
+ * 'remove' : Delete the entire comment unconditionally.
24
+ *
25
+ * metadataStrategy ('remove' | 'empty')
26
+ * 'remove' : Delete the entire <title>/<desc>/<metadata> element.
27
+ * 'empty' : Keep the element but empty its contents:
28
+ * <title>搜索</title> → <title></title>
29
+ *
30
+ * attrStrategy ('clean' | 'remove' | false)
31
+ * 'clean' : Strip Chinese (and CJK punctuation) from attribute values.
32
+ * If the cleaned value is empty, remove the attribute entirely.
33
+ * Renamed id attributes update internal url(#…) / href refs.
34
+ * 'remove' : Remove the entire attribute.
35
+ * false : Do not auto-fix attributes (default, backward-compat).
36
+ *
37
+ * Returns { fixed: number } (count of SVG elements/comments fixed)
38
+ */
39
+
40
+ const fs = require('fs');
41
+ const log = require('./log');
42
+
43
+ const ZH_REGEX = /[\u4e00-\u9fa5]/g;
44
+ const ZH_TEST = /[\u4e00-\u9fa5]/;
45
+ // Chinese chars + CJK symbols/punctuation + fullwidth forms (design-tool junk)
46
+ const ZH_CLEAN_REGEX = /[\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef]/g;
47
+
48
+ function escapeRegExp(str) {
49
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
50
+ }
51
+
52
+ /**
53
+ * Apply attribute fix strategy to the full source string.
54
+ * Handles id renames with internal SVG reference updates.
55
+ */
56
+ function fixAttrs(source, strategy) {
57
+ const idRenames = new Map(); // oldValue → newValue | null
58
+
59
+ // Pass 1: Process ONLY id attributes with Chinese values
60
+ source = source.replace(
61
+ /(\s)(id)=(["'])([^"']*[\u4e00-\u9fa5][^"']*)\3/g,
62
+ (match, ws, attr, quote, value) => {
63
+ if (strategy === 'remove') {
64
+ idRenames.set(value, null);
65
+ return '';
66
+ }
67
+ // 'clean': strip Chinese + CJK punctuation
68
+ const cleaned = value.replace(ZH_CLEAN_REGEX, '').trim();
69
+ if (!cleaned) {
70
+ idRenames.set(value, null);
71
+ return '';
72
+ }
73
+ idRenames.set(value, cleaned);
74
+ return `${ws}id=${quote}${cleaned}${quote}`;
75
+ }
76
+ );
77
+
78
+ // Pass 2: Update internal references for renamed / removed ids
79
+ idRenames.forEach((newId, oldId) => {
80
+ const esc = escapeRegExp(oldId);
81
+ if (newId) {
82
+ source = source.replace(new RegExp(`url\\(#${esc}\\)`, 'g'), `url(#${newId})`);
83
+ source = source.replace(
84
+ new RegExp(`(xlink:href|href)=(["'])#${esc}\\2`, 'g'),
85
+ `$1=$2#${newId}$2`
86
+ );
87
+ } else {
88
+ // id was removed — neutralise dangling url() references
89
+ source = source.replace(new RegExp(`url\\(#${esc}\\)`, 'g'), 'none');
90
+ }
91
+ });
92
+
93
+ // Pass 3: Process remaining (non-id) attributes with Chinese values
94
+ source = source.replace(
95
+ /(\s)([\w:_-]+)=(["'])([^"']*[\u4e00-\u9fa5][^"']*)\3/g,
96
+ (match, ws, attr, quote, value) => {
97
+ if (attr === 'id') return match; // already handled
98
+ if (strategy === 'remove') return '';
99
+ const cleaned = value.replace(ZH_CLEAN_REGEX, '').trim();
100
+ if (!cleaned) return '';
101
+ return `${ws}${attr}=${quote}${cleaned}${quote}`;
102
+ }
103
+ );
104
+
105
+ return source;
106
+ }
107
+
108
+ /**
109
+ * Apply comment fix strategy to the full source string.
110
+ */
111
+ function fixComments(source, strategy) {
112
+ return source.replace(/<!--([\s\S]*?)-->/g, (match, content) => {
113
+ if (!ZH_TEST.test(content)) return match;
114
+ if (strategy === 'remove') return '';
115
+ const cleaned = content.replace(ZH_REGEX, '');
116
+ if (!cleaned.trim()) return '';
117
+ return `<!--${cleaned}-->`;
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Apply metadata element fix strategy to the full source string.
123
+ * Handles <title>, <desc>, <metadata> (case-insensitive, with attributes).
124
+ */
125
+ function fixMetadata(source, strategy) {
126
+ return source.replace(/<(title|desc|metadata)(\s[^>]*)?>[\s\S]*?<\/\1>/gi, (match, tag, attrs) => {
127
+ if (!ZH_TEST.test(match)) return match;
128
+ if (strategy === 'remove') return '';
129
+ // 'empty': keep the opening/closing tags, remove content
130
+ const openTag = attrs ? `<${tag}${attrs}>` : `<${tag}>`;
131
+ return `${openTag}</${tag}>`;
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Fix Chinese in SVG comments and metadata elements.
137
+ *
138
+ * @param {string} filePath - absolute path to the .svg file
139
+ * @param {Finding[]} findings - from findZhCnInSvgFile (all types)
140
+ * @param {object} opts
141
+ * - commentStrategy 'clean' | 'remove' (default: 'clean')
142
+ * - metadataStrategy 'remove' | 'empty' (default: 'remove')
143
+ * - attrStrategy 'clean' | 'remove' | false (default: false)
144
+ * @returns {{ fixed: number }}
145
+ */
146
+ module.exports = function fixZhCnInSvgFile(filePath, findings, opts) {
147
+ const {
148
+ commentStrategy = 'clean',
149
+ metadataStrategy = 'remove',
150
+ attrStrategy = false,
151
+ } = opts || {};
152
+
153
+ const hasFixableFindings = findings.some(
154
+ (f) => f.type === 'svg-comment' || f.type === 'svg-metadata' ||
155
+ (f.type === 'svg-attr' && attrStrategy)
156
+ );
157
+ if (!hasFixableFindings) return { fixed: 0 };
158
+
159
+ let source;
160
+ try {
161
+ source = fs.readFileSync(filePath, 'utf8');
162
+ } catch (err) {
163
+ log.error(`[nozhcn] Cannot read SVG file: ${filePath} — ${err.message}`);
164
+ return { fixed: 0 };
165
+ }
166
+
167
+ const before = source;
168
+
169
+ // Fix comments
170
+ const hasCommentFindings = findings.some((f) => f.type === 'svg-comment');
171
+ if (hasCommentFindings) {
172
+ source = fixComments(source, commentStrategy);
173
+ }
174
+
175
+ // Fix metadata elements
176
+ const hasMetadataFindings = findings.some((f) => f.type === 'svg-metadata');
177
+ if (hasMetadataFindings) {
178
+ source = fixMetadata(source, metadataStrategy);
179
+ }
180
+
181
+ // Fix attributes (opt-in)
182
+ const hasAttrFindings = findings.some((f) => f.type === 'svg-attr');
183
+ if (hasAttrFindings && attrStrategy) {
184
+ source = fixAttrs(source, attrStrategy);
185
+ }
186
+
187
+ if (source === before) return { fixed: 0 };
188
+
189
+ // Clean up blank lines left behind by removed elements/comments
190
+ source = source.replace(/\n[ \t]*\n/g, '\n');
191
+
192
+ // Count how many fixable findings were addressed
193
+ const fixed = findings.filter(
194
+ (f) => f.type === 'svg-comment' || f.type === 'svg-metadata' ||
195
+ (f.type === 'svg-attr' && attrStrategy)
196
+ ).length;
197
+
198
+ try {
199
+ fs.writeFileSync(filePath, source, { encoding: 'utf-8' });
200
+ } catch (err) {
201
+ log.error(`[nozhcn] Cannot write SVG file: ${filePath} — ${err.message}`);
202
+ return { fixed: 0 };
203
+ }
204
+
205
+ return { fixed };
206
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chaoswise/intl",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "author": "cloudwiser",
5
5
  "description": "intl",
6
6
  "main": "lib/index.js",
@@ -24,7 +24,8 @@
24
24
  },
25
25
  "scripts": {
26
26
  "init": "npm i",
27
- "build": "gulp build --gulpfile ./scripts/gulpfile.js"
27
+ "build": "gulp build --gulpfile ./scripts/gulpfile.js",
28
+ "test:nozhcn": "node --test --test-reporter=spec '__tests__/nozhcn/*.test.js'"
28
29
  },
29
30
  "keywords": [
30
31
  "intl"