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.
@@ -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: normalizeList(config?.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) || protection.terms.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
- for (const term of protection.terms || []) {
173
- if (text.includes(term)) {
174
- addReplacement(replacements, term);
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,
@@ -1,36 +1,183 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const SecurityUtils = require('./security');
4
-
5
- function watchDirectory(dir, callback, watchers) {
6
- if (!SecurityUtils.safeExistsSync(dir, path.dirname(dir))) return;
7
- const watcher = fs.watch(dir, (event, filename) => {
8
- if (filename && filename.endsWith('.json')) {
9
- callback(path.join(dir, filename));
10
- }
11
- });
12
- watchers.push(watcher);
13
-
14
- try {
15
- const items = SecurityUtils.safeReaddirSync(dir, path.dirname(dir), { withFileTypes: true });
16
- if (items) {
17
- items.forEach(entry => {
18
- if (entry.isDirectory()) {
19
- watchDirectory(path.join(dir, entry.name), callback, watchers);
20
- }
21
- });
22
- }
23
- } catch (_) {
24
- // Cannot read directory contents
25
- }
26
- }
27
-
28
- function watchLocales(dirs, onChange) {
29
- const directories = Array.isArray(dirs) ? dirs : [dirs];
30
- const watchers = [];
31
- directories.forEach(d => watchDirectory(path.resolve(d), onChange, watchers));
32
- console.log(`Watching for changes in: ${directories.join(', ')}`);
33
- return () => watchers.forEach(w => w.close());
34
- }
35
-
36
- module.exports = watchLocales;
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;