i18ntk 3.2.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.
@@ -82,9 +82,11 @@ class I18nSizingAnalyzer {
82
82
  this.format = options.format || 'table';
83
83
  this.outputReport = options.outputReport || false;
84
84
  this.sourceLanguage = options.sourceLanguage || config.sourceLanguage || 'en';
85
- this.detailed = options.detailed || false;
86
- this.detailedKeys = options.detailedKeys || false;
87
- this.rl = null;
85
+ this.detailed = options.detailed || false;
86
+ this.detailedKeys = options.detailedKeys || false;
87
+ this.predictExpansion = options.predictExpansion || false;
88
+ this.rl = null;
89
+ this.expansionPredictions = null;
88
90
 
89
91
  // Initialize i18n with UI language from config
90
92
  const uiLanguage = options.uiLanguage || config.uiLanguage || 'en';
@@ -490,8 +492,13 @@ class I18nSizingAnalyzer {
490
492
  });
491
493
  this.generateFileComparison();
492
494
 
493
- // Generate recommendations
494
- this.generateRecommendations();
495
+ // Generate recommendations
496
+ this.generateRecommendations();
497
+
498
+ // Generate expansion predictions if requested
499
+ if (this.predictExpansion) {
500
+ this.generateExpansionPredictions();
501
+ }
495
502
  }
496
503
 
