i18ntk 2.6.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.
@@ -64,11 +64,12 @@ function getConfig() {
64
64
  projectRoot: settings.projectRoot || '.',
65
65
  sourceDir: sourceDir,
66
66
  i18nDir: settings.i18nDir || settings.sourceDir || './locales',
67
- outputDir: settings.outputDir || './i18ntk-reports',
68
- threshold: settings.processing?.sizingThreshold || 50,
69
- uiLanguage: settings.language || 'en'
70
- };
71
- }
67
+ outputDir: settings.outputDir || './i18ntk-reports',
68
+ threshold: settings.processing?.sizingThreshold || 50,
69
+ uiLanguage: settings.language || 'en',
70
+ sourceLanguage: settings.sourceLanguage || 'en'
71
+ };
72
+ }
72
73
 
73
74
  class I18nSizingAnalyzer {
74
75
  constructor(options = {}) {
@@ -77,10 +78,13 @@ class I18nSizingAnalyzer {
77
78
  this.sourceDir = path.resolve(projectRoot, options.sourceDir || config.sourceDir);
78
79
  this.outputDir = path.resolve(projectRoot, options.outputDir || config.outputDir);
79
80
  this.languages = options.languages || [];
80
- this.threshold = options.threshold || config.threshold; // Size difference threshold in percentage
81
- this.format = options.format || 'table';
82
- this.outputReport = options.outputReport || false;
83
- this.rl = null;
81
+ this.threshold = options.threshold || config.threshold; // Size difference threshold in percentage
82
+ this.format = options.format || 'table';
83
+ this.outputReport = options.outputReport || false;
84
+ this.sourceLanguage = options.sourceLanguage || config.sourceLanguage || 'en';
85
+ this.detailed = options.detailed || false;
86
+ this.detailedKeys = options.detailedKeys || false;
87
+ this.rl = null;
84
88
 
85
89
  // Initialize i18n with UI language from config
86
90
  const uiLanguage = options.uiLanguage || config.uiLanguage || 'en';
@@ -109,9 +113,9 @@ class I18nSizingAnalyzer {
109
113
  closeGlobalReadline();
110
114
  }
111
115
 
112
- // Get available language files
113
- getLanguageFiles() {
114
- const validatedSourceDir = SecurityUtils.validatePath(this.sourceDir, process.cwd());
116
+ // Get available language files
117
+ getLanguageFiles() {
118
+ const validatedSourceDir = SecurityUtils.validatePath(this.sourceDir, process.cwd());
115
119
  if (!validatedSourceDir) {
116
120
  throw new Error(t("sizing.invalidSourceDirectoryError", { sourceDir: this.sourceDir }));
117
121
  }
@@ -132,11 +136,8 @@ class I18nSizingAnalyzer {
132
136
  if (!stat) continue;
133
137
 
134
138
  if (stat.isDirectory()) {
135
- // This is a language directory, combine all JSON files
136
- const langFiles = SecurityUtils.safeReaddirSync(itemPath)
137
- .filter(file => file.endsWith('.json'))
138
- .map(file => SecurityUtils.validatePath(path.join(itemPath, file), process.cwd()))
139
- .filter(file => file !== null);
139
+ // This is a language directory, combine all JSON files
140
+ const langFiles = this.collectJsonFiles(itemPath);
140
141
 
141
142
  if (langFiles.length > 0) {
142
143
  files.push({
@@ -149,60 +150,130 @@ class I18nSizingAnalyzer {
149
150
  } else if (item.endsWith('.json')) {
150
151
  // Direct JSON file in root
151
152
  const lang = path.basename(item, '.json');
152
- files.push({
153
- language: lang,
154
- file: item,
155
- path: itemPath
156
- });
157
- }
158
- }
153
+ files.push({
154
+ language: lang,
155
+ file: item,
156
+ path: itemPath,
157
+ files: [itemPath],
158
+ isSingleFile: true
159
+ });
160
+ }
161
+ }
159
162
 
160
163
  if (this.languages.length > 0) {
161
- return files.filter(f => this.languages.includes(f.language));
162
- }
163
-
164
- return files;
165
- }
164
+ return files
165
+ .filter(f => this.languages.includes(f.language))
166
+ .sort((a, b) => a.language.localeCompare(b.language));
167
+ }
168
+
169
+ return files.sort((a, b) => a.language.localeCompare(b.language));
170
+ }
171
+
172
+ collectJsonFiles(dirPath) {
173
+ const jsonFiles = [];
174
+ const entries = SecurityUtils.safeReaddirSync(dirPath, process.cwd(), { withFileTypes: true });
175
+
176
+ entries.forEach(entry => {
177
+ const entryPath = SecurityUtils.validatePath(path.join(dirPath, entry.name), process.cwd());
178
+ if (!entryPath) return;
179
+
180
+ if (entry.isDirectory()) {
181
+ jsonFiles.push(...this.collectJsonFiles(entryPath));
182
+ } else if (entry.isFile() && entry.name.endsWith('.json')) {
183
+ jsonFiles.push(entryPath);
184
+ }
185
+ });
186
+
187
+ return jsonFiles.sort((a, b) => this.getFileSortName(dirPath, a).localeCompare(this.getFileSortName(dirPath, b)));
188
+ }
189
+
190
+ getLanguageFileName(languageEntry, filePath) {
191
+ if (languageEntry.isSingleFile) {
192
+ return path.basename(filePath);
193
+ }
194
+
195
+ return this.getFileSortName(languageEntry.path, filePath);
196
+ }
197
+
198
+ getFileSortName(basePath, filePath) {
199
+ return path.relative(basePath, filePath).replace(/\\/g, '/');
200
+ }
201
+
202
+ createTable(columns, rows) {
203
+ const widths = columns.map(column => {
204
+ return Math.max(
205
+ column.label.length,
206
+ ...rows.map(row => String(row[column.key] ?? '').length)
207
+ );
208
+ });
209
+
210
+ const formatCell = (value, width, align = 'left') => {
211
+ const text = String(value ?? '');
212
+ return align === 'right' ? text.padStart(width) : text.padEnd(width);
213
+ };
214
+
215
+ const header = columns.map((column, index) => formatCell(column.label, widths[index], column.align)).join(' ');
216
+ const separator = widths.map(width => '-'.repeat(width)).join(' ');
217
+ const body = rows.map(row => columns
218
+ .map((column, index) => formatCell(row[column.key], widths[index], column.align))
219
+ .join(' '));
220
+
221
+ return [header, separator, ...body].join('\n');
222
+ }
166
223
 
167
224
  // Analyze file sizes
168
225
  analyzeFileSizes(files) {
169
226
  logger.info(t("sizing.analyzing_file_sizes"));
170
227
 
171
- files.forEach(({ language, file, path: filePath, files: langFiles }) => {
172
- if (langFiles) {
173
- // Handle nested directory structure
174
- let totalSize = 0;
175
- let totalLines = 0;
176
- let totalCharacters = 0;
177
- let lastModified = new Date(0);
178
-
179
- langFiles.forEach(langFile => {
180
- const stats = SecurityUtils.safeStatSync(langFile, process.cwd());
181
- if (!stats) return;
182
-
228
+ files.forEach(languageEntry => {
229
+ const { language, file, path: filePath, files: langFiles } = languageEntry;
230
+ if (langFiles) {
231
+ // Handle nested directory structure
232
+ let totalSize = 0;
233
+ let totalLines = 0;
234
+ let totalCharacters = 0;
235
+ let lastModified = new Date(0);
236
+ const perFiles = {};
237
+
238
+ langFiles.forEach(langFile => {
239
+ const stats = SecurityUtils.safeStatSync(langFile, process.cwd());
240
+ if (!stats) return;
241
+
183
242
  let content = SecurityUtils.safeReadFileSync(langFile, process.cwd(), 'utf8');
184
243
  if (typeof content !== "string") content = "";
185
- totalSize += stats.size;
186
- totalLines += content.split('\n').length;
187
- totalCharacters += content.length;
188
- if (stats.mtime > lastModified) {
189
- lastModified = stats.mtime;
190
- }
191
- });
192
-
193
- this.stats.files[language] = {
194
- file,
195
- size: totalSize,
244
+ totalSize += stats.size;
245
+ totalLines += content.split('\n').length;
246
+ totalCharacters += content.length;
247
+ if (stats.mtime > lastModified) {
248
+ lastModified = stats.mtime;
249
+ }
250
+
251
+ const relativeName = this.getLanguageFileName(languageEntry, langFile);
252
+ perFiles[relativeName] = {
253
+ file: relativeName,
254
+ path: langFile,
255
+ size: stats.size,
256
+ sizeKB: (stats.size / 1024).toFixed(2),
257
+ lines: content.split('\n').length,
258
+ characters: content.length,
259
+ lastModified: stats.mtime
260
+ };
261
+ });
262
+
263
+ this.stats.files[language] = {
264
+ file,
265
+ size: totalSize,
196
266
  sizeKB: (totalSize / 1024).toFixed(2),
197
267
  lines: totalLines,
198
- characters: totalCharacters,
199
- lastModified: lastModified,
200
- fileCount: langFiles.length
201
- };
202
- } else {
203
- // Handle single file structure
204
- const stats = SecurityUtils.safeStatSync(filePath, process.cwd());
205
- if (!stats) return;
268
+ characters: totalCharacters,
269
+ lastModified: lastModified,
270
+ fileCount: Object.keys(perFiles).length,
271
+ perFiles
272
+ };
273
+ } else {
274
+ // Handle single file structure
275
+ const stats = SecurityUtils.safeStatSync(filePath, process.cwd());
276
+ if (!stats) return;
206
277
 
207
278
  let content = SecurityUtils.safeReadFileSync(filePath, process.cwd(), 'utf8');
208
279
  if (typeof content !== "string") content = "";
@@ -211,37 +282,72 @@ class I18nSizingAnalyzer {
211
282
  size: stats.size,
212
283
  sizeKB: (stats.size / 1024).toFixed(2),
213
284
  lines: content.split('\n').length,
214
- characters: content.length,
215
- lastModified: stats.mtime,
216
- fileCount: 1
217
- };
218
- }
219
- });
220
- }
285
+ characters: content.length,
286
+ lastModified: stats.mtime,
287
+ fileCount: 1,
288
+ perFiles: {
289
+ [file]: {
290
+ file,
291
+ path: filePath,
292
+ size: stats.size,
293
+ sizeKB: (stats.size / 1024).toFixed(2),
294
+ lines: content.split('\n').length,
295
+ characters: content.length,
296
+ lastModified: stats.mtime
297
+ }
298
+ }
299
+ };
300
+ }
301
+ });
302
+ }
221
303
 
222
304
  // Analyze translation content
223
305
  analyzeTranslationContent(files) {
224
306
  logger.info(t("sizing.analyzing_translation_content"));
225
307
 
226
- files.forEach(({ language, path: filePath, files: langFiles }) => {
227
- try {
228
- let combinedContent = {};
229
-
230
- if (langFiles) {
231
- // Handle nested directory structure - combine all JSON files
232
- langFiles.forEach(langFile => {
233
- const rawContent = SecurityUtils.safeReadFileSync(langFile, process.cwd(), 'utf8');
234
- const fileContent = SecurityUtils.safeParseJSON(rawContent);
235
- if (fileContent) {
236
- const fileName = path.basename(langFile, '.json');
237
- combinedContent[fileName] = fileContent;
238
- }
239
- });
240
- } else {
241
- // Handle single file structure
242
- const rawContent = SecurityUtils.safeReadFileSync(filePath, process.cwd(), 'utf8');
243
- combinedContent = SecurityUtils.safeParseJSON(rawContent) || {};
244
- }
308
+ files.forEach(languageEntry => {
309
+ const { language, path: filePath, files: langFiles } = languageEntry;
310
+ try {
311
+ let combinedContent = {};
312
+ const fileAnalyses = {};
313
+
314
+ if (langFiles && !languageEntry.isSingleFile) {
315
+ // Handle nested directory structure - combine all JSON files
316
+ langFiles.forEach(langFile => {
317
+ const rawContent = SecurityUtils.safeReadFileSync(langFile, process.cwd(), 'utf8');
318
+ const fileContent = SecurityUtils.safeParseJSON(rawContent);
319
+ if (fileContent) {
320
+ const fileName = this.getLanguageFileName(languageEntry, langFile);
321
+ const combinedKey = fileName.replace(/\.json$/i, '');
322
+ combinedContent[combinedKey] = fileContent;
323
+ const fileAnalysis = this.analyzeTranslationObject(fileContent, '');
324
+ fileAnalyses[fileName] = {
325
+ totalKeys: fileAnalysis.keyCount,
326
+ totalCharacters: fileAnalysis.charCount,
327
+ averageKeyLength: fileAnalysis.keyCount > 0 ? fileAnalysis.charCount / fileAnalysis.keyCount : 0,
328
+ maxKeyLength: fileAnalysis.maxLength,
329
+ minKeyLength: fileAnalysis.minLength,
330
+ emptyKeys: fileAnalysis.emptyKeys,
331
+ longKeys: fileAnalysis.longKeys
332
+ };
333
+ }
334
+ });
335
+ } else {
336
+ // Handle single file structure
337
+ const singleFilePath = languageEntry.isSingleFile && langFiles ? langFiles[0] : filePath;
338
+ const rawContent = SecurityUtils.safeReadFileSync(singleFilePath, process.cwd(), 'utf8');
339
+ combinedContent = SecurityUtils.safeParseJSON(rawContent) || {};
340
+ const fileAnalysis = this.analyzeTranslationObject(combinedContent, '');
341
+ fileAnalyses[path.basename(singleFilePath)] = {
342
+ totalKeys: fileAnalysis.keyCount,
343
+ totalCharacters: fileAnalysis.charCount,
344
+ averageKeyLength: fileAnalysis.keyCount > 0 ? fileAnalysis.charCount / fileAnalysis.keyCount : 0,
345
+ maxKeyLength: fileAnalysis.maxLength,
346
+ minKeyLength: fileAnalysis.minLength,
347
+ emptyKeys: fileAnalysis.emptyKeys,
348
+ longKeys: fileAnalysis.longKeys
349
+ };
350
+ }
245
351
 
246
352
  const analysis = this.analyzeTranslationObject(combinedContent, '');
247
353
 
@@ -249,11 +355,12 @@ class I18nSizingAnalyzer {
249
355
  totalKeys: analysis.keyCount,
250
356
  totalCharacters: analysis.charCount,
251
357
  averageKeyLength: analysis.keyCount > 0 ? analysis.charCount / analysis.keyCount : 0,
252
- maxKeyLength: analysis.maxLength,
253
- minKeyLength: analysis.minLength,
254
- emptyKeys: analysis.emptyKeys,
255
- longKeys: analysis.longKeys
256
- };
358
+ maxKeyLength: analysis.maxLength,
359
+ minKeyLength: analysis.minLength,
360
+ emptyKeys: analysis.emptyKeys,
361
+ longKeys: analysis.longKeys,
362
+ files: fileAnalyses
363
+ };
257
364
 
258
365
  // Store individual key sizes for comparison
259
366
  Object.entries(analysis.keys).forEach(([key, value]) => {
@@ -320,7 +427,7 @@ class I18nSizingAnalyzer {
320
427
  logger.info(t("sizing.generating_size_comparisons"));
321
428
 
322
429
  const languages = Object.keys(this.stats.languages);
323
- const baseLanguage = languages[0]; // Use first language as baseline
430
+ const baseLanguage = languages.includes(this.sourceLanguage) ? this.sourceLanguage : languages[0];
324
431
 
325
432
  if (!baseLanguage) {
326
433
  logger.warn(t("sizing.no_languages_found_for_comparison"));
@@ -342,7 +449,9 @@ class I18nSizingAnalyzer {
342
449
  const baseStats = this.stats.languages[baseLanguage];
343
450
  const langStats = this.stats.languages[lang];
344
451
 
345
- const sizeDiff = ((langStats.totalCharacters - baseStats.totalCharacters) / baseStats.totalCharacters) * 100;
452
+ const sizeDiff = baseStats.totalCharacters > 0
453
+ ? ((langStats.totalCharacters - baseStats.totalCharacters) / baseStats.totalCharacters) * 100
454
+ : 0;
346
455
 
347
456
  this.stats.summary.sizeVariations[lang] = {
348
457
  characterDifference: langStats.totalCharacters - baseStats.totalCharacters,
@@ -360,7 +469,7 @@ class I18nSizingAnalyzer {
360
469
  Object.entries(langData).forEach(([lang, data]) => {
361
470
  if (lang === baseLanguage) return;
362
471
 
363
- const diff = ((data.length - baseLang.length) / baseLang.length) * 100;
472
+ const diff = baseLang.length > 0 ? ((data.length - baseLang.length) / baseLang.length) * 100 : 0;
364
473
  if (Math.abs(diff) > this.threshold) {
365
474
  variations.push({
366
475
  language: lang,
@@ -379,10 +488,45 @@ class I18nSizingAnalyzer {
379
488
  });
380
489
  }
381
490
  });
382
-
383
- // Generate recommendations
384
- this.generateRecommendations();
385
- }
491
+ this.generateFileComparison();
492
+
493
+ // Generate recommendations
494
+ this.generateRecommendations();
495
+ }
496
+
497
+ generateFileComparison() {
498
+ const languages = Object.keys(this.stats.files);
499
+ const baseLanguage = this.stats.summary.baseLanguage || languages[0];
500
+ const fileSets = {};
501
+ const allFiles = new Set();
502
+
503
+ languages.forEach(language => {
504
+ const files = Object.keys(this.stats.files[language]?.perFiles || {}).sort();
505
+ fileSets[language] = files;
506
+ files.forEach(file => allFiles.add(file));
507
+ });
508
+
509
+ const baseFiles = new Set(fileSets[baseLanguage] || []);
510
+ const commonFiles = [...allFiles].filter(file => languages.every(language => fileSets[language].includes(file))).sort();
511
+ const missingFilesByLanguage = {};
512
+ const extraFilesByLanguage = {};
513
+
514
+ languages.forEach(language => {
515
+ const currentFiles = new Set(fileSets[language]);
516
+ missingFilesByLanguage[language] = [...allFiles].filter(file => !currentFiles.has(file)).sort();
517
+ extraFilesByLanguage[language] = fileSets[language].filter(file => !baseFiles.has(file)).sort();
518
+ });
519
+
520
+ this.stats.summary.fileComparison = {
521
+ baseLanguage,
522
+ totalUniqueFiles: allFiles.size,
523
+ commonFiles,
524
+ fileSets,
525
+ missingFilesByLanguage,
526
+ extraFilesByLanguage,
527
+ hasMismatches: Object.values(missingFilesByLanguage).some(files => files.length > 0)
528
+ };
529
+ }
386
530
 
387
531
  // Generate optimization recommendations
388
532
  generateRecommendations() {
@@ -414,58 +558,132 @@ class I18nSizingAnalyzer {
414
558
  }
415
559
 
416
560
  // Display concise folder-level results
417
- displayFolderResults() {
561
+ displayFolderResults() {
418
562
  console.log("\n" + t("sizing.sizing_analysis_results"));
419
563
  console.log(t("sizing.lineSeparator"));
420
564
 
421
- // Folder-level summary table
422
- console.log("\n" + t("sizing.folder_summary_title"));
423
- console.log(t("sizing.folder_summary_table_header"));
424
- console.log(t("sizing.lineSeparator"));
425
-
426
- Object.entries(this.stats.files).forEach(([lang, data]) => {
427
- const langData = this.stats.languages[lang];
428
- const totalChars = Math.round(langData.totalKeys * langData.averageKeyLength);
429
- console.log(t("sizing.folder_summary_row", {
430
- lang,
431
- sizeKB: data.sizeKB,
432
- totalKeys: langData.totalKeys,
433
- avgLength: langData.averageKeyLength.toFixed(1),
434
- totalChars: totalChars
435
- }));
436
- });
437
-
438
- // Language comparison summary
439
- console.log("\n" + t("sizing.language_comparison_title"));
440
- const baseLang = this.languages[0];
441
- if (this.languages.length > 1 && this.stats.languages[baseLang]) {
442
- const baseChars = Math.round(this.stats.languages[baseLang].totalKeys * this.stats.languages[baseLang].averageKeyLength);
443
-
444
- this.languages.slice(1).forEach(lang => {
445
- if (this.stats.languages[lang]) {
446
- const langChars = Math.round(this.stats.languages[lang].totalKeys * this.stats.languages[lang].averageKeyLength);
447
- const diff = langChars - baseChars;
448
- const percent = baseChars > 0 ? ((diff / baseChars) * 100).toFixed(1) : 0;
449
- const status = Math.abs(diff) > this.threshold ? "⚠️" : "✅";
450
- console.log(`${lang}: ${diff > 0 ? '+' : ''}${diff} chars (${percent}%) ${status}`);
451
- }
452
- });
453
- }
565
+ // Folder-level summary table
566
+ console.log("\n" + t("sizing.folder_summary_title"));
567
+ const folderRows = Object.entries(this.stats.files).map(([lang, data]) => {
568
+ const langData = this.stats.languages[lang];
569
+ return {
570
+ language: lang,
571
+ files: data.fileCount,
572
+ sizeKB: data.sizeKB,
573
+ totalKeys: langData.totalKeys,
574
+ avgLength: langData.averageKeyLength.toFixed(1),
575
+ totalChars: langData.totalCharacters
576
+ };
577
+ });
578
+ console.log(this.createTable([
579
+ { key: 'language', label: 'Language' },
580
+ { key: 'files', label: 'Files', align: 'right' },
581
+ { key: 'sizeKB', label: 'Size(KB)', align: 'right' },
582
+ { key: 'totalKeys', label: 'Keys', align: 'right' },
583
+ { key: 'avgLength', label: 'Avg Len', align: 'right' },
584
+ { key: 'totalChars', label: 'Total Chars', align: 'right' }
585
+ ], folderRows));
454
586
 
455
- // Summary stats
587
+ // Language comparison summary
588
+ console.log("\n" + t("sizing.language_comparison_title"));
589
+ const comparisonRows = Object.entries(this.stats.summary.sizeVariations || {}).map(([lang, data]) => ({
590
+ language: lang,
591
+ charDiff: `${data.characterDifference > 0 ? '+' : ''}${data.characterDifference}`,
592
+ percent: `${data.percentageDifference > 0 ? '+' : ''}${data.percentageDifference}%`,
593
+ status: data.isProblematic ? 'WARN' : 'OK'
594
+ }));
595
+
596
+ if (comparisonRows.length > 0) {
597
+ console.log(this.createTable([
598
+ { key: 'language', label: 'Language' },
599
+ { key: 'charDiff', label: 'Char Diff', align: 'right' },
600
+ { key: 'percent', label: 'Diff %', align: 'right' },
601
+ { key: 'status', label: 'Status' }
602
+ ], comparisonRows));
603
+ } else {
604
+ console.log("No language comparison available.");
605
+ }
606
+ this.displayFileComparison();
607
+
608
+ if (this.detailed) {
609
+ this.displayPerFileResults();
610
+ }
611
+
612
+ // Summary stats
456
613
  console.log("\n" + t("sizing.summary_stats", {
457
614
  totalLanguages: Object.keys(this.stats.languages).length,
458
615
  totalKeys: Object.keys(this.stats.keys).length,
459
616
  reportPath: this.outputDir
460
617
  }));
461
618
 
462
- if (this.detailedKeys) {
463
- this.displayDetailedKeys();
464
- }
465
- }
466
-
467
- // Display detailed key analysis (only when explicitly requested)
468
- displayDetailedKeys() {
619
+ if (this.detailedKeys) {
620
+ this.displayDetailedKeys();
621
+ }
622
+ }
623
+
624
+ displayFileComparison() {
625
+ const comparison = this.stats.summary.fileComparison;
626
+ if (!comparison) return;
627
+
628
+ console.log("\nFile Set Comparison");
629
+ const rows = Object.keys(this.stats.files).map(language => ({
630
+ language,
631
+ files: this.stats.files[language].fileCount,
632
+ missing: comparison.missingFilesByLanguage[language]?.length || 0,
633
+ extra: comparison.extraFilesByLanguage[language]?.length || 0
634
+ }));
635
+
636
+ console.log(this.createTable([
637
+ { key: 'language', label: 'Language' },
638
+ { key: 'files', label: 'Files', align: 'right' },
639
+ { key: 'missing', label: 'Missing', align: 'right' },
640
+ { key: 'extra', label: 'Extra vs Base', align: 'right' }
641
+ ], rows));
642
+
643
+ if (comparison.hasMismatches) {
644
+ Object.entries(comparison.missingFilesByLanguage).forEach(([language, files]) => {
645
+ if (files.length > 0) {
646
+ console.log(`${language} missing: ${files.join(', ')}`);
647
+ }
648
+ });
649
+ }
650
+ }
651
+
652
+ displayPerFileResults() {
653
+ console.log("\nPer-File Analysis");
654
+ const rows = [];
655
+
656
+ Object.entries(this.stats.languages).forEach(([language, languageData]) => {
657
+ Object.entries(languageData.files || {}).forEach(([fileName, fileData]) => {
658
+ const fileSizeData = this.stats.files[language]?.perFiles?.[fileName] || {};
659
+ rows.push({
660
+ language,
661
+ file: fileName,
662
+ sizeKB: fileSizeData.sizeKB || '0.00',
663
+ keys: fileData.totalKeys,
664
+ avgLength: fileData.averageKeyLength.toFixed(1),
665
+ totalChars: fileData.totalCharacters
666
+ });
667
+ });
668
+ });
669
+
670
+ if (rows.length === 0) {
671
+ console.log("No per-file analysis available.");
672
+ return;
673
+ }
674
+
675
+ console.log(this.createTable([
676
+ { key: 'language', label: 'Language' },
677
+ { key: 'file', label: 'File' },
678
+ { key: 'sizeKB', label: 'Size(KB)', align: 'right' },
679
+ { key: 'keys', label: 'Keys', align: 'right' },
680
+ { key: 'avgLength', label: 'Avg Len', align: 'right' },
681
+ { key: 'totalChars', label: 'Total Chars', align: 'right' }
682
+ ], rows));
683
+ }
684
+
685
+ // Display detailed key analysis (only when explicitly requested)
686
+ displayDetailedKeys() {
469
687
  console.log("\n" + t("sizing.detailed_key_analysis_title"));
470
688
  console.log(t("sizing.lineSeparator"));
471
689
 
@@ -478,14 +696,14 @@ class I18nSizingAnalyzer {
478
696
 
479
697
  console.log(t("sizing.key_analysis_header", { key }));
480
698
 
481
- Object.entries(data.translations).forEach(([lang, translation]) => {
482
- const length = translation.length;
483
- const isEmpty = length === 0;
484
- const isLong = length > this.threshold;
485
- const status = isEmpty ? t("sizing.status_empty") : isLong ? t("sizing.status_long") : t("sizing.status_ok");
486
-
487
- console.log(t("sizing.key_analysis_detail", { lang, length, status, translation: translation.substring(0, 50) + (translation.length > 50 ? "..." : "") }));
488
- });
699
+ Object.entries(data).forEach(([lang, keyData]) => {
700
+ const length = keyData.length;
701
+ const isEmpty = length === 0;
702
+ const isLong = length > this.threshold;
703
+ const status = isEmpty ? t("sizing.status_empty") : isLong ? t("sizing.status_long") : t("sizing.status_ok");
704
+
705
+ console.log(t("sizing.key_analysis_detail", { lang, length, status, translation: `${length} chars` }));
706
+ });
489
707
 
490
708
  console.log("");
491
709
  counter++;
@@ -503,10 +721,10 @@ class I18nSizingAnalyzer {
503
721
  throw new Error(t("sizing.invalidOutputDirectoryError", { outputDir: this.outputDir }));
504
722
  }
505
723
 
506
- // Ensure output directory exists
507
- if (!SecurityUtils.safeExistsSync(validatedOutputDir)) {
508
- SecurityUtils.safeMkdirSync(validatedOutputDir, { recursive: true });
509
- }
724
+ // Ensure output directory exists
725
+ if (!SecurityUtils.safeExistsSync(validatedOutputDir)) {
726
+ SecurityUtils.safeMkdirSync(validatedOutputDir, process.cwd(), { recursive: true });
727
+ }
510
728
 
511
729
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
512
730
 
@@ -536,11 +754,11 @@ class I18nSizingAnalyzer {
536
754
  threshold: this.threshold
537
755
  },
538
756
  analysis: this.stats,
539
- metadata: {
540
- totalFiles: Object.keys(this.stats.files).length,
541
- totalLanguages: Object.keys(this.stats.languages).length,
542
- totalKeys: Object.keys(this.stats.keys).length
543
- }
757
+ metadata: {
758
+ totalFiles: Object.values(this.stats.files).reduce((total, data) => total + (data.fileCount || 0), 0),
759
+ totalLanguages: Object.keys(this.stats.languages).length,
760
+ totalKeys: Object.keys(this.stats.keys).length
761
+ }
544
762
  };
545
763
 
546
764
  const jsonSuccess = await SecurityUtils.safeWriteFile(jsonReportPath, JSON.stringify(report, null, 2), process.cwd());
@@ -560,39 +778,66 @@ class I18nSizingAnalyzer {
560
778
  Generated: ${new Date().toISOString()}
561
779
 
562
780
  ## Configuration
563
- - Source Directory: ${this.sourceDir}
564
- - Languages: ${this.languages.join(', ')}
565
- - Threshold: ${this.threshold}%
566
-
567
- ## Summary Statistics
568
- - Total Languages: ${Object.keys(this.stats.languages).length}
569
- - Total Translation Keys: ${Object.keys(this.stats.keys).length}
570
- - Total Files: ${Object.keys(this.stats.files).length}
571
-
572
- ## Language Overview
573
- `;
574
-
575
- Object.entries(this.stats.languages).forEach(([lang, data]) => {
576
- const fileData = this.stats.files[lang] || { sizeKB: 0, lines: 0, characters: 0 };
577
- report += `
578
- ### ${lang.toUpperCase()}
579
- - File Size: ${fileData.sizeKB} KB
580
- - Lines: ${fileData.lines}
581
- - Total Characters: ${fileData.characters}
582
- - Translation Keys: ${data.totalKeys}
583
- - Average Key Length: ${data.averageKeyLength.toFixed(1)} characters
781
+ - Source Directory: ${this.sourceDir}
782
+ - Languages: ${(this.languages.length > 0 ? this.languages : Object.keys(this.stats.languages)).join(', ')}
783
+ - Threshold: ${this.threshold}%
784
+
785
+ ## Summary Statistics
786
+ - Total Languages: ${Object.keys(this.stats.languages).length}
787
+ - Total Translation Keys: ${Object.keys(this.stats.keys).length}
788
+ - Total Files: ${Object.values(this.stats.files).reduce((total, data) => total + (data.fileCount || 0), 0)}
789
+
790
+ ## Language Overview
791
+ `;
792
+
793
+ Object.entries(this.stats.languages).forEach(([lang, data]) => {
794
+ const fileData = this.stats.files[lang] || { sizeKB: 0, lines: 0, characters: 0 };
795
+ report += `
796
+ ### ${lang.toUpperCase()}
797
+ - Files: ${fileData.fileCount || 0}
798
+ - File Size: ${fileData.sizeKB} KB
799
+ - Lines: ${fileData.lines}
800
+ - Total Characters: ${fileData.characters}
801
+ - Translation Keys: ${data.totalKeys}
802
+ - Average Key Length: ${data.averageKeyLength.toFixed(1)} characters
584
803
  - Empty Translations: ${data.emptyKeys}
585
804
  - Long Keys (> ${this.threshold} chars): ${data.longKeys}
586
- `;
587
- });
588
-
589
- // Size variations
805
+ `;
806
+ });
807
+
808
+ const fileComparison = this.stats.summary.fileComparison;
809
+ if (fileComparison) {
810
+ report += `
811
+ ## File Set Comparison
812
+ - Base Language: ${fileComparison.baseLanguage}
813
+ - Unique Files: ${fileComparison.totalUniqueFiles}
814
+ - Common Files: ${fileComparison.commonFiles.length}
815
+ - Mismatches: ${fileComparison.hasMismatches ? 'Yes' : 'No'}
816
+ `;
817
+
818
+ Object.entries(fileComparison.missingFilesByLanguage).forEach(([lang, files]) => {
819
+ report += `- ${lang}: ${files.length} missing${files.length > 0 ? ` (${files.join(', ')})` : ''}\n`;
820
+ });
821
+ }
822
+
823
+ report += `
824
+ ## Per-File Analysis
825
+ `;
826
+
827
+ Object.entries(this.stats.languages).forEach(([lang, data]) => {
828
+ Object.entries(data.files || {}).forEach(([fileName, fileData]) => {
829
+ const sizeData = this.stats.files[lang]?.perFiles?.[fileName] || {};
830
+ report += `- ${lang}/${fileName}: ${fileData.totalKeys} keys, ${fileData.totalCharacters} chars, ${fileData.averageKeyLength.toFixed(1)} avg chars, ${sizeData.sizeKB || '0.00'} KB\n`;
831
+ });
832
+ });
833
+
834
+ // Size variations
590
835
  if (this.stats.summary.sizeVariations && Object.keys(this.stats.summary.sizeVariations).length > 0) {
591
836
  report += `
592
- ## Size Variations (vs ${this.languages[0]})
837
+ ## Size Variations (vs ${this.stats.summary.baseLanguage})
593
838
  `;
594
839
  Object.entries(this.stats.summary.sizeVariations).forEach(([lang, data]) => {
595
- report += `- ${lang}: ${data.characterDifference > 0 ? '+' : ''}${data.characterDifference} chars (${data.percentageDifference > 0 ? '+' : ''}${data.percentageDifference}%) ${data.isProblematic ? '⚠️ PROBLEMATIC' : 'OK'}\n`;
840
+ report += `- ${lang}: ${data.characterDifference > 0 ? '+' : ''}${data.characterDifference} chars (${data.percentageDifference > 0 ? '+' : ''}${data.percentageDifference}%) ${data.isProblematic ? 'PROBLEMATIC' : 'OK'}\n`;
596
841
  });
597
842
  }
598
843
 
@@ -629,13 +874,13 @@ Generated: ${new Date().toISOString()}
629
874
  report += `
630
875
  ### ${key}
631
876
  `;
632
- Object.entries(data.translations).forEach(([lang, translation]) => {
633
- const length = translation.length;
634
- const isEmpty = length === 0;
635
- const isLong = length > this.threshold;
636
- const status = isEmpty ? 'EMPTY' : isLong ? 'LONG' : 'OK';
637
- report += `- ${lang}: ${length} chars [${status}] "${translation.substring(0, 100)}${translation.length > 100 ? '...' : ''}"\n`;
638
- });
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
+ });
639
884
  });
640
885
  }
641
886
 
@@ -654,13 +899,13 @@ Generated: ${new Date().toISOString()}
654
899
  throw new Error(t("sizing.invalidCsvFileError"));
655
900
  }
656
901
 
657
- let csvContent = 'Language,File Size (KB),Lines,Characters,Total Keys,Avg Key Length,Max Key Length,Empty Keys,Long Keys\n';
902
+ let csvContent = 'Language,File Count,File Size (KB),Lines,Characters,Total Keys,Avg Key Length,Max Key Length,Empty Keys,Long Keys\n';
658
903
 
659
904
  Object.entries(this.stats.files).forEach(([lang]) => {
660
905
  const fileData = this.stats.files[lang];
661
906
  const langData = this.stats.languages[lang];
662
907
 
663
- csvContent += `${lang},${fileData.sizeKB},${fileData.lines},${fileData.characters},${langData.totalKeys},${langData.averageKeyLength.toFixed(1)},${langData.maxKeyLength},${langData.emptyKeys},${langData.longKeys}\n`;
908
+ csvContent += `${lang},${fileData.fileCount},${fileData.sizeKB},${fileData.lines},${fileData.characters},${langData.totalKeys},${langData.averageKeyLength.toFixed(1)},${langData.maxKeyLength},${langData.emptyKeys},${langData.longKeys}\n`;
664
909
  });
665
910
 
666
911
  const success = await SecurityUtils.safeWriteFile(csvPath, csvContent, process.cwd());
@@ -718,8 +963,9 @@ Generated: ${new Date().toISOString()}
718
963
  'languages': '',
719
964
  'output-report': true,
720
965
  'format': 'table',
721
- 'threshold': 50,
722
- 'detailed': false,
966
+ 'threshold': 50,
967
+ 'source-language': '',
968
+ 'detailed': false,
723
969
  'detailed-keys': false,
724
970
  'output-dir': './i18ntk-reports',
725
971
  'help': false,
@@ -759,13 +1005,15 @@ Generated: ${new Date().toISOString()}
759
1005
  options.format = value;
760
1006
  options.f = value;
761
1007
  }
762
- } else if (key === 'threshold' || key === 't') {
763
- const numValue = parseInt(value);
764
- if (!isNaN(numValue)) {
765
- options.threshold = numValue;
766
- options.t = numValue;
767
- }
768
- } else if (key === 'detailed' || key === 'd') {
1008
+ } else if (key === 'threshold' || key === 't') {
1009
+ const numValue = parseInt(value);
1010
+ if (!isNaN(numValue)) {
1011
+ options.threshold = numValue;
1012
+ options.t = numValue;
1013
+ }
1014
+ } else if (key === 'source-language') {
1015
+ options['source-language'] = value;
1016
+ } else if (key === 'detailed' || key === 'd') {
769
1017
  options.detailed = value.toLowerCase() !== 'false';
770
1018
  options.d = options.detailed;
771
1019
  } else if (key === 'detailed-keys') {
@@ -806,14 +1054,17 @@ Generated: ${new Date().toISOString()}
806
1054
  options.f = value;
807
1055
  }
808
1056
  if (nextArg && !nextArg.startsWith('-') && ['json', 'csv', 'table'].includes(nextArg)) i++;
809
- } else if (key === 'threshold' || key === 't') {
810
- const value = parseInt(nextArg);
811
- if (!isNaN(value)) {
812
- options.threshold = value;
813
- options.t = value;
814
- }
815
- if (nextArg && !nextArg.startsWith('-') && !isNaN(parseInt(nextArg))) i++;
816
- } else if (key === 'detailed' || key === 'd') {
1057
+ } else if (key === 'threshold' || key === 't') {
1058
+ const value = parseInt(nextArg);
1059
+ if (!isNaN(value)) {
1060
+ options.threshold = value;
1061
+ options.t = value;
1062
+ }
1063
+ if (nextArg && !nextArg.startsWith('-') && !isNaN(parseInt(nextArg))) i++;
1064
+ } else if (key === 'source-language') {
1065
+ options['source-language'] = nextArg || options['source-language'];
1066
+ if (nextArg && !nextArg.startsWith('-')) i++;
1067
+ } else if (key === 'detailed' || key === 'd') {
817
1068
  if (nextArg && !nextArg.startsWith('-') && ['true', 'false'].includes(nextArg.toLowerCase())) {
818
1069
  options.detailed = nextArg.toLowerCase() !== 'false';
819
1070
  options.d = options.detailed;
@@ -847,8 +1098,9 @@ Options:
847
1098
  -l, --languages <langs> Comma-separated list of languages to analyze
848
1099
  -o, --output-report Generate detailed sizing report (default: true)
849
1100
  -f, --format <format> Output format: json, csv, table (default: table)
850
- -t, --threshold <number> Size difference threshold for warnings (%) (default: 50)
851
- -d, --detailed Generate detailed report with more information
1101
+ -t, --threshold <number> Size difference threshold for warnings (%) (default: 50)
1102
+ --source-language <code> Source language baseline for comparisons (default: en)
1103
+ -d, --detailed Generate detailed report with more information
852
1104
  --detailed-keys Show detailed key-level analysis
853
1105
  --output-dir <dir> Output directory for reports (default: ./i18ntk-reports)
854
1106
  --help Show this help message
@@ -868,12 +1120,13 @@ Options:
868
1120
 
869
1121
  this.sourceDir = path.resolve(config.projectRoot || '.', config.sourceDir || './locales');
870
1122
  this.outputDir = path.resolve(config.projectRoot || '.', config.outputDir || './i18ntk-reports');
871
- this.threshold = args.threshold ?? config.processing?.sizingThreshold ?? 50;
872
- this.languages = args.languages ? args.languages.split(',').map(l => l.trim()) : [];
873
- this.outputReport = args['output-report'] !== undefined ? args['output-report'] : false;
874
- this.format = args.format || 'table';
875
- this.detailed = args.detailed;
876
- this.detailedKeys = args['detailed-keys'];
1123
+ this.threshold = args.threshold ?? config.processing?.sizingThreshold ?? 50;
1124
+ this.languages = args.languages ? args.languages.split(',').map(l => l.trim()) : [];
1125
+ this.outputReport = args['output-report'] !== undefined ? args['output-report'] : false;
1126
+ this.format = args.format || 'table';
1127
+ this.detailed = args.detailed;
1128
+ this.detailedKeys = args['detailed-keys'];
1129
+ this.sourceLanguage = args['source-language'] || config.sourceLanguage || this.sourceLanguage || 'en';
877
1130
 
878
1131
  if (!fromMenu) {
879
1132