i18ntk 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.
@@ -46,9 +46,10 @@ const watchLocales = require('../utils/watch-locales');
46
46
  const { getGlobalReadline, closeGlobalReadline } = require('../utils/cli');
47
47
  const { getUnifiedConfig, parseCommonArgs, displayHelp, validateSourceDir, displayPaths } = require('../utils/config-helper');
48
48
  const I18nInitializer = require('./i18ntk-init');
49
- const JsonOutput = require('../utils/json-output');
50
- const ExitCodes = require('../utils/exit-codes');
51
- const SetupEnforcer = require('../utils/setup-enforcer');
49
+ const JsonOutput = require('../utils/json-output');
50
+ const ExitCodes = require('../utils/exit-codes');
51
+ const SetupEnforcer = require('../utils/setup-enforcer');
52
+ const { detectTranslationContentRisks } = require('../utils/validation-risk');
52
53
 
53
54
  // Ensure setup is complete before running
54
55
  (async () => {
@@ -405,17 +406,28 @@ class I18nValidator {
405
406
  }
406
407
  }
407
408
 
408
- detectRiskyKeys(obj, language, fileName, prefix = '') {
409
- for (const [key, value] of Object.entries(obj || {})) {
410
- const fullKey = prefix ? `${prefix}.${key}` : key;
411
- if (typeof value === 'string') {
412
- if (/https?:\/\//.test(value) || /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/.test(value) || /(api[_-]?key|secret|token)/i.test(value)) {
413
- const reporter = this.config.strictMode ? this.addError.bind(this) : this.addWarning.bind(this);
414
- reporter(`Risky content in ${language}/${fileName}`, { key: fullKey, value });
415
- }
416
- } else if (value && typeof value === 'object' && !Array.isArray(value)) {
417
- this.detectRiskyKeys(value, language, fileName, fullKey);
418
- }
409
+ detectRiskyKeys(obj, language, fileName, prefix = '') {
410
+ for (const [key, value] of Object.entries(obj || {})) {
411
+ const fullKey = prefix ? `${prefix}.${key}` : key;
412
+ if (typeof value === 'string') {
413
+ const issues = detectTranslationContentRisks(value, {
414
+ keyPath: fullKey,
415
+ sourceLanguage: this.config.sourceLanguage,
416
+ targetLanguage: language,
417
+ allowedEnglishTerms: this.config.allowedEnglishTerms,
418
+ englishThresholdPercent: this.config.englishContentThresholdPercent
419
+ });
420
+
421
+ issues.forEach(issue => {
422
+ const reporter = this.config.strictMode ? this.addError.bind(this) : this.addWarning.bind(this);
423
+ const message = issue.type === 'english_content'
424
+ ? `Possible untranslated English content in ${language}/${fileName}`
425
+ : `Potential risky content in ${language}/${fileName}`;
426
+ reporter(message, { key: fullKey, value, ...issue });
427
+ });
428
+ } else if (value && typeof value === 'object' && !Array.isArray(value)) {
429
+ this.detectRiskyKeys(value, language, fileName, fullKey);
430
+ }
419
431
  }
420
432
  }
421
433
 
@@ -7,11 +7,17 @@
7
7
  * Wraps i18ntk-translate.js behind a user-friendly menu flow.
8
8
  */
9
9
 
10
- const fs = require('fs');
11
10
  const path = require('path');
11
+ const SecurityUtils = require('../../../utils/security');
12
+ const configManager = require('../../../utils/config-manager');
12
13
  const { getUnifiedConfig } = require('../../../utils/config-helper');
13
14
  const { loadTranslations } = require('../../../utils/i18n-helper');
14
15
  const SetupEnforcer = require('../../../utils/setup-enforcer');
16
+ const {
17
+ createProtectionFile,
18
+ readProtectionFile,
19
+ saveProtectionFile
20
+ } = require('../../../utils/translate/protection');
15
21
 
16
22
  class TranslateCommand {
17
23
  constructor(config = {}, ui = null) {
@@ -23,6 +29,7 @@ class TranslateCommand {
23
29
  this.sourceDir = null;
24
30
  this.sourceLang = null;
25
31
  this.targetLang = null;
32
+ this.configuredTargetLangs = [];
26
33
  }
27
34
 
28
35
  setRuntimeDependencies(prompt, isNonInteractiveMode, safeClose) {
@@ -42,10 +49,17 @@ class TranslateCommand {
42
49
  loadTranslations('en', path.resolve(__dirname, '..', '..', '..', 'ui-locales'));
43
50
 
44
51
  const config = this.config || {};
45
- const unified = getUnifiedConfig(config);
52
+ let unified;
53
+ try {
54
+ unified = await getUnifiedConfig('translate', options);
55
+ } catch (_) {
56
+ unified = config;
57
+ }
58
+ this.autoTranslateSettings = this.getAutoTranslateSettings(unified);
46
59
 
47
60
  const defaultSourceDir = unified.sourceDir || unified.i18nDir || path.resolve(process.cwd(), 'locales', 'en');
48
61
  this.sourceLang = unified.sourceLanguage || 'en';
62
+ this.configuredTargetLangs = this.getConfiguredTargetLanguages(unified, defaultSourceDir);
49
63
 
50
64
  console.log('\n============================================================');
51
65
  console.log(' \u{1F310} AUTO TRANSLATE (BETA)');
@@ -53,11 +67,11 @@ class TranslateCommand {
53
67
 
54
68
  if (this.isNonInteractiveMode) {
55
69
  this.sourceDir = defaultSourceDir;
56
- if (!fs.existsSync(this.sourceDir)) {
70
+ if (!SecurityUtils.safeExistsSync(this.sourceDir, path.dirname(this.sourceDir))) {
57
71
  console.error(`Source locale directory not found: ${this.sourceDir}`);
58
72
  return { success: false, error: 'Source directory not found' };
59
73
  }
60
- const jsonFiles = fs.readdirSync(this.sourceDir).filter(f => f.endsWith('.json')).sort();
74
+ const jsonFiles = SecurityUtils.safeReaddirSync(this.sourceDir, path.dirname(this.sourceDir)).filter(f => f.endsWith('.json')).sort();
61
75
  return await this.nonInteractiveFlow(jsonFiles);
62
76
  }
63
77
 
@@ -70,8 +84,9 @@ class TranslateCommand {
70
84
  // Step 2: Choose source language
71
85
  this.sourceLang = await this.promptSourceLang(ask);
72
86
  if (!this.sourceLang) return { success: false, error: 'No source language selected' };
87
+ this.configuredTargetLangs = this.getConfiguredTargetLanguages(unified, this.sourceDir);
73
88
 
74
- const jsonFiles = fs.readdirSync(this.sourceDir)
89
+ const jsonFiles = SecurityUtils.safeReaddirSync(this.sourceDir, path.dirname(this.sourceDir))
75
90
  .filter(f => f.endsWith('.json'))
76
91
  .sort();
77
92
 
@@ -85,12 +100,19 @@ class TranslateCommand {
85
100
 
86
101
  async promptSourceDir(ask, defaultDir) {
87
102
  while (true) {
88
- console.log(`\n Source directory [default: ${defaultDir}]`);
89
- console.log(' Press Enter for default, or type a custom path.');
103
+ console.log('\n Source locale directory');
104
+ console.log(` Default: ${defaultDir}`);
105
+ console.log(` Current project: ${process.cwd()}`);
106
+ console.log(' Accepted: an absolute path, or a path relative to the current project.');
107
+ console.log(' Examples:');
108
+ console.log(' ./locales/en');
109
+ console.log(` ${defaultDir}`);
110
+ console.log(' The folder must contain the source JSON files to translate.');
111
+ console.log(' Press Enter to use the default.');
90
112
  const input = await ask(' > ');
91
113
 
92
114
  if (!input.trim()) {
93
- if (!fs.existsSync(defaultDir)) {
115
+ if (!SecurityUtils.safeExistsSync(defaultDir, path.dirname(defaultDir))) {
94
116
  console.log(` Default directory not found: ${defaultDir}`);
95
117
  console.log(' Please enter an existing directory with JSON locale files.');
96
118
  continue;
@@ -99,25 +121,36 @@ class TranslateCommand {
99
121
  return defaultDir;
100
122
  }
101
123
 
102
- const resolved = path.resolve(process.cwd(), input.trim());
103
- if (!fs.existsSync(resolved)) {
124
+ const cleanInput = input.trim().replace(/^["']|["']$/g, '');
125
+ const resolved = path.isAbsolute(cleanInput)
126
+ ? path.resolve(cleanInput)
127
+ : path.resolve(process.cwd(), cleanInput);
128
+ if (!SecurityUtils.safeExistsSync(resolved, path.dirname(resolved))) {
104
129
  console.log(` Directory not found: ${resolved}`);
130
+ console.log(' Enter an existing folder, for example ./locales/en.');
105
131
  continue;
106
132
  }
107
- if (!fs.statSync(resolved).isDirectory()) {
133
+ const stats = SecurityUtils.safeStatSync(resolved, path.dirname(resolved));
134
+ if (!stats || !stats.isDirectory()) {
108
135
  console.log(` Not a directory: ${resolved}`);
109
136
  continue;
110
137
  }
138
+ console.log(` Using source directory: ${resolved}`);
111
139
  return resolved;
112
140
  }
113
141
  }
114
142
 
115
143
  async promptSourceLang(ask) {
116
144
  while (true) {
117
- console.log(`\n Source language code [default: ${this.sourceLang}]`);
145
+ console.log('\n Source language code');
146
+ console.log(` Default: ${this.sourceLang}`);
147
+ console.log(' This should match the language of the source JSON values.');
148
+ console.log(' Example: en');
149
+ console.log(' Press Enter to use the default.');
118
150
  const input = await ask(' > ');
119
151
 
120
152
  if (!input.trim()) {
153
+ console.log(` Using source language: ${this.sourceLang}`);
121
154
  return this.sourceLang;
122
155
  }
123
156
 
@@ -130,35 +163,44 @@ class TranslateCommand {
130
163
  }
131
164
 
132
165
  async interactiveFlow(jsonFiles, ask) {
166
+ await this.maybeConfigureProtection(ask);
133
167
 
134
- console.log('\n Target language code(s)');
135
- console.log(' Enter one or more comma/space-separated codes');
136
- console.log(' (e.g. de, es, fr or de es fr or de):');
168
+ console.log('\n Target language(s)');
169
+ if (this.configuredTargetLangs.length > 0) {
170
+ console.log(` a) All configured target languages: ${this.configuredTargetLangs.join(', ')}`);
171
+ } else {
172
+ console.log(' a) All configured target languages: none configured');
173
+ }
174
+ console.log(' Or enter one or more comma/space-separated language codes.');
175
+ console.log(' Examples: de, es, fr or de es fr or zh');
176
+ console.log(` Source language "${this.sourceLang}" will be excluded automatically.`);
137
177
  const langInput = await ask(' > ');
138
178
 
139
- const targetLangs = langInput
140
- .trim()
141
- .split(/[,;\s]+/)
142
- .map(s => s.toLowerCase().trim())
143
- .filter(s => s.length >= 2);
179
+ const targetLangs = this.parseTargetLanguages(langInput);
144
180
 
145
181
  if (targetLangs.length === 0) {
146
- console.log(' No valid language codes entered. Aborting.');
182
+ console.log(' No valid target languages selected. Aborting.');
183
+ if (this.configuredTargetLangs.length === 0) {
184
+ console.log(' Configure defaultLanguages in .i18ntk-config, or enter target codes manually.');
185
+ }
147
186
  return { success: false, error: 'Invalid language code' };
148
187
  }
149
188
 
150
189
  console.log(`\n Target languages: ${targetLangs.join(', ')}`);
151
190
 
152
191
  console.log(`\n Which file(s) to translate?`);
153
- console.log(` a) All files (${jsonFiles.join(', ')})`);
192
+ const filePreview = jsonFiles.length <= 6
193
+ ? jsonFiles.join(', ')
194
+ : `${jsonFiles.slice(0, 6).join(', ')}, ...`;
195
+ console.log(` a) All JSON files (${jsonFiles.length}: ${filePreview})`);
154
196
  jsonFiles.forEach((f, i) => {
155
197
  console.log(` ${i + 1}) ${f}`);
156
198
  });
157
199
 
158
- const fileChoice = await ask('\n Choice [a/1-9]: ');
200
+ const fileChoice = await ask('\n Choice [a/all or file number]: ');
159
201
  let sourceFiles;
160
202
 
161
- if (fileChoice.toLowerCase() === 'a') {
203
+ if (['a', 'all', '*'].includes(fileChoice.trim().toLowerCase())) {
162
204
  sourceFiles = jsonFiles.map(f => path.join(this.sourceDir, f));
163
205
  } else {
164
206
  const idx = parseInt(fileChoice, 10) - 1;
@@ -169,10 +211,12 @@ class TranslateCommand {
169
211
  sourceFiles = [path.join(this.sourceDir, jsonFiles[idx])];
170
212
  }
171
213
 
172
- // Dry-run for first language only (all languages use same source so same keys)
173
- const firstLang = targetLangs[0];
174
- console.log(`\n Dry-run preview for "${firstLang}"...\n`);
175
- await this.runTranslate(sourceFiles, firstLang, { dryRun: true });
214
+ if (this.autoTranslateSettings.dryRunFirst !== false) {
215
+ // Dry-run for first language only (all languages use same source so same keys)
216
+ const firstLang = targetLangs[0];
217
+ console.log(`\n Dry-run preview for "${firstLang}"...\n`);
218
+ await this.runTranslate(sourceFiles, firstLang, { dryRun: true });
219
+ }
176
220
 
177
221
  console.log('\n Proceed with actual translation?');
178
222
  const answer = await ask(' [y]es / [n]o: ');
@@ -207,34 +251,211 @@ class TranslateCommand {
207
251
  return { success: false, error: 'Non-interactive mode not supported from menu' };
208
252
  }
209
253
 
254
+ getConfiguredTargetLanguages(config = {}, sourceDir = this.sourceDir) {
255
+ const candidates = []
256
+ .concat(config.defaultLanguages || [])
257
+ .concat(config.targetLanguages || [])
258
+ .concat(config.supportedLanguages || [])
259
+ .concat(config.settings?.defaultLanguages || []);
260
+
261
+ const parentDir = sourceDir ? path.dirname(sourceDir) : null;
262
+ if (parentDir && SecurityUtils.safeExistsSync(parentDir, path.dirname(parentDir))) {
263
+ const siblingDirs = SecurityUtils.safeReaddirSync(parentDir, path.dirname(parentDir), { withFileTypes: true })
264
+ .filter(entry => entry.isDirectory())
265
+ .map(entry => entry.name);
266
+ candidates.push(...siblingDirs);
267
+ }
268
+
269
+ return this.normalizeLanguageList(candidates);
270
+ }
271
+
272
+ normalizeLanguageList(languages) {
273
+ const seen = new Set();
274
+ const sourceLang = String(this.sourceLang || '').toLowerCase();
275
+ const normalized = [];
276
+
277
+ for (const lang of languages || []) {
278
+ if (typeof lang !== 'string') continue;
279
+ const clean = lang.trim().toLowerCase();
280
+ if (!/^[a-z]{2,3}(?:[-_][a-z0-9]{2,8})?$/i.test(clean)) continue;
281
+ if (clean === sourceLang || seen.has(clean)) continue;
282
+ seen.add(clean);
283
+ normalized.push(clean);
284
+ }
285
+
286
+ return normalized.sort();
287
+ }
288
+
289
+ parseTargetLanguages(input) {
290
+ const clean = String(input || '').trim().toLowerCase();
291
+ if (['a', 'all', '*'].includes(clean)) {
292
+ return [...this.configuredTargetLangs];
293
+ }
294
+
295
+ return this.normalizeLanguageList(clean.split(/[,;\s]+/));
296
+ }
297
+
298
+ getAutoTranslateSettings(config = {}) {
299
+ const settings = config.autoTranslate || config.settings?.autoTranslate || {};
300
+ return {
301
+ placeholderMode: ['preserve', 'skip', 'send'].includes(settings.placeholderMode)
302
+ ? settings.placeholderMode
303
+ : 'preserve',
304
+ concurrency: this.toInt(settings.concurrency, 6, 1, 25),
305
+ batchSize: this.toInt(settings.batchSize, 100, 1, 10000),
306
+ progressInterval: this.toInt(settings.progressInterval, 25, 1, 10000),
307
+ retryCount: this.toInt(settings.retryCount, 3, 0, 10),
308
+ retryDelay: this.toInt(settings.retryDelay, 1000, 0, 30000),
309
+ timeout: this.toInt(settings.timeout, 15000, 1000, 120000),
310
+ dryRunFirst: settings.dryRunFirst !== false,
311
+ reportStdout: settings.reportStdout !== false,
312
+ bom: settings.bom === true,
313
+ protectionEnabled: settings.protectionEnabled !== false,
314
+ protectionFile: settings.protectionFile || './i18ntk-auto-translate.json',
315
+ promptProtectionSetup: settings.promptProtectionSetup !== false,
316
+ promptProtectionUpdate: settings.promptProtectionUpdate !== false
317
+ };
318
+ }
319
+
320
+ async updateAutoTranslateSetting(key, value) {
321
+ try {
322
+ await configManager.updateConfig({ autoTranslate: { [key]: value } });
323
+ await configManager.saveConfig();
324
+ this.autoTranslateSettings[key] = value;
325
+ } catch (error) {
326
+ console.log(` Warning: could not save Auto Translate setting "${key}": ${error.message}`);
327
+ }
328
+ }
329
+
330
+ async maybeConfigureProtection(ask) {
331
+ const settings = this.autoTranslateSettings;
332
+ if (!settings.protectionEnabled) return;
333
+
334
+ const protectionPath = path.resolve(process.cwd(), settings.protectionFile);
335
+ const exists = SecurityUtils.safeExistsSync(protectionPath, path.dirname(protectionPath));
336
+
337
+ if (!exists && settings.promptProtectionSetup) {
338
+ console.log('\n Protected terms and keys');
339
+ console.log(' Auto Translate can keep brand names, product terms, exact values, or key paths unchanged.');
340
+ console.log(` Protection file: ${protectionPath}`);
341
+ console.log(' Create this JSON file now?');
342
+ const answer = await ask(' [y]es / [n]o / [d]on\'t ask again: ');
343
+ const clean = answer.trim().toLowerCase();
344
+ if (clean === 'd' || clean === 'dont ask again' || clean === "don't ask again") {
345
+ await this.updateAutoTranslateSetting('promptProtectionSetup', false);
346
+ return;
347
+ }
348
+ if (/^y|yes$/i.test(clean)) {
349
+ createProtectionFile(settings.protectionFile);
350
+ await this.promptProtectionEntries(ask, settings.protectionFile);
351
+ return;
352
+ }
353
+ }
354
+
355
+ if (exists && settings.promptProtectionUpdate) {
356
+ console.log('\n Protected terms and keys');
357
+ console.log(` Current protection file: ${protectionPath}`);
358
+ console.log(' Update protection rules for this run?');
359
+ const answer = await ask(' [y]es / [n]o / [d]on\'t ask again: ');
360
+ const clean = answer.trim().toLowerCase();
361
+ if (clean === 'd' || clean === 'dont ask again' || clean === "don't ask again") {
362
+ await this.updateAutoTranslateSetting('promptProtectionUpdate', false);
363
+ return;
364
+ }
365
+ if (/^y|yes$/i.test(clean)) {
366
+ await this.promptProtectionEntries(ask, settings.protectionFile);
367
+ }
368
+ }
369
+ }
370
+
371
+ splitList(input) {
372
+ return String(input || '')
373
+ .split(/[,;\n]+/)
374
+ .map(item => item.trim())
375
+ .filter(Boolean);
376
+ }
377
+
378
+ mergeList(existing, additions) {
379
+ const seen = new Set(existing || []);
380
+ for (const item of additions || []) {
381
+ if (!seen.has(item)) seen.add(item);
382
+ }
383
+ return Array.from(seen);
384
+ }
385
+
386
+ async promptProtectionEntries(ask, protectionFile) {
387
+ let config;
388
+ try {
389
+ config = readProtectionFile(protectionFile);
390
+ } catch (_) {
391
+ config = {
392
+ version: 1,
393
+ terms: [],
394
+ keys: [],
395
+ values: [],
396
+ patterns: []
397
+ };
398
+ }
399
+
400
+ console.log('\n Add protected terms separated by commas.');
401
+ console.log(' Example: BrandName, PRODUCT_CODE, API');
402
+ const terms = this.splitList(await ask(' Terms [Enter to skip]: '));
403
+
404
+ console.log('\n Add protected key paths separated by commas.');
405
+ console.log(' Exact keys and * wildcards are supported.');
406
+ console.log(' Example: app.brandName, legal.companyName, product.*.symbol');
407
+ const keys = this.splitList(await ask(' Keys [Enter to skip]: '));
408
+
409
+ console.log('\n Add exact values to copy unchanged separated by commas.');
410
+ console.log(' Example: BrandName Ltd, support@example.com');
411
+ const values = this.splitList(await ask(' Values [Enter to skip]: '));
412
+
413
+ console.log('\n Add optional JavaScript regex patterns separated by commas.');
414
+ console.log(' Example: [A-Z]{2,}-\\d+');
415
+ const patterns = this.splitList(await ask(' Patterns [Enter to skip]: '));
416
+
417
+ config.terms = this.mergeList(config.terms, terms);
418
+ config.keys = this.mergeList(config.keys, keys);
419
+ config.values = this.mergeList(config.values, values);
420
+ config.patterns = this.mergeList(config.patterns, patterns);
421
+
422
+ const savedPath = saveProtectionFile(protectionFile, config);
423
+ console.log(` Protection file saved: ${savedPath}`);
424
+ }
425
+
426
+ toInt(value, fallback, min, max) {
427
+ const parsed = parseInt(value, 10);
428
+ if (!Number.isInteger(parsed)) return fallback;
429
+ return Math.min(Math.max(parsed, min), max);
430
+ }
431
+
210
432
  async runTranslate(sourceFiles, targetLang, opts = {}) {
211
- const { spawn } = require('child_process');
433
+ const { parseArgs, run } = require('../../i18ntk-translate');
434
+ const settings = this.autoTranslateSettings || this.getAutoTranslateSettings();
212
435
 
213
436
  for (const src of sourceFiles) {
214
- const args = [
215
- path.resolve(__dirname, '..', '..', 'i18ntk-translate.js'),
216
- src,
217
- targetLang,
218
- '--no-confirm',
219
- '--skip-placeholders',
220
- '--report-stdout'
221
- ];
222
-
223
- if (opts.dryRun) {
224
- args.push('--dry-run');
437
+ const args = parseArgs(['node', 'i18ntk-translate', src, targetLang]);
438
+ args.noConfirm = true;
439
+ args.sourceLang = this.sourceLang || 'en';
440
+ args.dryRun = opts.dryRun === true;
441
+ args.reportStdout = settings.reportStdout;
442
+ args.bom = settings.bom;
443
+ args.concurrency = settings.concurrency;
444
+ args.batchSize = settings.batchSize;
445
+ args.progressInterval = settings.progressInterval;
446
+ args.retryCount = settings.retryCount;
447
+ args.retryDelay = settings.retryDelay;
448
+ args.timeout = settings.timeout;
449
+ args.protectionEnabled = settings.protectionEnabled;
450
+ args.protectionFile = settings.protectionFile;
451
+ args.preservePlaceholders = settings.placeholderMode === 'preserve';
452
+ args.skipPlaceholders = settings.placeholderMode === 'skip';
453
+ args.sendPlaceholders = settings.placeholderMode === 'send';
454
+
455
+ const result = await run(args);
456
+ if (!result || result.success !== true) {
457
+ throw new Error(result?.error || `Translation failed for ${src}`);
225
458
  }
226
-
227
- await new Promise((resolve, reject) => {
228
- const proc = spawn('node', args, {
229
- stdio: 'inherit',
230
- cwd: process.cwd()
231
- });
232
- proc.on('close', (code) => {
233
- if (code === 0) resolve();
234
- else reject(new Error(`Exit code ${code}`));
235
- });
236
- proc.on('error', reject);
237
- });
238
459
  }
239
460
  }
240
461
  }
@@ -18,8 +18,9 @@ const watchLocales = require('../../../utils/watch-locales');
18
18
  const { getGlobalReadline, closeGlobalReadline } = require('../../../utils/cli');
19
19
  const { getUnifiedConfig, parseCommonArgs, displayHelp, validateSourceDir, displayPaths } = require('../../../utils/config-helper');
20
20
  const I18nInitializer = require('../../i18ntk-init');
21
- const JsonOutput = require('../../../utils/json-output');
22
- const ExitCodes = require('../../../utils/exit-codes');
21
+ const JsonOutput = require('../../../utils/json-output');
22
+ const ExitCodes = require('../../../utils/exit-codes');
23
+ const { detectTranslationContentRisks } = require('../../../utils/validation-risk');
23
24
 
24
25
  loadTranslations('en', path.resolve(__dirname, '../../../ui-locales'));
25
26
 
@@ -383,17 +384,28 @@ class ValidateCommand {
383
384
  }
384
385
  }
385
386
 
386
- detectRiskyKeys(obj, language, fileName, prefix = '') {
387
- for (const [key, value] of Object.entries(obj || {})) {
388
- const fullKey = prefix ? `${prefix}.${key}` : key;
389
- if (typeof value === 'string') {
390
- if (/https?:\/\//.test(value) || /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/.test(value) || /(api[_-]?key|secret|token)/i.test(value)) {
391
- const reporter = this.config.strictMode ? this.addError.bind(this) : this.addWarning.bind(this);
392
- reporter(`Risky content in ${language}/${fileName}`, { key: fullKey, value });
393
- }
394
- } else if (value && typeof value === 'object' && !Array.isArray(value)) {
395
- this.detectRiskyKeys(value, language, fileName, fullKey);
396
- }
387
+ detectRiskyKeys(obj, language, fileName, prefix = '') {
388
+ for (const [key, value] of Object.entries(obj || {})) {
389
+ const fullKey = prefix ? `${prefix}.${key}` : key;
390
+ if (typeof value === 'string') {
391
+ const issues = detectTranslationContentRisks(value, {
392
+ keyPath: fullKey,
393
+ sourceLanguage: this.config.sourceLanguage,
394
+ targetLanguage: language,
395
+ allowedEnglishTerms: this.config.allowedEnglishTerms,
396
+ englishThresholdPercent: this.config.englishContentThresholdPercent
397
+ });
398
+
399
+ issues.forEach(issue => {
400
+ const reporter = this.config.strictMode ? this.addError.bind(this) : this.addWarning.bind(this);
401
+ const message = issue.type === 'english_content'
402
+ ? `Possible untranslated English content in ${language}/${fileName}`
403
+ : `Potential risky content in ${language}/${fileName}`;
404
+ reporter(message, { key: fullKey, value, ...issue });
405
+ });
406
+ } else if (value && typeof value === 'object' && !Array.isArray(value)) {
407
+ this.detectRiskyKeys(value, language, fileName, fullKey);
408
+ }
397
409
  }
398
410
  }
399
411
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18ntk",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, auto translation, fixing, reporting, and runtime translation loading.",
5
5
  "keywords": [
6
6
  "i18n",
@@ -123,6 +123,7 @@
123
123
  "utils/translate/traverse.js",
124
124
  "utils/translate/report.js",
125
125
  "utils/translate/cli.js",
126
+ "utils/translate/protection.js",
126
127
  "utils/framework-detector.js",
127
128
  "utils/i18n-helper.js",
128
129
  "utils/init-helper.js",
@@ -137,6 +138,7 @@
137
138
  "utils/security.js",
138
139
  "utils/setup-enforcer.js",
139
140
  "utils/terminal-icons.js",
141
+ "utils/validation-risk.js",
140
142
  "utils/version-utils.js",
141
143
  "utils/watch-locales.js",
142
144
  "LICENSE",