497
504
  generateFileComparison() {
@@ -557,6 +564,145 @@ class I18nSizingAnalyzer {
557
564
  this.stats.summary.recommendations = recommendations;
558
565
  }
559
566
 
567
+ // Language pair expansion reference table (average % expansion from English)
568
+ // Values represent typical character count ratio (target/source) minus 1, as percentage
569
+ getExpansionReference() {
570
+ return {
571
+ de: 35, es: 25, fr: 20, it: 15, pt: 20, nl: 30, sv: 15,
572
+ ru: 50, uk: 45, pl: 30, cs: 25, sk: 25, bg: 40, sr: 40,
573
+ ja: -40, zh: -45, ko: -35, th: -30, vi: -20, km: -5,
574
+ ar: 15, he: 10, fa: 20, tr: 10, fi: 25, hu: 20, el: 15,
575
+ da: 10, nb: 10, ro: 15, id: 5, ms: 5, hi: 15, bn: 20,
576
+ ta: 10, te: 15, mr: 20, gu: 20, ml: 25, kn: 15
577
+ };
578
+ }
579
+
580
+ classifyExpansionRisk(ratio) {
581
+ const absRatio = Math.abs(ratio);
582
+ if (absRatio < 30) return { tier: 'safe', label: 'Safe', color: 'green' };
583
+ if (absRatio < 50) return { tier: 'warning', label: 'Warning', color: 'yellow' };
584
+ return { tier: 'critical', label: 'Critical', color: 'red' };
585
+ }
586
+
587
+ generateExpansionPredictions() {
588
+ const languages = Object.keys(this.stats.languages);
589
+ if (languages.length < 2) return;
590
+
591
+ const baseLanguage = this.stats.summary.baseLanguage || this.config.sourceLanguage || 'en';
592
+ const expansionRef = this.getExpansionReference();
593
+ const predictions = {
594
+ baseLanguage,
595
+ perLanguage: {},
596
+ perKey: {},
597
+ topExpandedKeys: [],
598
+ summary: { safe: 0, warning: 0, critical: 0, total: 0 }
599
+ };
600
+
601
+ for (const lang of languages) {
602
+ if (lang === baseLanguage) continue;
603
+
604
+ const baseStats = this.stats.languages[baseLanguage];
605
+ const langStats = this.stats.languages[lang];
606
+ if (!baseStats || !langStats) continue;
607
+
608
+ const actualRatio = baseStats.totalCharacters > 0
609
+ ? ((langStats.totalCharacters - baseStats.totalCharacters) / baseStats.totalCharacters) * 100
610
+ : 0;
611
+
612
+ const referenceRatio = expansionRef[lang] || 10;
613
+ const risk = this.classifyExpansionRisk(actualRatio);
614
+ predictions.perLanguage[lang] = {
615
+ actualExpansionPercent: Math.round(actualRatio * 100) / 100,
616
+ referenceExpansionPercent: referenceRatio,
617
+ riskTier: risk.tier,
618
+ riskLabel: risk.label,
619
+ totalKeys: langStats.totalKeys,
620
+ totalChars: langStats.totalCharacters
621
+ };
622
+ }
623
+
624
+ const keyEntries = [];
625
+ for (const [key, langData] of Object.entries(this.stats.keys)) {
626
+ const baseData = langData[baseLanguage];
627
+ if (!baseData || baseData.length === 0) continue;
628
+
629
+ const keyPredictions = {};
630
+ for (const [lang, data] of Object.entries(langData)) {
631
+ if (lang === baseLanguage) continue;
632
+ const ratio = ((data.length - baseData.length) / baseData.length) * 100;
633
+ const risk = this.classifyExpansionRisk(ratio);
634
+ keyPredictions[lang] = {
635
+ sourceLength: baseData.length,
636
+ targetLength: data.length,
637
+ expansionPercent: Math.round(ratio * 100) / 100,
638
+ riskTier: risk.tier
639
+ };
640
+ predictions.summary[risk.tier]++;
641
+ predictions.summary.total++;
642
+ }
643
+
644
+ const maxExpansion = Math.max(...Object.values(keyPredictions).map(p => Math.abs(p.expansionPercent)));
645
+ keyEntries.push({ key, maxExpansion, predictions: keyPredictions });
646
+ }
647
+
648
+ keyEntries.sort((a, b) => b.maxExpansion - a.maxExpansion);
649
+ predictions.topExpandedKeys = keyEntries.slice(0, 30);
650
+ predictions.perKey = Object.fromEntries(keyEntries.slice(0, 200).map(e => [e.key, e.predictions]));
651
+
652
+ this.expansionPredictions = predictions;
653
+ this.stats.expansionPredictions = predictions;
654
+ }
655
+
656
+ displayExpansionPredictions() {
657
+ if (!this.expansionPredictions) return;
658
+
659
+ const p = this.expansionPredictions;
660
+ console.log('\n' + '='.repeat(60));
661
+ console.log(' EXPANSION PREDICTION ANALYSIS');
662
+ console.log('='.repeat(60));
663
+ console.log(` Base Language: ${p.baseLanguage}`);
664
+ console.log(` Total Key-Language Pairs Analyzed: ${p.summary.total}`);
665
+ console.log(` Safe (<30%): ${p.summary.safe} | Warning (30-50%): ${p.summary.warning} | Critical (>50%): ${p.summary.critical}`);
666
+
667
+ console.log('\n PER-LANGUAGE EXPANSION RATIOS:');
668
+ const langRows = Object.entries(p.perLanguage).map(([lang, data]) => ({
669
+ language: lang,
670
+ actual: `${data.actualExpansionPercent > 0 ? '+' : ''}${data.actualExpansionPercent}%`,
671
+ reference: `${data.referenceExpansionPercent > 0 ? '+' : ''}${data.referenceExpansionPercent}%`,
672
+ risk: data.riskLabel
673
+ }));
674
+ console.log(this.createTable([
675
+ { key: 'language', label: 'Language' },
676
+ { key: 'actual', label: 'Actual', align: 'right' },
677
+ { key: 'reference', label: 'Reference', align: 'right' },
678
+ { key: 'risk', label: 'Risk Tier' }
679
+ ], langRows));
680
+
681
+ if (p.topExpandedKeys.length > 0) {
682
+ console.log('\n TOP EXPANDED KEYS (highest risk of UI overflow):');
683
+ const keyRows = p.topExpandedKeys.slice(0, 15).map(entry => {
684
+ const langs = Object.entries(entry.predictions).map(([l, d]) =>
685
+ `${l}:${d.expansionPercent > 0 ? '+' : ''}${d.expansionPercent}%`
686
+ ).join(' ');
687
+ return { key: entry.key, maxExp: `${entry.maxExpansion > 0 ? '+' : ''}${Math.round(entry.maxExpansion)}%`, languages: langs };
688
+ });
689
+ console.log(this.createTable([
690
+ { key: 'key', label: 'Key' },
691
+ { key: 'maxExp', label: 'Max Exp', align: 'right' },
692
+ { key: 'languages', label: 'Per-Language' }
693
+ ], keyRows));
694
+ }
695
+
696
+ console.log('\n RECOMMENDATIONS:');
697
+ if (p.summary.critical > 0) {
698
+ console.log(` - ${p.summary.critical} key-language pairs have >50% expansion — review UI layouts for truncation risk`);
699
+ }
700
+ if (p.summary.warning > 0) {
701
+ console.log(` - ${p.summary.warning} key-language pairs have 30-50% expansion — test on target languages`);
702
+ }
703
+ console.log(' - Use the reference expansion ratios to plan UI element sizing for unsupported languages');
704
+ }
705
+
560
706
  // Display concise folder-level results
561
707
  displayFolderResults() {
562
708
  console.log("\n" + t("sizing.sizing_analysis_results"));
@@ -616,9 +762,13 @@ class I18nSizingAnalyzer {
616
762
  reportPath: this.outputDir
617
763
  }));
