i18ntk 3.3.0 → 4.0.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/CHANGELOG.md +29 -2
- package/README.md +157 -15
- package/SECURITY.md +14 -8
- package/main/i18ntk-backup.js +305 -62
- package/main/i18ntk-scanner.js +188 -49
- package/main/i18ntk-sizing.js +223 -29
- package/main/i18ntk-usage.js +203 -3
- package/main/i18ntk-validate.js +107 -3
- package/main/manage/commands/FixerCommand.js +23 -21
- package/main/manage/index.js +13 -7
- package/main/manage/services/FileManagementService.js +12 -6
- package/package.json +2 -2
- package/runtime/i18ntk.d.ts +22 -16
- package/runtime/index.d.ts +9 -7
- package/runtime/index.js +240 -50
- package/ui-locales/en.json +1 -1
- package/utils/translate/protection.js +147 -6
- package/utils/watch-locales.js +183 -36
|
@@ -3,6 +3,8 @@ const SecurityUtils = require('../security');
|
|
|
3
3
|
|
|
4
4
|
const DEFAULT_PROTECTION_FILE = 'i18ntk-auto-translate.json';
|
|
5
5
|
const TOKEN_PREFIX = '__I18NTK_KEEP_';
|
|
6
|
+
const MAX_CONTEXT_RULES = 100;
|
|
7
|
+
const MAX_CONTEXT_INPUT_LENGTH = 200;
|
|
6
8
|
|
|
7
9
|
function defaultProtectionConfig() {
|
|
8
10
|
return {
|
|
@@ -49,12 +51,85 @@ function compilePatterns(patterns) {
|
|
|
49
51
|
return compiled;
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
function parseContextString(context) {
|
|
55
|
+
if (typeof context !== 'string') return null;
|
|
56
|
+
if (context.length > MAX_CONTEXT_INPUT_LENGTH) return null;
|
|
57
|
+
|
|
58
|
+
const trimmed = context.trim();
|
|
59
|
+
|
|
60
|
+
if (trimmed === 'standalone') {
|
|
61
|
+
return { mode: 'standalone' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const afterMatch = trimmed.match(/^after:(.+)$/i);
|
|
65
|
+
if (afterMatch) {
|
|
66
|
+
const words = afterMatch[1].split('|').map(w => w.trim()).filter(Boolean);
|
|
67
|
+
if (words.length === 0) return null;
|
|
68
|
+
return { mode: 'after', words };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const beforeMatch = trimmed.match(/^before:(.+)$/i);
|
|
72
|
+
if (beforeMatch) {
|
|
73
|
+
const words = beforeMatch[1].split('|').map(w => w.trim()).filter(Boolean);
|
|
74
|
+
if (words.length === 0) return null;
|
|
75
|
+
return { mode: 'before', words };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const surroundedMatch = trimmed.match(/^surrounded:(.+),(.+)$/i);
|
|
79
|
+
if (surroundedMatch) {
|
|
80
|
+
const leftWords = surroundedMatch[1].split('|').map(w => w.trim()).filter(Boolean);
|
|
81
|
+
const rightWords = surroundedMatch[2].split('|').map(w => w.trim()).filter(Boolean);
|
|
82
|
+
if (leftWords.length === 0 || rightWords.length === 0) return null;
|
|
83
|
+
return { mode: 'surrounded', left: leftWords, right: rightWords };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseContextRule(rule) {
|
|
90
|
+
if (typeof rule === 'string') {
|
|
91
|
+
const trimmed = rule.trim();
|
|
92
|
+
if (!trimmed || trimmed.length > MAX_CONTEXT_INPUT_LENGTH) return null;
|
|
93
|
+
return { value: trimmed, type: 'global' };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (
|
|
97
|
+
typeof rule === 'object' &&
|
|
98
|
+
rule !== null &&
|
|
99
|
+
typeof rule.value === 'string' &&
|
|
100
|
+
typeof rule.context === 'string'
|
|
101
|
+
) {
|
|
102
|
+
const value = rule.value.trim();
|
|
103
|
+
const rawContext = rule.context;
|
|
104
|
+
|
|
105
|
+
if (!value || value.length > MAX_CONTEXT_INPUT_LENGTH) return null;
|
|
106
|
+
if (!rawContext || rawContext.length > MAX_CONTEXT_INPUT_LENGTH) return null;
|
|
107
|
+
|
|
108
|
+
const parsed = parseContextString(rawContext);
|
|
109
|
+
if (!parsed) return null;
|
|
110
|
+
|
|
111
|
+
return { value, type: 'context', context: parsed };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
52
117
|
function normalizeProtectionConfig(config = {}, filePath = null) {
|
|
53
118
|
const terms = normalizeList(config.terms);
|
|
54
119
|
const keys = normalizeList(config.keys);
|
|
55
120
|
const values = normalizeList(config.values);
|
|
56
121
|
const patterns = normalizeList(config.patterns);
|
|
57
122
|
|
|
123
|
+
const rawTerms = Array.isArray(config.terms) ? config.terms : [];
|
|
124
|
+
const normalizedTerms = [];
|
|
125
|
+
for (const term of rawTerms) {
|
|
126
|
+
const normalized = parseContextRule(term);
|
|
127
|
+
if (normalized) {
|
|
128
|
+
normalizedTerms.push(normalized);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const cappedTerms = normalizedTerms.slice(0, MAX_CONTEXT_RULES);
|
|
132
|
+
|
|
58
133
|
return {
|
|
59
134
|
enabled: true,
|
|
60
135
|
filePath,
|
|
@@ -62,7 +137,8 @@ function normalizeProtectionConfig(config = {}, filePath = null) {
|
|
|
62
137
|
keys,
|
|
63
138
|
values,
|
|
64
139
|
patterns,
|
|
65
|
-
compiledPatterns: compilePatterns(patterns)
|
|
140
|
+
compiledPatterns: compilePatterns(patterns),
|
|
141
|
+
normalizedTerms: cappedTerms
|
|
66
142
|
};
|
|
67
143
|
}
|
|
68
144
|
|
|
@@ -98,10 +174,20 @@ function saveProtectionFile(filePath, config, options = {}) {
|
|
|
98
174
|
if (!SecurityUtils.safeExistsSync(dir, path.dirname(dir))) {
|
|
99
175
|
SecurityUtils.safeMkdirSync(dir, path.dirname(dir), { recursive: true });
|
|
100
176
|
}
|
|
177
|
+
|
|
178
|
+
const rawTerms = Array.isArray(config?.terms) ? config.terms : [];
|
|
179
|
+
const sanitizedTerms = [];
|
|
180
|
+
for (const term of rawTerms) {
|
|
181
|
+
const normalized = parseContextRule(term);
|
|
182
|
+
if (normalized) {
|
|
183
|
+
sanitizedTerms.push(term);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
101
187
|
const nextConfig = {
|
|
102
188
|
...defaultProtectionConfig(),
|
|
103
189
|
...(config || {}),
|
|
104
|
-
terms:
|
|
190
|
+
terms: sanitizedTerms.slice(0, MAX_CONTEXT_RULES),
|
|
105
191
|
keys: normalizeList(config?.keys),
|
|
106
192
|
values: normalizeList(config?.values),
|
|
107
193
|
patterns: normalizeList(config?.patterns)
|
|
@@ -156,7 +242,8 @@ function shouldPreserveWholeValue(keyPath, value, protection) {
|
|
|
156
242
|
if (!protection || protection.enabled === false) return false;
|
|
157
243
|
if (protection.keys.some(rule => keyMatchesRule(keyPath, rule))) return true;
|
|
158
244
|
const valueText = String(value);
|
|
159
|
-
return protection.values.includes(valueText) ||
|
|
245
|
+
return protection.values.includes(valueText) ||
|
|
246
|
+
(protection.normalizedTerms || []).some(rule => rule.value === valueText);
|
|
160
247
|
}
|
|
161
248
|
|
|
162
249
|
function addReplacement(replacements, original) {
|
|
@@ -165,13 +252,65 @@ function addReplacement(replacements, original) {
|
|
|
165
252
|
replacements.push({ original });
|
|
166
253
|
}
|
|
167
254
|
|
|
255
|
+
function shouldProtectInContext(value, rule, index, fullText) {
|
|
256
|
+
if (rule.type === 'global') return true;
|
|
257
|
+
|
|
258
|
+
const { context } = rule;
|
|
259
|
+
const before = fullText.substring(0, index);
|
|
260
|
+
const after = fullText.substring(index + value.length);
|
|
261
|
+
|
|
262
|
+
if (context.mode === 'after') {
|
|
263
|
+
const alternation = context.words.map(escapeRegExp).join('|');
|
|
264
|
+
const regex = new RegExp(`\\b(${alternation})\\s+$`, 'i');
|
|
265
|
+
return regex.test(before);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (context.mode === 'before') {
|
|
269
|
+
const alternation = context.words.map(escapeRegExp).join('|');
|
|
270
|
+
const regex = new RegExp(`^\\s+(${alternation})\\b`, 'i');
|
|
271
|
+
return regex.test(after);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (context.mode === 'standalone') {
|
|
275
|
+
const isBoundaryBefore = before === '' || /\s$/.test(before);
|
|
276
|
+
const isBoundaryAfter = after === '' || /^[\s.,!?;:]/.test(after);
|
|
277
|
+
return isBoundaryBefore && isBoundaryAfter;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (context.mode === 'surrounded') {
|
|
281
|
+
const leftAlternation = context.left.map(escapeRegExp).join('|');
|
|
282
|
+
const rightAlternation = context.right.map(escapeRegExp).join('|');
|
|
283
|
+
const leftRegex = new RegExp(`\\b(${leftAlternation})\\s+$`, 'i');
|
|
284
|
+
const rightRegex = new RegExp(`^\\s+(${rightAlternation})\\b`, 'i');
|
|
285
|
+
return leftRegex.test(before) && rightRegex.test(after);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
|
|
168
291
|
function collectReplacements(value, protection) {
|
|
169
292
|
const text = String(value);
|
|
170
293
|
const replacements = [];
|
|
171
294
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
295
|
+
const rules = protection.normalizedTerms || (protection.terms || [])
|
|
296
|
+
.map(parseContextRule)
|
|
297
|
+
.filter(Boolean);
|
|
298
|
+
for (const rule of rules) {
|
|
299
|
+
if (rule.type === 'global') {
|
|
300
|
+
if (text.includes(rule.value)) {
|
|
301
|
+
addReplacement(replacements, rule.value);
|
|
302
|
+
}
|
|
303
|
+
} else {
|
|
304
|
+
if (!rule.value) continue;
|
|
305
|
+
const escaped = escapeRegExp(rule.value);
|
|
306
|
+
const regex = new RegExp(escaped, 'gi');
|
|
307
|
+
let match;
|
|
308
|
+
while ((match = regex.exec(text)) !== null) {
|
|
309
|
+
if (shouldProtectInContext(rule.value, rule, match.index, text)) {
|
|
310
|
+
addReplacement(replacements, match[0]);
|
|
311
|
+
}
|
|
312
|
+
if (match[0] === '') regex.lastIndex++;
|
|
313
|
+
}
|
|
175
314
|
}
|
|
176
315
|
}
|
|
177
316
|
|
|
@@ -221,6 +360,7 @@ function hasProtectionRules(protection) {
|
|
|
221
360
|
return Boolean(
|
|
222
361
|
protection &&
|
|
223
362
|
(
|
|
363
|
+
(protection.normalizedTerms && protection.normalizedTerms.length) ||
|
|
224
364
|
protection.terms.length ||
|
|
225
365
|
protection.keys.length ||
|
|
226
366
|
protection.values.length ||
|
|
@@ -235,6 +375,7 @@ module.exports = {
|
|
|
235
375
|
defaultProtectionConfig,
|
|
236
376
|
hasProtectionRules,
|
|
237
377
|
loadProtectionConfig,
|
|
378
|
+
parseContextRule,
|
|
238
379
|
protectText,
|
|
239
380
|
readProtectionFile,
|
|
240
381
|
restoreText,
|
package/utils/watch-locales.js
CHANGED
|
@@ -1,36 +1,183 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const EventEmitter = require('events');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const SecurityUtils = require('./security');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_DEBOUNCE_MS = 300;
|
|
8
|
+
const DEFAULT_MAX_DIRECTORIES = 50;
|
|
9
|
+
|
|
10
|
+
function sha256File(filePath) {
|
|
11
|
+
try {
|
|
12
|
+
const content = SecurityUtils.safeReadFileSync(filePath, path.dirname(filePath));
|
|
13
|
+
if (content === null || content === undefined) return null;
|
|
14
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
15
|
+
} catch (_) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function watchDirectory(dir, emitter, watchers, options = {}) {
|
|
21
|
+
const {
|
|
22
|
+
debounceMs = DEFAULT_DEBOUNCE_MS,
|
|
23
|
+
hashTracking = true,
|
|
24
|
+
watchState = { count: 0, maxDirectories: DEFAULT_MAX_DIRECTORIES }
|
|
25
|
+
} = options;
|
|
26
|
+
|
|
27
|
+
if (!SecurityUtils.safeExistsSync(dir, path.dirname(dir))) return;
|
|
28
|
+
if (watchState.count >= watchState.maxDirectories) {
|
|
29
|
+
emitter.emit('error', new Error(`Maximum watched directories (${watchState.maxDirectories}) exceeded`));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const fileHashes = new Map();
|
|
34
|
+
const debounceTimers = new Map();
|
|
35
|
+
|
|
36
|
+
if (hashTracking) {
|
|
37
|
+
try {
|
|
38
|
+
const items = SecurityUtils.safeReaddirSync(dir, path.dirname(dir), { withFileTypes: true });
|
|
39
|
+
if (items) {
|
|
40
|
+
for (const entry of items) {
|
|
41
|
+
if (entry.isFile() && entry.name.endsWith('.json')) {
|
|
42
|
+
const fullPath = path.join(dir, entry.name);
|
|
43
|
+
const h = sha256File(fullPath);
|
|
44
|
+
if (h) fileHashes.set(fullPath, h);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} catch (_) { /* initial read may fail */ }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let watcher;
|
|
52
|
+
try {
|
|
53
|
+
watcher = fs.watch(dir, (event, filename) => {
|
|
54
|
+
if (!filename || !filename.endsWith('.json')) return;
|
|
55
|
+
|
|
56
|
+
const fullPath = path.join(dir, filename);
|
|
57
|
+
const validated = SecurityUtils.validatePath(fullPath, path.dirname(dir));
|
|
58
|
+
if (!validated) return;
|
|
59
|
+
|
|
60
|
+
if (debounceTimers.has(fullPath)) {
|
|
61
|
+
clearTimeout(debounceTimers.get(fullPath));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
debounceTimers.set(fullPath, setTimeout(() => {
|
|
65
|
+
debounceTimers.delete(fullPath);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
if (event === 'rename') {
|
|
69
|
+
if (SecurityUtils.safeExistsSync(fullPath, path.dirname(fullPath))) {
|
|
70
|
+
if (hashTracking) {
|
|
71
|
+
const h = sha256File(fullPath);
|
|
72
|
+
if (h) fileHashes.set(fullPath, h);
|
|
73
|
+
}
|
|
74
|
+
emitter.emit('add', fullPath);
|
|
75
|
+
} else {
|
|
76
|
+
fileHashes.delete(fullPath);
|
|
77
|
+
emitter.emit('unlink', fullPath);
|
|
78
|
+
}
|
|
79
|
+
} else if (event === 'change') {
|
|
80
|
+
if (hashTracking) {
|
|
81
|
+
const newHash = sha256File(fullPath);
|
|
82
|
+
const oldHash = fileHashes.get(fullPath);
|
|
83
|
+
if (newHash && newHash === oldHash) return;
|
|
84
|
+
if (newHash) fileHashes.set(fullPath, newHash);
|
|
85
|
+
}
|
|
86
|
+
emitter.emit('change', fullPath);
|
|
87
|
+
}
|
|
88
|
+
} catch (err) {
|
|
89
|
+
emitter.emit('error', err);
|
|
90
|
+
}
|
|
91
|
+
}, debounceMs));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
watcher.on('error', (err) => {
|
|
95
|
+
emitter.emit('error', err);
|
|
96
|
+
});
|
|
97
|
+
} catch (err) {
|
|
98
|
+
emitter.emit('error', err);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
watchers.push({ watcher, path: dir });
|
|
103
|
+
watchState.count++;
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const items = SecurityUtils.safeReaddirSync(dir, path.dirname(dir), { withFileTypes: true });
|
|
107
|
+
if (items) {
|
|
108
|
+
for (const entry of items) {
|
|
109
|
+
if (entry.isDirectory()) {
|
|
110
|
+
watchDirectory(path.join(dir, entry.name), emitter, watchers, options);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch (_) {
|
|
115
|
+
// Cannot read directory contents
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function watchLocales(dirs, onChange, options = {}) {
|
|
120
|
+
const directories = Array.isArray(dirs) ? dirs : [dirs];
|
|
121
|
+
const emitter = new EventEmitter();
|
|
122
|
+
emitter.on('error', () => {});
|
|
123
|
+
const watchers = [];
|
|
124
|
+
|
|
125
|
+
// Backward-compatible onChange callback
|
|
126
|
+
if (typeof onChange === 'function') {
|
|
127
|
+
emitter.on('change', onChange);
|
|
128
|
+
emitter.on('add', onChange);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const {
|
|
132
|
+
debounceMs = DEFAULT_DEBOUNCE_MS,
|
|
133
|
+
hashTracking = true,
|
|
134
|
+
maxDirectories = DEFAULT_MAX_DIRECTORIES
|
|
135
|
+
} = (typeof onChange === 'object' && onChange !== null) ? onChange : options;
|
|
136
|
+
|
|
137
|
+
const watchState = { count: 0, maxDirectories };
|
|
138
|
+
for (const d of directories) {
|
|
139
|
+
if (watchState.count >= watchState.maxDirectories) {
|
|
140
|
+
emitter.emit('error', new Error(`Maximum watched directories (${watchState.maxDirectories}) exceeded`));
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const resolved = path.resolve(d);
|
|
145
|
+
const validated = SecurityUtils.validatePath(resolved, process.cwd());
|
|
146
|
+
if (!validated) {
|
|
147
|
+
emitter.emit('error', new Error(`Path validation failed for: ${d}`));
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const projectRoot = path.resolve(process.cwd());
|
|
152
|
+
const rel = path.relative(projectRoot, validated);
|
|
153
|
+
if (rel.startsWith('..')) {
|
|
154
|
+
emitter.emit('error', new Error(`Directory outside project root: ${d}`));
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
watchDirectory(validated, emitter, watchers, { debounceMs, hashTracking, watchState });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const stop = () => {
|
|
162
|
+
for (const entry of watchers) {
|
|
163
|
+
try { entry.watcher.close(); } catch (_) { /* ignore */ }
|
|
164
|
+
}
|
|
165
|
+
watchers.length = 0;
|
|
166
|
+
emitter.removeAllListeners();
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
stop.stop = stop;
|
|
170
|
+
stop.emitter = emitter;
|
|
171
|
+
stop.on = emitter.on.bind(emitter);
|
|
172
|
+
stop.once = emitter.once.bind(emitter);
|
|
173
|
+
stop.off = emitter.off ? emitter.off.bind(emitter) : emitter.removeListener.bind(emitter);
|
|
174
|
+
stop.emit = emitter.emit.bind(emitter);
|
|
175
|
+
stop.removeListener = emitter.removeListener.bind(emitter);
|
|
176
|
+
stop.removeAllListeners = emitter.removeAllListeners.bind(emitter);
|
|
177
|
+
stop.getWatchedPaths = () => watchers.map(w => w.path);
|
|
178
|
+
stop.getDebounceMs = () => debounceMs;
|
|
179
|
+
|
|
180
|
+
return stop;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = watchLocales;
|