i18ntk 3.3.0 → 4.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/runtime/index.js CHANGED
@@ -11,13 +11,32 @@ const { envManager } = require('../utils/env-manager');
11
11
  let configManager = null;
12
12
  try { configManager = require('../utils/config-manager'); } catch (_) { /* optional */ }
13
13
 
14
- const state = {
14
+ function createState(options = {}) {
15
+ return {
16
+ baseDir: resolveBaseDir(options.baseDir),
17
+ language: options.language || 'en',
18
+ fallbackLanguage: options.fallbackLanguage || 'en',
19
+ keySeparator: options.keySeparator || '.',
20
+ cache: new Map(),
21
+ lazy: options.lazy === true,
22
+ keyManifest: new Map(),
23
+ loadedFiles: new Set(),
24
+ eagerLoadedLanguages: new Set(),
25
+ };
26
+ }
27
+
28
+ const singletonState = {
15
29
  baseDir: null, // absolute path to locales dir (e.g., ./locales)
16
30
  language: 'en',
17
31
  fallbackLanguage: 'en',
18
- keySeparator: '.',
19
- cache: new Map(), // lang -> merged translations object
20
- };
32
+ keySeparator: '.',
33
+ cache: new Map(), // lang -> merged translations object
34
+ lazy: false,
35
+ keyManifest: new Map(), // lang -> Map: keyName -> filePath
36
+ loadedFiles: new Set(), // tracks loaded files in lazy mode
37
+ eagerLoadedLanguages: new Set(),
38
+ };
39
+ let singletonInitialized = false;
21
40
 
22
41
  // --- Utilities ---
23
42
  function stripBOMAndComments(s) {
@@ -98,12 +117,12 @@ function resolveBaseDir(explicitBaseDir) {
98
117
  }
99
118
  }
100
119
 