618
764
 
619
- if (this.detailedKeys) {
620
- this.displayDetailedKeys();
621
- }
765
+ if (this.detailedKeys) {
766
+ this.displayDetailedKeys();
767
+ }
768
+
769
+ if (this.predictExpansion && this.expansionPredictions) {
770
+ this.displayExpansionPredictions();
771
+ }
622
772
  }
623
773
 
624
774
  displayFileComparison() {
@@ -874,17 +1024,50 @@ Generated: ${new Date().toISOString()}
874
1024
  report += `
875
1025
  ### ${key}
876
1026
  `;
877
- Object.entries(data).forEach(([lang, keyData]) => {
878
- const length = keyData.length;
879
- const isEmpty = length === 0;
880
- const isLong = length > this.threshold;
881
- const status = isEmpty ? 'EMPTY' : isLong ? 'LONG' : 'OK';
882
- report += `- ${lang}: ${length} chars [${status}]\n`;
883
- });
1027
+ Object.entries(data).forEach(([lang, keyData]) => {
1028
+ const length = keyData.length;
1029
+ const isEmpty = length === 0;
1030
+ const isLong = length > this.threshold;
1031
+ const status = isEmpty ? 'EMPTY' : isLong ? 'LONG' : 'OK';
1032
+ report += `- ${lang}: ${length} chars [${status}]\n`;
1033
+ });
1034
+ });
1035
+ }
1036
+
1037
+ // Expansion predictions
1038
+ if (this.expansionPredictions) {
1039
+ const ep = this.expansionPredictions;
1040
+ report += `
1041
+ ## Expansion Prediction Analysis
1042
+
1043
+ ### Summary
1044
+ - Base Language: ${ep.baseLanguage}
1045
+ - Safe (<30%): ${ep.summary.safe}
1046
+ - Warning (30-50%): ${ep.summary.warning}
1047
+ - Critical (>50%): ${ep.summary.critical}
1048
+ - Total Pairs: ${ep.summary.total}
1049
+
1050
+ ### Per-Language Ratios
1051
+ `;
1052
+ Object.entries(ep.perLanguage).forEach(([lang, data]) => {
1053
+ report += `- ${lang}: ${data.actualExpansionPercent > 0 ? '+' : ''}${data.actualExpansionPercent}% (ref: ${data.referenceExpansionPercent > 0 ? '+' : ''}${data.referenceExpansionPercent}%) [${data.riskLabel}]\n`;
884
1054
  });
1055
+
1056
+ if (ep.topExpandedKeys.length > 0) {
1057
+ report += `
1058
+ ### Top Expanded Keys
1059
+ `;
1060
+ ep.topExpandedKeys.slice(0, 30).forEach((entry, idx) => {
1061
+ report += `${idx + 1}. ${entry.key} (max: ${entry.maxExpansion > 0 ? '+' : ''}${Math.round(entry.maxExpansion)}%)\n`;
1062
+ Object.entries(entry.predictions).forEach(([l, d]) => {
1063
+ report += ` ${l}: ${d.sourceLength} → ${d.targetLength} chars (${d.expansionPercent > 0 ? '+' : ''}${d.expansionPercent}%) [${d.riskTier}]\n`;
1064
+ });
1065
+ report += '\n';
1066
+ });
1067
+ }
885
1068
  }
