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.
- package/CHANGELOG.md +62 -2
- package/README.md +177 -22
- package/SECURITY.md +28 -8
- package/main/i18ntk-backup.js +305 -62
- package/main/i18ntk-complete.js +120 -49
- package/main/i18ntk-scanner.js +188 -49
- package/main/i18ntk-sizing.js +223 -29
- package/main/i18ntk-translate.js +25 -1
- package/main/i18ntk-usage.js +203 -3
- package/main/i18ntk-validate.js +107 -3
- package/main/manage/commands/FixerCommand.js +23 -21
- package/main/manage/index.js +13 -7
- package/main/manage/services/FileManagementService.js +12 -6
- package/package.json +3 -3
- package/runtime/i18ntk.d.ts +22 -16
- package/runtime/index.d.ts +9 -7
- package/runtime/index.js +240 -50
- package/ui-locales/de.json +1389 -1359
- package/ui-locales/en.json +1 -1
- package/ui-locales/es.json +1503 -1473
- package/ui-locales/fr.json +1626 -1596
- package/ui-locales/ja.json +1595 -1565
- package/ui-locales/ru.json +1638 -1608
- package/ui-locales/zh.json +1613 -1583
- package/utils/translate/api.js +164 -41
- package/utils/translate/protection.js +147 -6
- package/utils/translate/safe-network.js +280 -0
- package/utils/watch-locales.js +183 -36
package/main/i18ntk-sizing.js
CHANGED
|
@@ -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.
|
|
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 === '
|
|
1084
|
-
|
|
1085
|
-
|
|
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) {
|
package/main/i18ntk-translate.js
CHANGED
|
@@ -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
|
|
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,
|
package/main/i18ntk-usage.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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 = [];
|