101
- function listJsonFilesRecursively(dir) {
120
+ function listJsonFilesRecursively(dir, baseDir = dir) {
102
121
  const results = [];
103
122
  const stack = [dir];
104
123
  while (stack.length) {
105
124
  const d = stack.pop();
106
- if (!SecurityUtils.safeExistsSync(d)) continue;
125
+ if (!SecurityUtils.safeExistsSync(d, baseDir)) continue;
107
126
  try {
108
127
  for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
109
128
  const full = path.join(d, entry.name);
@@ -120,6 +139,87 @@ function listJsonFilesRecursively(dir) {
120
139
  return results;
121
140
  }
122
141
 
142
+ function loadKeyManifestFromDir(baseDir) {
143
+ if (!baseDir || typeof baseDir !== 'string') return new Map();
144
+ const validatedBase = SecurityUtils.validatePath(baseDir, path.dirname(baseDir));
145
+ const baseStat = SecurityUtils.safeStatSync(validatedBase, path.dirname(validatedBase));
146
+ const baseRoot = baseStat && baseStat.isFile() ? path.dirname(validatedBase) : validatedBase;
147
+ const files = baseStat && baseStat.isFile() ? [validatedBase] : listJsonFilesRecursively(validatedBase, validatedBase);
148
+ const manifest = new Map();
149
+ const MAX_SIZE = 100 * 1024;
150
+ let currentSize = 0;
151
+
152
+ for (const file of files) {
153
+ let validated;
154
+ try { validated = SecurityUtils.validatePath(file, baseRoot); } catch (_) { continue; }
155
+ const rel = path.relative(baseRoot, validated);
156
+ if (rel.startsWith('..') || path.isAbsolute(rel)) continue;
157
+
158
+ try {
159
+ const data = readJsonSafe(validated);
160
+ if (!data || typeof data !== 'object') continue;
161
+
162
+ for (const key of Object.keys(data)) {
163
+ if (manifest.has(key)) continue;
164
+
165
+ const entrySize = JSON.stringify(key).length + JSON.stringify(validated).length + 5;
166
+ if (currentSize + entrySize > MAX_SIZE) break;
167
+
168
+ manifest.set(key, validated);
169
+ currentSize += entrySize;
170
+ }
171
+ } catch (_) { continue; }
172
+
173
+ if (currentSize >= MAX_SIZE) break;
174
+ }
175
+
176
+ return manifest;
177
+ }
178
+
179
+ function getLanguagePath(baseDir, lang) {
180
+ const langDir = path.join(baseDir, lang);
181
+ const langFile = path.join(baseDir, `${lang}.json`);
182
+ const langDirStat = SecurityUtils.safeStatSync(langDir, path.dirname(langDir));
183
+ if (langDirStat && langDirStat.isDirectory()) return langDir;
184
+ const langFileStat = SecurityUtils.safeStatSync(langFile, path.dirname(langFile));
185
+ if (langFileStat && langFileStat.isFile()) return langFile;
186
+ return null;
187
+ }
188
+
189
+ function getManifestForLanguage(runtimeState, lang) {
190
+ if (!runtimeState.keyManifest) runtimeState.keyManifest = new Map();
191
+ if (runtimeState.keyManifest.has(lang)) return runtimeState.keyManifest.get(lang);
192
+
193
+ const languagePath = getLanguagePath(runtimeState.baseDir, lang);
194
+ const manifest = languagePath ? loadKeyManifestFromDir(languagePath) : new Map();
195
+ runtimeState.keyManifest.set(lang, manifest);
196
+ return manifest;
197
+ }
198
+
199
+ function loadFileLazy(runtimeState, filePath, lang) {
200
+ const baseDir = runtimeState.baseDir;
201
+ if (!baseDir) throw new Error('baseDir not initialized');
202
+
203
+ let validatedPath;
204
+ try { validatedPath = SecurityUtils.validatePath(filePath, baseDir); } catch (e) {
205
+ throw new Error(`Invalid file path for lazy load: ${e.message}`);
206
+ }
207
+
208
+ const rel = path.relative(baseDir, validatedPath);
209
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
210
+ throw new Error(`File outside base directory: ${filePath}`);
211
+ }
212
+
213
+ const data = readJsonSafe(validatedPath);
214
+ if (data && typeof data === 'object') {
215
+ let cacheEntry = runtimeState.cache.get(lang);
216
+ if (!cacheEntry) cacheEntry = {};
217
+ deepMerge(cacheEntry, data);
218
+ runtimeState.cache.set(lang, cacheEntry);
219
+ }
220
+ return data;
221
+ }
222
+
123
223
  function readLanguageFromBase(baseDir, lang) {
124
224
  const merged = {};
125
225
  const langFile = path.join(baseDir, `${lang}.json`);
@@ -128,7 +228,7 @@ function readLanguageFromBase(baseDir, lang) {
128
228
  // Prefer folder if exists, otherwise single file
129
229
  const langDirStat = SecurityUtils.safeStatSync(langDir, path.dirname(langDir));
130
230
  if (langDirStat && langDirStat.isDirectory()) {
131
- const files = listJsonFilesRecursively(langDir);
231
+ const files = listJsonFilesRecursively(langDir, langDir);
132
232
  for (const file of files) {
133
233
  try {
134
234
  const data = readJsonSafe(file);
@@ -150,10 +250,19 @@ function readLanguageFromBase(baseDir, lang) {
150
250
  return merged;
151
251
  }
152
252
 
153
- function getTranslations(lang) {
154
- if (state.cache.has(lang)) return state.cache.get(lang);
155
- const data = readLanguageFromBase(state.baseDir, lang);
156
- state.cache.set(lang, data);
253
+ function getTranslationsForState(runtimeState, lang) {
254
+ if (!runtimeState.baseDir) runtimeState.baseDir = resolveBaseDir();
255
+
256
+ if (runtimeState.lazy) {
257
+ if (runtimeState.cache.has(lang)) return runtimeState.cache.get(lang);
258
+ runtimeState.cache.set(lang, {});
259
+ getManifestForLanguage(runtimeState, lang);
260
+ return runtimeState.cache.get(lang);
261
+ }
262
+
263
+ if (runtimeState.cache.has(lang)) return runtimeState.cache.get(lang);
264
+ const data = readLanguageFromBase(runtimeState.baseDir, lang);
265
+ runtimeState.cache.set(lang, data);
157
266
  return data;
158
267
  }
159
268
 
@@ -165,15 +274,42 @@ function interpolate(template, params) {
165
274
  }
166
275
 
167
276
  // Resolve a dotted key path from an object
168
- function resolveKey(obj, key, sep = '.') {
169
- if (!obj || typeof obj !== 'object') return undefined;
170
- if (!key || typeof key !== 'string') return undefined;
277
+ function resolveKey(obj, key, sep = '.', runtimeState = null, lang = null) {
278
+ if (!obj || typeof obj !== 'object') return undefined;
279
+ if (!key || typeof key !== 'string') return undefined;
171
280
  const parts = key.split(sep);
172
281
  let cur = obj;
173
282
  for (const p of parts) {
174
283
  if (cur && Object.prototype.hasOwnProperty.call(cur, p)) {
175
284
  cur = cur[p];
176
285
  } else {
286
+ if (runtimeState && lang && runtimeState.lazy) {
287
+ const manifest = getManifestForLanguage(runtimeState, lang);
288
+ for (let i = parts.length; i > 0; i--) {
289
+ const prefix = parts.slice(0, i).join(sep);
290
+ const filePath = manifest.get(prefix);
291
+ const loadedFileKey = `${lang}\0${filePath}`;
292
+ if (filePath && !runtimeState.loadedFiles.has(loadedFileKey)) {
293
+ runtimeState.loadedFiles.add(loadedFileKey);
294
+ try {
295
+ loadFileLazy(runtimeState, filePath, lang);
296
+ } catch (_) {
297
+ // stale manifest entry — already marked as loaded to prevent retry
298
+ }
299
+ const langData = runtimeState.cache.get(lang);
300
+ return resolveKey(langData, key, sep, runtimeState, lang);
301
+ }
302
+ }
303
+ if (!runtimeState.eagerLoadedLanguages.has(lang)) {
304
+ const fullData = readLanguageFromBase(runtimeState.baseDir, lang);
305
+ const langData = runtimeState.cache.get(lang) || {};
306
+ deepMerge(langData, fullData);
307
+ runtimeState.cache.set(lang, langData);
308
+ runtimeState.eagerLoadedLanguages.add(lang);
309
+ return resolveKey(langData, key, sep, runtimeState, lang);
310
+ }
311
+ return undefined;
312
+ }
177
313
  return undefined;
178
314
  }
179
315
  }
@@ -182,62 +318,110 @@ function resolveKey(obj, key, sep = '.') {
182
318
 
183
319
  // --- Public API ---
184
320
  function initRuntime(options = {}) {
185
- state.baseDir = resolveBaseDir(options.baseDir);
186
- state.language = options.language || state.language || 'en';
187
- state.fallbackLanguage = options.fallbackLanguage || state.fallbackLanguage || 'en';
188
- state.keySeparator = options.keySeparator || state.keySeparator || '.';
189
- // Optional prewarm caches
190
- state.cache.clear();
191
- if (options.preload === true) {
192
- getTranslations(state.language);
193
- if (state.fallbackLanguage && state.fallbackLanguage !== state.language) {
194
- getTranslations(state.fallbackLanguage);
321
+ const runtimeState = createState(options);
322
+ preload(runtimeState, options.preload);
323
+
324
+ if (!singletonInitialized) {
325
+ singletonState.baseDir = runtimeState.baseDir;
326
+ singletonState.language = runtimeState.language;
327
+ singletonState.fallbackLanguage = runtimeState.fallbackLanguage;
328
+ singletonState.keySeparator = runtimeState.keySeparator;
329
+ singletonState.lazy = runtimeState.lazy;
330
+ singletonState.keyManifest = new Map();
331
+ singletonState.loadedFiles = new Set();
332
+ singletonState.eagerLoadedLanguages = new Set();
333
+ singletonState.cache.clear();
334
+ preload(singletonState, options.preload);
335
+ singletonInitialized = true;
336
+ }
337
+
338
+ return createRuntime(runtimeState);
339
+ }
340
+
341
+ function preload(runtimeState, shouldPreload) {
342
+ if (shouldPreload === true) {
343
+ if (runtimeState.lazy) {
344
+ getManifestForLanguage(runtimeState, runtimeState.language);
345
+ if (!runtimeState.cache.has(runtimeState.language)) {
346
+ runtimeState.cache.set(runtimeState.language, {});
347
+ }
348
+ if (runtimeState.fallbackLanguage && runtimeState.fallbackLanguage !== runtimeState.language) {
349
+ getManifestForLanguage(runtimeState, runtimeState.fallbackLanguage);
350
+ if (!runtimeState.cache.has(runtimeState.fallbackLanguage)) {
351
+ runtimeState.cache.set(runtimeState.fallbackLanguage, {});
352
+ }
353
+ }
354
+ } else {
355
+ getTranslationsForState(runtimeState, runtimeState.language);
356
+ if (runtimeState.fallbackLanguage && runtimeState.fallbackLanguage !== runtimeState.language) {
357
+ getTranslationsForState(runtimeState, runtimeState.fallbackLanguage);
358
+ }
195
359
  }
196
360
  }
361
+ }
362
+
363
+ function createRuntime(runtimeState) {
364
+ const runtimeTranslate = (key, params) => translateWithState(runtimeState, key, params);
197
365
  return {
198
- t: translate,
199
- translate,
200
- setLanguage,
201
- getLanguage,
202
- getAvailableLanguages,
203
- refresh,
366
+ t: runtimeTranslate,
367
+ translate: runtimeTranslate,
368
+ setLanguage: (lang) => setLanguageForState(runtimeState, lang),
369
+ getLanguage: () => getLanguageForState(runtimeState),
370
+ getAvailableLanguages: () => getAvailableLanguagesForState(runtimeState),
371
+ refresh: (lang) => refreshForState(runtimeState, lang),
204
372
  };
205
373
  }
206
374
 
207
375
  function translate(key, params = {}) {
208
- const langData = getTranslations(state.language);
209
- let value = resolveKey(langData, key, state.keySeparator);
376
+ return translateWithState(singletonState, key, params);
377
+ }
210
378
 
211
- if (typeof value === 'undefined' && state.fallbackLanguage) {
212
- const fbData = getTranslations(state.fallbackLanguage);
213
- value = resolveKey(fbData, key, state.keySeparator);
214
- }
379
+ function translateWithState(runtimeState, key, params = {}) {
380
+ const langData = getTranslationsForState(runtimeState, runtimeState.language);
381
+ let value = resolveKey(langData, key, runtimeState.keySeparator, runtimeState, runtimeState.language);
382
+
383
+ if (typeof value === 'undefined' && runtimeState.fallbackLanguage) {
384
+ const fbData = getTranslationsForState(runtimeState, runtimeState.fallbackLanguage);
385
+ value = resolveKey(fbData, key, runtimeState.keySeparator, runtimeState, runtimeState.fallbackLanguage);
386
+ }
215
387
 
216
388
  if (typeof value === 'string') return interpolate(value, params);
217
389
  return typeof value === 'undefined' ? key : value;
218
390
  }
219
391
 
220
392
  function setLanguage(lang) {
393
+ setLanguageForState(singletonState, lang);
394
+ }
395
+
396
+ function setLanguageForState(runtimeState, lang) {
221
397
  if (!lang || typeof lang !== 'string') return;
222
- state.language = lang;
398
+ runtimeState.language = lang;
223
399
  }
224
400
 
225
401
  function getLanguage() {
226
- return state.language;
402
+ return getLanguageForState(singletonState);
403
+ }
404
+
405
+ function getLanguageForState(runtimeState) {
406
+ return runtimeState.language;
227
407
  }
228
408
 
229
409
  function getAvailableLanguages() {
410
+ return getAvailableLanguagesForState(singletonState);
411
+ }
412
+
413
+ function getAvailableLanguagesForState(runtimeState) {
230
414
  const langs = new Set();
231
- if (!state.baseDir) state.baseDir = resolveBaseDir();
232
- if (!SecurityUtils.safeExistsSync(state.baseDir)) return ['en'];
415
+ if (!runtimeState.baseDir) runtimeState.baseDir = resolveBaseDir();
416
+ if (!SecurityUtils.safeExistsSync(runtimeState.baseDir, path.dirname(runtimeState.baseDir))) return ['en'];
233
417
  try {
234
- for (const entry of fs.readdirSync(state.baseDir, { withFileTypes: true })) {
418
+ for (const entry of fs.readdirSync(runtimeState.baseDir, { withFileTypes: true })) {
235
419
  if (entry.isFile() && entry.name.toLowerCase().endsWith('.json')) {
236
420
  langs.add(entry.name.replace(/\.json$/i, ''));
237
421
  } else if (entry.isDirectory()) {
238
422
  const lang = entry.name;
239
- const idx = path.join(state.baseDir, lang, `${lang}.json`);
240
- if (SecurityUtils.safeExistsSync(idx)) langs.add(lang);
423
+ const idx = path.join(runtimeState.baseDir, lang, `${lang}.json`);
424
+ if (SecurityUtils.safeExistsSync(idx, path.dirname(idx))) langs.add(lang);
241
425
  else langs.add(lang); // be permissive
242
426
  }
243
427
  }
@@ -248,13 +432,24 @@ function getAvailableLanguages() {
248
432
  return Array.from(langs.size ? langs : new Set(['en']));
249
433
  }
250
434
 
251
- function refresh(lang = state.language) {
252
- if (state.cache.has(lang)) state.cache.delete(lang);
253
- if (lang !== state.fallbackLanguage && state.cache.has(state.fallbackLanguage)) {
254
- // do nothing; keep or clear on demand
255
- }
435
+ function refresh(lang) {
436
+ refreshForState(singletonState, lang);
256
437
  }
257
438
 
439
+ function refreshForState(runtimeState, lang = runtimeState.language) {
440
+ if (runtimeState.cache.has(lang)) runtimeState.cache.delete(lang);
441
+ if (runtimeState.eagerLoadedLanguages) runtimeState.eagerLoadedLanguages.delete(lang);
442
+ if (runtimeState.keyManifest) runtimeState.keyManifest.delete(lang);
443
+ if (runtimeState.loadedFiles) {
444
+ for (const fileKey of Array.from(runtimeState.loadedFiles)) {
445
+ if (fileKey.startsWith(`${lang}\0`)) runtimeState.loadedFiles.delete(fileKey);
446
+ }
447
+ }
448
+ if (lang !== runtimeState.fallbackLanguage && runtimeState.cache.has(runtimeState.fallbackLanguage)) {
449
+ // do nothing; keep or clear on demand
450
+ }
451
+ }
452
+
258
453
  module.exports = {
259
454
  initRuntime,
260
455
  translate,
@@ -262,5 +457,6 @@ module.exports = {
262
457
  setLanguage,
263
458
  getLanguage,
264
459
  getAvailableLanguages,
265
- refresh,
266
- };
460
+ refresh,
461
+ loadKeyManifest: (baseDir) => loadKeyManifestFromDir(baseDir || singletonState.baseDir),
462
+ };
@@ -1209,7 +1209,7 @@
1209
1209
  },
1210
1210
  "operations": {
1211
1211
  "completed": "✅ Operation completed successfully!",
1212
- "deleteReportsTitle": "🗑️ Delete Reports & Logs",
1212
+ "deleteReportsTitle": "🗑️ Delete Reports, Logs & Cache",
1213
1213
  "scanningForFiles": "🔍 Scanning for files to delete...",
1214
1214
  "noFilesFoundToDelete": "✅ No files found to delete.",
1215
1215
  "availableDirectories": "📁 Available directories:",
@@ -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,90 @@ 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 prefix = trimmed.substring(0, 'surrounded:'.length);
79
+ if (prefix.toLowerCase() === 'surrounded:') {
80
+ const rest = trimmed.substring('surrounded:'.length);
81
+ const idx = rest.indexOf(',');
82
+ if (idx === -1) return null;
83
+ const left = rest.slice(0, idx).trim();
84
+ const right = rest.slice(idx + 1).trim();
85
+ const leftWords = left.split('|').map(w => w.trim()).filter(Boolean);
86
+ const rightWords = right.split('|').map(w => w.trim()).filter(Boolean);
87
+ if (leftWords.length === 0 || rightWords.length === 0) return null;
88
+ return { mode: 'surrounded', left: leftWords, right: rightWords };
89
+ }
90
+
91
+ return null;
92
+ }
93
+
94
+ function parseContextRule(rule) {
95
+ if (typeof rule === 'string') {
96
+ const trimmed = rule.trim();
97
+ if (!trimmed || trimmed.length > MAX_CONTEXT_INPUT_LENGTH) return null;
98
+ return { value: trimmed, type: 'global' };
99
+ }
100
+
101
+ if (
102
+ typeof rule === 'object' &&
103
+ rule !== null &&
104
+ typeof rule.value === 'string' &&
105
+ typeof rule.context === 'string'
106
+ ) {
107
+ const value = rule.value.trim();
108
+ const rawContext = rule.context;
109
+
110
+ if (!value || value.length > MAX_CONTEXT_INPUT_LENGTH) return null;
111
+ if (!rawContext || rawContext.length > MAX_CONTEXT_INPUT_LENGTH) return null;
112
+
113
+ const parsed = parseContextString(rawContext);
114
+ if (!parsed) return null;
115
+
116
+ return { value, type: 'context', context: parsed };
117
+ }
118
+
119
+ return null;
120
+ }
121
+
52
122
  function normalizeProtectionConfig(config = {}, filePath = null) {
53
123
  const terms = normalizeList(config.terms);
54
124
  const keys = normalizeList(config.keys);
55
125
  const values = normalizeList(config.values);
56
126
  const patterns = normalizeList(config.patterns);
57
127
 
128
+ const rawTerms = Array.isArray(config.terms) ? config.terms : [];
129
+ const normalizedTerms = [];
130
+ for (const term of rawTerms) {
131
+ const normalized = parseContextRule(term);
132
+ if (normalized) {
133
+ normalizedTerms.push(normalized);
134
+ }
135
+ }
136
+ const cappedTerms = normalizedTerms.slice(0, MAX_CONTEXT_RULES);
137
+
58
138
  return {
59
139
  enabled: true,
60
140
  filePath,
@@ -62,7 +142,8 @@ function normalizeProtectionConfig(config = {}, filePath = null) {
62
142
  keys,
63
143
  values,
64
144
  patterns,
65
- compiledPatterns: compilePatterns(patterns)
145
+ compiledPatterns: compilePatterns(patterns),
146
+ normalizedTerms: cappedTerms
66
147
  };
67
148
  }
68
149
 
@@ -98,10 +179,20 @@ function saveProtectionFile(filePath, config, options = {}) {
98
179
  if (!SecurityUtils.safeExistsSync(dir, path.dirname(dir))) {
99
180
  SecurityUtils.safeMkdirSync(dir, path.dirname(dir), { recursive: true });
100
181
  }
182
+
183
+ const rawTerms = Array.isArray(config?.terms) ? config.terms : [];
184
+ const sanitizedTerms = [];
185
+ for (const term of rawTerms) {
186
+ const normalized = parseContextRule(term);
187
+ if (normalized) {
188
+ sanitizedTerms.push(term);
189
+ }
190
+ }
191
+
101
192
  const nextConfig = {
102
193
  ...defaultProtectionConfig(),
103
194
  ...(config || {}),
104
- terms: normalizeList(config?.terms),
195
+ terms: sanitizedTerms.slice(0, MAX_CONTEXT_RULES),
105
196
  keys: normalizeList(config?.keys),
106
197
  values: normalizeList(config?.values),
107
198
  patterns: normalizeList(config?.patterns)
@@ -156,7 +247,8 @@ function shouldPreserveWholeValue(keyPath, value, protection) {
156
247
  if (!protection || protection.enabled === false) return false;
157
248
  if (protection.keys.some(rule => keyMatchesRule(keyPath, rule))) return true;
158
249
  const valueText = String(value);
159
- return protection.values.includes(valueText) || protection.terms.includes(valueText);
250
+ return protection.values.includes(valueText) ||
251
+ (protection.normalizedTerms || []).some(rule => rule.type === 'global' && rule.value === valueText);
160
252
  }
161
253
 
162
254
  function addReplacement(replacements, original) {
@@ -165,13 +257,65 @@ function addReplacement(replacements, original) {
165
257
  replacements.push({ original });
166
258
  }
167
259
 
260
+ function shouldProtectInContext(value, rule, index, fullText) {
261
+ if (rule.type === 'global') return true;
262
+
263
+ const { context } = rule;
264
+ const before = fullText.substring(0, index);
265
+ const after = fullText.substring(index + value.length);
266
+
267
+ if (context.mode === 'after') {
268
+ const alternation = context.words.map(escapeRegExp).join('|');
269
+ const regex = new RegExp(`(^|[\\s\\p{P}])(${alternation})\\s+$`, 'iu');
270
+ return regex.test(before);
271
+ }
272
+
273
+ if (context.mode === 'before') {
274
+ const alternation = context.words.map(escapeRegExp).join('|');
275
+ const regex = new RegExp(`^\\s+(${alternation})([\\s\\p{P}]|$)`, 'iu');
276
+ return regex.test(after);
277
+ }
278
+
279
+ if (context.mode === 'standalone') {
280
+ const isBoundaryBefore = before === '' || /(?:\s|\(|\[|\{|"|'|\-|–|—|。|、|」)$/.test(before);
281
+ const isBoundaryAfter = after === '' || /^[\s.,!?;:)\]}<>"'\-–—。、」]/.test(after);
282
+ return isBoundaryBefore && isBoundaryAfter;
283
+ }
284
+
285
+ if (context.mode === 'surrounded') {
286
+ const leftAlternation = context.left.map(escapeRegExp).join('|');
287
+ const rightAlternation = context.right.map(escapeRegExp).join('|');
288
+ const leftRegex = new RegExp(`(^|[\\s\\p{P}])(${leftAlternation})\\s+$`, 'iu');
289
+ const rightRegex = new RegExp(`^\\s+(${rightAlternation})([\\s\\p{P}]|$)`, 'iu');
290
+ return leftRegex.test(before) && rightRegex.test(after);
291
+ }
292
+
293
+ return false;
294
+ }
295
+
168
296
  function collectReplacements(value, protection) {
169
297
  const text = String(value);
170
298
  const replacements = [];
171
299
 
172
- for (const term of protection.terms || []) {
173
- if (text.includes(term)) {
174
- addReplacement(replacements, term);
300
+ const rules = protection.normalizedTerms || (protection.terms || [])
301
+ .map(parseContextRule)
302
+ .filter(Boolean);
303
+ for (const rule of rules) {
304
+ if (rule.type === 'global') {
305
+ if (text.includes(rule.value)) {
306
+ addReplacement(replacements, rule.value);
307
+ }
308
+ } else {
309
+ if (!rule.value) continue;
310
+ const escaped = escapeRegExp(rule.value);
311
+ const regex = new RegExp(escaped, 'gi');
312
+ let match;
313
+ while ((match = regex.exec(text)) !== null) {
314
+ if (shouldProtectInContext(rule.value, rule, match.index, text)) {
315
+ addReplacement(replacements, match[0]);
316
+ }
317
+ if (match[0] === '') regex.lastIndex++;
318
+ }
175
319
  }
176
320
  }
177
321
 
@@ -221,7 +365,8 @@ function hasProtectionRules(protection) {
221
365
  return Boolean(
222
366
  protection &&
223
367
  (
224
- protection.terms.length ||
368
+ (protection.normalizedTerms && protection.normalizedTerms.length) ||
369
+ (protection.terms && protection.terms.length) ||
225
370
  protection.keys.length ||
226
371
  protection.values.length ||
227
372
  protection.patterns.length
@@ -235,6 +380,7 @@ module.exports = {
235
380
  defaultProtectionConfig,
236
381
  hasProtectionRules,
237
382
  loadProtectionConfig,
383
+ parseContextRule,
238
384
  protectText,
239
385
  readProtectionFile,
240
386
  restoreText,