886
1069
 
887
- return report;
1070
+ return report;
888
1071
  }
889
1072
 
890
1073
  // Generate CSV report
@@ -1018,6 +1201,8 @@ Generated: ${new Date().toISOString()}
1018
1201
  options.d = options.detailed;
1019
1202
  } else if (key === 'detailed-keys') {
1020
1203
  options['detailed-keys'] = value.toLowerCase() !== 'false';
1204
+ } else if (key === 'predict-expansion') {
1205
+ options['predict-expansion'] = value.toLowerCase() !== 'false';
1021
1206
  } else if (key === 'output-dir') {
1022
1207
  options['output-dir'] = value;
1023
1208
  }
@@ -1073,17 +1258,24 @@ Generated: ${new Date().toISOString()}
1073
1258
  options.detailed = true;
1074
1259
  options.d = true;
1075
1260
  }
1076
- } else if (key === 'detailed-keys') {
1077
- if (nextArg && !nextArg.startsWith('-') && ['true', 'false'].includes(nextArg.toLowerCase())) {
1078
- options['detailed-keys'] = nextArg.toLowerCase() !== 'false';
1079
- i++;
1080
- } else {
1081
- options['detailed-keys'] = true;
1082
- }
1083
- } else if (key === 'output-dir') {
1084
- options['output-dir'] = nextArg || options['output-dir'];
1085
- if (nextArg && !nextArg.startsWith('-')) i++;
1086
- }
1261
+ } else if (key === 'detailed-keys') {
1262
+ if (nextArg && !nextArg.startsWith('-') && ['true', 'false'].includes(nextArg.toLowerCase())) {
1263
+ options['detailed-keys'] = nextArg.toLowerCase() !== 'false';
1264
+ i++;
1265
+ } else {
1266
+ options['detailed-keys'] = true;
1267
+ }
1268
+ } else if (key === 'predict-expansion') {
1269
+ if (nextArg && !nextArg.startsWith('-') && ['true', 'false'].includes(nextArg.toLowerCase())) {
1270
+ options['predict-expansion'] = nextArg.toLowerCase() !== 'false';
1271
+ i++;
1272
+ } else {
1273
+ options['predict-expansion'] = true;
1274
+ }
1275
+ } else if (key === 'output-dir') {
1276
+ options['output-dir'] = nextArg || options['output-dir'];
1277
+ if (nextArg && !nextArg.startsWith('-')) i++;
1278
+ }
1087
1279
  }
1088
1280
  }
1089
1281
 
@@ -1102,6 +1294,7 @@ Options:
1102
1294
  --source-language <code> Source language baseline for comparisons (default: en)
1103
1295
  -d, --detailed Generate detailed report with more information
1104
1296
  --detailed-keys Show detailed key-level analysis
1297
+ --predict-expansion Predict UI layout expansion risk per language
1105
1298
  --output-dir <dir> Output directory for reports (default: ./i18ntk-reports)
1106
1299
  --help Show this help message
1107
1300
  `);
@@ -1124,8 +1317,9 @@ Options:
1124
1317
  this.languages = args.languages ? args.languages.split(',').map(l => l.trim()) : [];
1125
1318
  this.outputReport = args['output-report'] !== undefined ? args['output-report'] : false;
1126
1319
  this.format = args.format || 'table';
1127
- this.detailed = args.detailed;
1128
- this.detailedKeys = args['detailed-keys'];
1320
+ this.detailed = args.detailed;
1321
+ this.detailedKeys = args['detailed-keys'];
1322
+ this.predictExpansion = args['predict-expansion'] || false;
1129
1323
  this.sourceLanguage = args['source-language'] || config.sourceLanguage || this.sourceLanguage || 'en';
1130
1324
 
1131
1325
  if (!fromMenu) {
@@ -15,6 +15,7 @@
15
15
  * Options:
16
16
  * --source-dir <dir> Source directory (default: ./locales/en)
17
17
  * --output-dir <dir> Output directory (default: ./locales/<lang>)
18
+ * --provider <name> Translation provider: google, deepl, libretranslate
18
19
  * --custom-regex <regex> Additional placeholder regex pattern
19
20
  * --no-confirm Skip all confirmation dialogs
20
21
  * --preserve-placeholders Translate text around placeholders and reinsert tokens
@@ -90,6 +91,7 @@ function printHelp() {
90
91
  'Options:',
91
92
  ' --source-dir <dir> Source directory containing locale files',
92
93
  ' --output-dir <dir> Output directory for translated files',
94
+ ' --provider <name> Provider: google (default), deepl, libretranslate',
93
95
  ' --source-lang <code> Source language code (default: en)',
94
96
  ' --custom-regex <regex> Additional placeholder regex pattern',
95
97
  ' --no-confirm Automate: skip confirmation dialogs',
@@ -110,6 +112,15 @@ function printHelp() {
110
112
  ' --retry-count <n> Max retries per failed request (default: 3)',
111
113
  ' --retry-delay <ms> Base backoff delay in ms (default: 1000)',
112
114
  ' --timeout <ms> HTTP request timeout in ms (default: 15000)',
115
+ '',
116
+ 'Environment:',
117
+ ' I18NTK_TRANSLATE_PROVIDER Default provider when --provider is omitted',
118
+ ' DEEPL_API_KEY Required for --provider deepl',
119
+ ' DEEPL_API_URL Optional, defaults to https://api-free.deepl.com/v2/translate',
120
+ ' I18NTK_ALLOW_CUSTOM_TRANSLATE_HOSTS=1 Allow custom DeepL-compatible HTTPS hosts',
121
+ ' LIBRETRANSLATE_URL Optional, defaults to https://libretranslate.com/translate',
122
+ ' LIBRETRANSLATE_API_KEY Optional API key for LibreTranslate servers that require one',
123
+ ' I18NTK_ALLOW_PRIVATE_TRANSLATE_URLS=1 Allow localhost/private provider URLs for trusted testing',
113
124
  ' -h, --help Show this help',
114
125
  ].join('\n'));
115
126
  }
@@ -121,6 +132,7 @@ function parseArgs(argv) {
121
132
  sourceDir: null,
122
133
  outputDir: null,
123
134
  sourceLang: 'en',
135
+ provider: process.env.I18NTK_TRANSLATE_PROVIDER || 'google',
124
136
  customRegex: [],
125
137
  noConfirm: false,
126
138
  preservePlaceholders: false,
@@ -161,6 +173,7 @@ function parseArgs(argv) {
161
173
  else if (arg === '--source-dir' && i + 1 < argv.length) { args.sourceDir = argv[++i]; }
162
174
  else if (arg === '--output-dir' && i + 1 < argv.length) { args.outputDir = argv[++i]; }
163
175
  else if (arg === '--source-lang' && i + 1 < argv.length) { args.sourceLang = argv[++i]; }
176
+ else if (arg === '--provider' && i + 1 < argv.length) { args.provider = argv[++i]; }
164
177
  else if (arg === '--custom-regex' && i + 1 < argv.length) { args.customRegex.push(argv[++i]); }
165
178
  else if (arg === '--protection-file' && i + 1 < argv.length) { args.protectionFile = argv[++i]; }
166
179
  else if (arg === '--concurrency' && i + 1 < argv.length) { args.concurrency = parseInt(argv[++i], 10) || 3; }
@@ -206,7 +219,17 @@ function loadCustomTranslateFn(modulePath) {
206
219
  if (!modulePath) return null;
207
220
  try {
208
221
  const resolved = path.isAbsolute(modulePath) ? modulePath : path.resolve(process.cwd(), modulePath);
209
- const mod = require(resolved);
222
+ const validated = SecurityUtils.validatePath(resolved);
223
+ if (!validated) {
224
+ SecurityUtils.logSecurityEvent('Blocked unsafe custom translate module path', 'warn', {
225
+ modulePath,
226
+ resolved,
227
+ source: 'user'
228
+ });
229
+ console.error(`Error: Custom translate module path "${modulePath}" failed security validation.`);
230
+ process.exit(1);
231
+ }
232
+ const mod = require(validated);
210
233
  if (typeof mod === 'function') return mod;
211
234
  if (mod && typeof mod.translate === 'function') return mod.translate;
212
235
  if (mod && typeof mod.default === 'function') return mod.default;
@@ -676,6 +699,7 @@ async function processFile(sourcePath, targetLang, args) {
676
699
 
677
700
  const translateOptions = {
678
701
  sourceLang: args.sourceLang,
702
+ provider: args.provider,
679
703
  concurrency: args.concurrency,
680
704
  batchSize: args.batchSize,
681
705
  retryCount: args.retryCount,
@@ -84,6 +84,11 @@ class I18nUsageAnalyzer {
84
84
  this.startTime = Date.now(); // Track performance metrics
85
85
  this.version = '1.10.1'; // Version tracking
86
86
 
87
+ // Dead key detection properties
88
+ this.deadKeys = new Map();
89
+ this.cleanupMode = false;
90
+ this.dryRunDelete = false;
91
+
87
92
  // Use global translation function
88
93
  this.rl = null;
89
94
  }
@@ -187,7 +192,9 @@ class I18nUsageAnalyzer {
187
192
  help: a.help || a.h,
188
193
  noPrompt: a.noPrompt ?? a['no-prompt'],
189
194
  strict: a.strict,
190
- debug: a.debug
195
+ debug: a.debug,
196
+ cleanup: a.cleanup ?? a['cleanup'],
197
+ dryRunDelete: a.dryRunDelete ?? a['dry-run-delete']
191
198
  };
192
199
  }
193
200
 
@@ -376,6 +383,13 @@ class I18nUsageAnalyzer {
376
383
  console.log('🔍 Debug mode enabled');
377
384
  }
378
385
 
386
+ if (args.cleanup) {
387
+ this.cleanupMode = true;
388
+ }
389
+ if (args.dryRunDelete) {
390
+ this.dryRunDelete = true;
391
+ }
392
+
379
393
  try {
380
394
  // Ensure config is always initialized
381
395
  if (!this.config) {
@@ -599,6 +613,33 @@ class I18nUsageAnalyzer {
599
613
  }));
600
614
  }
601
615
 
616
+ if (this.cleanupMode) {
617
+ const deadKeys = this.findDeadKeys();
618
+ console.log('\n' + t('usage.deadKeysDetectionTitle'));
619
+ console.log(t('usage.deadKeysCount', { count: deadKeys.length }));
620
+
621
+ const highConfidence = deadKeys.filter(dk => dk.confidence >= 0.8).length;
622
+ const mediumConfidence = deadKeys.filter(dk => dk.confidence >= 0.4 && dk.confidence < 0.8).length;
623
+ const lowConfidence = deadKeys.filter(dk => dk.confidence < 0.4).length;
624
+
625
+ console.log(t('usage.deadKeysConfidenceBreakdown', { high: highConfidence, medium: mediumConfidence, low: lowConfidence }));
626
+
627
+ if (deadKeys.length > 0) {
628
+ console.log('\n' + t('usage.deadKeysSample'));
629
+ deadKeys.slice(0, 10).forEach(dk => {
630
+ console.log(` ${dk.key} [${(dk.confidence * 100).toFixed(0)}%] - ${dk.reason}`);
631
+ });
632
+ if (deadKeys.length > 10) {
633
+ console.log(t('usage.deadKeysMore', { count: deadKeys.length - 10 }));
634
+ }
635
+ }
636
+
637
+ if (this.dryRunDelete) {
638
+ const reportPath = this.saveDeadKeysReport(deadKeys, args.outputDir || this.config.outputDir || './i18ntk-reports/usage');
639
+ console.log(t('usage.deadKeysReportSaved', { path: reportPath }));
640
+ }
641
+ }
642
+
602
643
  if (args.outputReport) {
603
644
  const report = this.generateUsageReport();
604
645
  await this.saveReport(report, args.outputDir);
@@ -625,7 +666,7 @@ class I18nUsageAnalyzer {
625
666
  // Show help message
626
667
  showHelp() {
627
668
  console.log(`
628
- 📊 i18ntk usage - Translation key usage analysis (v1.8.3)
669
+ 📊 i18ntk usage - Translation key usage analysis (v1.10.1)
629
670
 
630
671
  Usage:
631
672
  node i18ntk-usage.js [options]
@@ -642,14 +683,17 @@ Options:
642
683
  --validate-placeholders Enable placeholder key validation
643
684
  --framework-detect Enable framework-specific pattern detection
644
685
  --performance-mode Enable performance metrics tracking
686
+ --cleanup Enable dead key detection for cleanup mode
687
+ --dry-run-delete Save dead keys report without deleting (requires --cleanup)
645
688
  --help, -h Show this help message
646
689
 
647
690
  Examples:
648
691
  node i18ntk-usage.js --source-dir=./src --i18n-dir=./translations --output-report
649
692
  npm run i18ntk:usage -- --strict --debug --validate-placeholders
650
693
  node i18ntk-usage.js --no-prompt --performance-mode --output-dir=./reports
694
+ node i18ntk-usage.js --cleanup --dry-run-delete
651
695
 
652
- Analysis Features (v1.8.3):
696
+ Analysis Features (v1.10.1):
653
697
  • Detects unused translation keys
654
698
  • Identifies missing translation keys
655
699
  • Shows translation completeness by language
@@ -662,6 +706,7 @@ Analysis Features (v1.8.3):
662
706
  • Key complexity analysis
663
707
  • Security-enhanced path validation
664
708
  • Detailed reporting with validation errors
709
+ • Dead key detection with confidence scoring
665
710
  `);
666
711
  }
667
712
 
@@ -1133,6 +1178,161 @@ Analysis Features (v1.8.3):
1133
1178
  return missing;
1134
1179
  }
1135
1180
 
1181
+ findDeadKeys() {
1182
+ const unusedKeys = this.findUnusedKeys();
1183
+ const deadKeys = [];
1184
+
1185
+ for (const key of unusedKeys) {
1186
+ let confidence = 0.9;
1187
+ let reason = 'Key not found in any source file';
1188
+
1189
+ if (this._matchesDynamicPattern(key)) {
1190
+ confidence = 0.3;
1191
+ reason = 'Key matches dynamic template pattern (likely used)';
1192
+ } else if (this._keyInSourceComments(key)) {
1193
+ confidence = 0.5;
1194
+ reason = 'Key referenced in comments/JSDoc';
1195
+ } else if (this._parentFileRecentlyModified(key)) {
1196
+ confidence = 0.4;
1197
+ reason = 'Translation file modified within last 30 days';
1198
+ }
1199
+
1200
+ deadKeys.push({ key, confidence, reason });
1201
+ }
1202
+
1203
+ deadKeys.sort((a, b) => b.confidence - a.confidence);
1204
+ return deadKeys;
1205
+ }
1206
+
1207
+ _matchesDynamicPattern(key) {
1208
+ const keyParts = key.split('.');
1209
+ if (keyParts.length < 2) return false;
1210
+
1211
+ const dynamicPatterns = [
1212
+ /t\(`[^`]*\$\{[^}]*\}[^`]*`\)/g,
1213
+ /i18n\.t\(`[^`]*\$\{[^}]*\}[^`]*`\)/g,
1214
+ /useTranslation\(\)\.t\(`[^`]*\$\{[^}]*\}[^`]*`\)/g
1215
+ ];
1216
+
1217
+ try {
1218
+ const sourceFiles = Array.from(this.fileUsage.keys());
1219
+ for (const filePath of sourceFiles) {
1220
+ const fullPath = path.join(this.sourceDir, filePath);
1221
+ if (!SecurityUtils.safeExistsSync(fullPath, this.sourceDir)) continue;
1222
+
1223
+ const content = SecurityUtils.safeReadFileSync(fullPath, this.sourceDir, 'utf8');
1224
+ if (!content) continue;
1225
+
1226
+ for (const pattern of dynamicPatterns) {
1227
+ const matches = content.match(pattern);
1228
+ if (matches) {
1229
+ for (const match of matches) {
1230
+ const matchLower = match.toLowerCase();
1231
+ if (keyParts.some(part => matchLower.includes(part.toLowerCase()))) {
1232
+ return true;
1233
+ }
1234
+ }
1235
+ }
1236
+ }
1237
+ }
1238
+ } catch (e) {
1239
+ // Silently fail - dynamic pattern detection is best-effort
1240
+ }
1241
+
1242
+ return false;
1243
+ }
1244
+
1245
+ _keyInSourceComments(key) {
1246
+ const commentPatterns = [
1247
+ /\/\/[^\n]*/g,
1248
+ /\/\*[\s\S]*?\*\//g,
1249
+ /\/\*\*[\s\S]*?\*\//g
1250
+ ];
1251
+
1252
+ try {
1253
+ const sourceFiles = Array.from(this.fileUsage.keys());
1254
+ for (const filePath of sourceFiles) {
1255
+ const fullPath = path.join(this.sourceDir, filePath);
1256
+ if (!SecurityUtils.safeExistsSync(fullPath, this.sourceDir)) continue;
1257
+
1258
+ const content = SecurityUtils.safeReadFileSync(fullPath, this.sourceDir, 'utf8');
1259
+ if (!content) continue;
1260
+
1261
+ for (const pattern of commentPatterns) {
1262
+ const comments = content.match(pattern);
1263
+ if (comments) {
1264
+ for (const comment of comments) {
1265
+ if (comment.includes(key)) {
1266
+ return true;
1267
+ }
1268
+ }
1269
+ }
1270
+ }
1271
+ }
1272
+ } catch (e) {
1273
+ // Silently fail - comment detection is best-effort
1274
+ }
1275
+
1276
+ return false;
1277
+ }
1278
+
1279
+ _parentFileRecentlyModified(key) {
1280
+ const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
1281
+ const now = Date.now();
1282
+
1283
+ try {
1284
+ for (const [filePath] of this.translationFiles) {
1285
+ if (!SecurityUtils.safeExistsSync(filePath, this.i18nDir)) continue;
1286
+
1287
+ const content = SecurityUtils.safeReadFileSync(filePath, this.i18nDir, 'utf8');
1288
+ if (!content) continue;
1289
+
1290
+ if (content.includes(key)) {
1291
+ const stats = SecurityUtils.safeStatSync(filePath, this.i18nDir);
1292
+ if (stats && stats.mtime) {
1293
+ const mtimeMs = new Date(stats.mtime).getTime();
1294
+ if ((now - mtimeMs) <= thirtyDaysMs) {
1295
+ return true;
1296
+ }
1297
+ }
1298
+ }
1299
+ }
1300
+ } catch (e) {
1301
+ // Silently fail - file stat is best-effort
1302
+ }
1303
+
1304
+ return false;
1305
+ }
1306
+
1307
+ generateDeadKeysReport(deadKeys) {
1308
+ const allCleanupReady = deadKeys.length === 0 || deadKeys.every(dk => dk.confidence >= 0.8);
1309
+
1310
+ return {
1311
+ deadKeys,
1312
+ totalAvailableKeys: this.availableKeys.size,
1313
+ totalUsedKeys: this.usedKeys.size,
1314
+ deadKeyCount: deadKeys.length,
1315
+ cleanupReady: allCleanupReady,
1316
+ generatedAt: new Date().toISOString(),
1317
+ version: this.version
1318
+ };
1319
+ }
1320
+
1321
+ saveDeadKeysReport(deadKeys, outputDir) {
1322
+ const report = this.generateDeadKeysReport(deadKeys);
1323
+ const resolvedDir = path.resolve(outputDir || './i18ntk-reports/usage');
1324
+
1325
+ if (!SecurityUtils.safeExistsSync(resolvedDir, process.cwd())) {
1326
+ SecurityUtils.safeMkdirSync(resolvedDir, process.cwd(), { recursive: true });
1327
+ }
1328
+
1329
+ const filename = '.dead-keys.json';
1330
+ const filepath = path.join(resolvedDir, filename);
1331
+
1332
+ SecurityUtils.safeWriteFileSync(filepath, JSON.stringify(report, null, 2), resolvedDir, 'utf8');
1333
+ return filepath;
1334
+ }
1335
+
1136
1336
  // Find files that use specific keys
1137
1337
  findKeyUsage(searchKey) {
1138
1338
  const usage = [];