i18ntk 3.3.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.
@@ -109,49 +109,64 @@ class I18nTextScanner {
109
109
  const args = process.argv.slice(2);
110
110
  const parsed = {};
111
111
 
112
- args.forEach(arg => {
113
- if (arg.startsWith('--')) {
114
- const [key, ...valueParts] = arg.substring(2).split('=');
115
- const value = valueParts.join('=');
116
-
117
- switch (key) {
118
- case 'source-dir':
119
- parsed.sourceDir = value || '';
120
- break;
121
- case 'framework':
122
- parsed.framework = value || '';
123
- break;
124
- case 'patterns':
125
- parsed.patterns = value ? value.split(',').map(p => p.trim()).filter(Boolean) : [];
126
- break;
127
- case 'exclude':
128
- parsed.exclude = value ? value.split(',').map(e => e.trim()).filter(Boolean) : [];
129
- break;
130
- case 'output-dir':
131
- parsed.outputDir = value || '';
132
- break;
133
- case 'min-length':
134
- parsed.minLength = parseInt(value) || 3;
135
- break;
136
- case 'max-length':
137
- parsed.maxLength = parseInt(value) || 100;
138
- break;
139
- case 'output-report':
140
- parsed.outputReport = true;
112
+ for (let i = 0; i < args.length; i++) {
113
+ const arg = args[i];
114
+ if (arg.startsWith('--')) {
115
+ const [key, ...valueParts] = arg.substring(2).split('=');
116
+ let value = valueParts.join('=');
117
+ if (!value && args[i + 1] && !args[i + 1].startsWith('--')) {
118
+ value = args[i + 1];
119
+ }
120
+
121
+ switch (key) {
122
+ case 'source-dir':
123
+ parsed.sourceDir = value || '';
124
+ if (value === args[i + 1]) i++;
125
+ break;
126
+ case 'framework':
127
+ parsed.framework = value || '';
128
+ if (value === args[i + 1]) i++;
129
+ break;
130
+ case 'patterns':
131
+ parsed.patterns = value ? value.split(',').map(p => p.trim()).filter(Boolean) : [];
132
+ if (value === args[i + 1]) i++;
133
+ break;
134
+ case 'exclude':
135
+ parsed.exclude = value ? value.split(',').map(e => e.trim()).filter(Boolean) : [];
136
+ if (value === args[i + 1]) i++;
137
+ break;
138
+ case 'output-dir':
139
+ parsed.outputDir = value || '';
140
+ if (value === args[i + 1]) i++;
141
+ break;
142
+ case 'min-length':
143
+ parsed.minLength = parseInt(value) || 3;
144
+ if (value === args[i + 1]) i++;
145
+ break;
146
+ case 'max-length':
147
+ parsed.maxLength = parseInt(value) || 100;
148
+ if (value === args[i + 1]) i++;
149
+ break;
150
+ case 'output-report':
151
+ parsed.outputReport = true;
141
152
  break;
142
153
  case 'include-tests':
143
- parsed.includeTests = true;
144
- break;
145
- case 'help':
146
- case 'h':
147
- parsed.help = true;
148
- break;
149
- }
150
- }
151
- });
152
-
153
- return parsed;
154
- }
154
+ parsed.includeTests = true;
155
+ break;
156
+ case 'source-language':
157
+ parsed.sourceLanguage = value || '';
158
+ if (value === args[i + 1]) i++;
159
+ break;
160
+ case 'help':
161
+ case 'h':
162
+ parsed.help = true;
163
+ break;
164
+ }
165
+ }
166
+ }
167
+
168
+ return parsed;
169
+ }
155
170
 
156
171
  detectFramework(projectRoot) {
157
172
  const packagePath = path.join(projectRoot, 'package.json');
@@ -293,43 +308,148 @@ class I18nTextScanner {
293
308
  }
294
309
 
295
310
  isEnglishText(text) {
296
- // Enhanced text detection for Unicode and multilingual support
297
311
  const trimmed = text.trim();
298
312
  if (trimmed.length < 3) return false;
299
313
 
300
- // Skip if it's just numbers or special characters
301
314
  if (/^\d+$/.test(trimmed)) return false;
302
315
  if (/^[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>?]+$/.test(trimmed)) return false;
303
316
 
304
- // Allow Unicode characters including CJK, Cyrillic, etc.
305
317
  const validChars = trimmed.match(/[\p{L}\p{N}\s\-,.!?':"()\[\]{}]/gu) || [];
306
318
  const validRatio = validChars.length / trimmed.length;
307
319
 
308
- // Must have at least 50% valid characters and some alphabetic characters
309
320
  const hasAlpha = /[a-zA-Z\u00C0-\u024F\u1E00-\u1EFF\u0400-\u04FF\u4E00-\u9FFF\uAC00-\uD7AF]/u.test(trimmed);
310
321
 
311
322
  return validRatio >= 0.5 && hasAlpha;
312
323
  }
313
324
 
325
+ getLanguageProfile(langCode) {
326
+ const profiles = {
327
+ en: {
328
+ name: 'English',
329
+ charRegex: /[a-zA-Z\u00C0-\u024F]/u,
330
+ stopwords: ['the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'had', 'her', 'was', 'one', 'our', 'out', 'has', 'have', 'from', 'they', 'that', 'with', 'this', 'will', 'your', 'which', 'their', 'them', 'than', 'then', 'been', 'being', 'would', 'should', 'could', 'about', 'after'],
331
+ minLength: 3,
332
+ maxLength: 150
333
+ },
334
+ de: {
335
+ name: 'German',
336
+ charRegex: /[a-zA-Z\u00C0-\u00FF\u0100-\u017F\u00DF\u1E00-\u1EFF]/u,
337
+ stopwords: ['der', 'die', 'das', 'und', 'ist', 'von', 'mit', 'sich', 'des', 'auf', 'dem', 'nicht', 'ein', 'eine', 'auch', 'als', 'aus', 'bei', 'nach', 'wie', 'oder', 'war', 'hat', 'ich', 'sie', 'einem', 'um', 'am', 'im', 'es'],
338
+ minLength: 3,
339
+ maxLength: 180
340
+ },
341
+ fr: {
342
+ name: 'French',
343
+ charRegex: /[a-zA-Z\u00C0-\u00FF\u0152\u0153]/u,
344
+ stopwords: ['le', 'la', 'les', 'des', 'est', 'pas', 'que', 'une', 'dans', 'sur', 'plus', 'par', 'pour', 'avec', 'aux', 'ces', 'ses', 'mes', 'tes', 'notre', 'votre', 'leur', 'dont', 'sont', 'comme', 'mais', 'alors', 'peut', 'tout', 'tous', 'fait'],
345
+ minLength: 3,
346
+ maxLength: 170
347
+ },
348
+ es: {
349
+ name: 'Spanish',
350
+ charRegex: /[a-zA-Z\u00C0-\u00FF\u00F1\u00D1]/u,
351
+ stopwords: ['que', 'los', 'las', 'del', 'como', 'por', 'para', 'con', 'una', 'sus', 'muy', 'más', 'pero', 'este', 'esta', 'hay', 'son', 'eran', 'fue', 'han', 'será', 'está', 'todo', 'otro', 'otra'],
352
+ minLength: 3,
353
+ maxLength: 150
354
+ },
355
+ ja: {
356
+ name: 'Japanese',
357
+ charRegex: /[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uFF66-\uFF9F]/u,
358
+ stopwords: ['の', 'に', 'は', 'を', 'た', 'が', 'で', 'て', 'と', 'し', 'れ', 'さ', 'る', 'す', 'ん', 'な', 'い', 'か', 'ま', 'も', 'こ', 'り', 'ち', 'き', 'ょ', 'う'],
359
+ minLength: 2,
360
+ maxLength: 80
361
+ },
362
+ zh: {
363
+ name: 'Chinese',
364
+ charRegex: /[\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF]/u,
365
+ stopwords: ['的', '是', '在', '不', '了', '有', '和', '人', '这', '中', '大', '为', '上', '个', '国', '我', '以', '要', '他', '时', '来', '用', '们', '生', '到', '作', '地'],
366
+ minLength: 1,
367
+ maxLength: 50
368
+ },
369
+ ru: {
370
+ name: 'Russian',
371
+ charRegex: /[\u0400-\u04FF\u0500-\u052F]/u,
372
+ stopwords: ['и', 'в', 'не', 'на', 'что', 'как', 'по', 'к', 'от', 'это', 'за', 'то', 'для', 'все', 'его', 'она', 'так', 'же', 'но', 'был', 'быть', 'еще', 'уже', 'кто', 'мой', 'ее', 'их', 'из'],
373
+ minLength: 2,
374
+ maxLength: 200
375
+ },
376
+ ko: {
377
+ name: 'Korean',
378
+ charRegex: /[\uAC00-\uD7AF\u1100-\u11FF\u3130-\u318F]/u,
379
+ stopwords: ['이', '그', '저', '것', '수', '등', '들', '및', '년', '월', '일', '에서', '에게', '으로', '보다', '에게서', '의', '에', '는', '은', '가', '를', '과', '와', '도', '만', '까지', '부터'],
380
+ minLength: 1,
381
+ maxLength: 70
382
+ },
383
+ ar: {
384
+ name: 'Arabic',
385
+ charRegex: /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF]/u,
386
+ stopwords: ['في', 'من', 'على', 'عن', 'مع', 'هو', 'هي', 'كان', 'هذا', 'ذلك', 'بين', 'بعد', 'قبل', 'عند', 'حتى', 'الى', 'او', 'لا', 'ما', 'لم', 'لن', 'كل', 'بعض', 'أي'],
387
+ minLength: 2,
388
+ maxLength: 150
389
+ },
390
+ hi: {
391
+ name: 'Hindi',
392
+ charRegex: /[\u0900-\u097F]/u,
393
+ stopwords: ['का', 'की', 'के', 'है', 'हैं', 'था', 'थे', 'होगा', 'होगी', 'में', 'से', 'पर', 'को', 'तक', 'और', 'या', 'लेकिन', 'जब', 'तब', 'कि', 'यह', 'वह', 'एक', 'दो'],
394
+ minLength: 2,
395
+ maxLength: 160
396
+ },
397
+ vanilla: {
398
+ name: 'Generic Latin',
399
+ charRegex: /[a-zA-Z\u00C0-\u024F]/u,
400
+ stopwords: [],
401
+ minLength: 3,
402
+ maxLength: 150
403
+ }
404
+ };
405
+ return profiles[langCode] || profiles.en;
406
+ }
407
+
408
+ isTextInLanguage(text, langCode) {
409
+ const profile = this.getLanguageProfile(langCode);
410
+ const trimmed = text.trim();
411
+
412
+ if (trimmed.length < profile.minLength) return false;
413
+ if (trimmed.length > profile.maxLength) return false;
414
+
415
+ if (/^\d+$/.test(trimmed)) return false;
416
+ if (/^[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>?]+$/.test(trimmed)) return false;
417
+
418
+ const hasScriptChar = profile.charRegex.test(trimmed);
419
+ if (!hasScriptChar) return false;
420
+
421
+ if (profile.stopwords.length > 0) {
422
+ const words = trimmed.toLowerCase().split(/\s+/);
423
+ for (const word of words) {
424
+ if (profile.stopwords.includes(word)) return true;
425
+ }
426
+ const validChars = trimmed.match(/[\p{L}\p{N}\s\-,.!?':"()\[\]{}]/gu) || [];
427
+ const validRatio = validChars.length / trimmed.length;
428
+ return validRatio >= 0.5;
429
+ }
430
+
431
+ return true;
432
+ }
433
+
314
434
  scanFile(filePath, patterns, minLength, maxLength) {
315
435
  try {
316
436
  const content = SecurityUtils.safeReadFileSync(filePath, path.dirname(filePath), 'utf8');
317
437
  const lines = content.split('\n');
318
438
  const results = [];
439
+ const sourceLang = this.config.sourceLanguage || 'en';
319
440
 
320
441
  patterns.forEach(pattern => {
321
442
  let match;
322
443
  while ((match = pattern.exec(content)) !== null) {
323
444
  const text = match[1] || match[0];
324
445
 
325
- // Skip translation function calls
326
446
  const beforeMatch = content.substring(Math.max(0, match.index - 20), match.index);
327
447
  if (beforeMatch.includes('t(') || beforeMatch.includes('i18next.t(') ||
328
448
  beforeMatch.includes('$t(') || beforeMatch.includes('translate(')) {
329
449
  continue;
330
450
  }
331
451
 
332
- if (text && this.isEnglishText(text) &&
452
+ if (text && this.isTextInLanguage(text, sourceLang) &&
333
453
  text.length >= minLength && text.length <= maxLength) {
334
454
 
335
455
  const lineNumber = content.substring(0, match.index).split('\n').length;
@@ -355,7 +475,23 @@ class I18nTextScanner {
355
475
  }
356
476
 
357
477
  generateSuggestion(text) {
358
- const key = text.toLowerCase()
478
+ const sourceLang = this.config.sourceLanguage || 'en';
479
+ const transliterations = {
480
+ ja: { 'あ': 'a', 'い': 'i', 'う': 'u', 'え': 'e', 'お': 'o', 'か': 'ka', 'き': 'ki', 'く': 'ku', 'け': 'ke', 'こ': 'ko', 'さ': 'sa', 'し': 'shi', 'す': 'su', 'せ': 'se', 'そ': 'so', 'た': 'ta', 'ち': 'chi', 'つ': 'tsu', 'て': 'te', 'と': 'to', 'な': 'na', 'に': 'ni', 'ぬ': 'nu', 'ね': 'ne', 'の': 'no', 'は': 'ha', 'ひ': 'hi', 'ふ': 'fu', 'へ': 'he', 'ほ': 'ho', 'ま': 'ma', 'み': 'mi', 'む': 'mu', 'め': 'me', 'も': 'mo', 'や': 'ya', 'ゆ': 'yu', 'よ': 'yo', 'ら': 'ra', 'り': 'ri', 'る': 'ru', 'れ': 're', 'ろ': 'ro', 'わ': 'wa', 'を': 'wo', 'ん': 'n' },
481
+ ru: { 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', 'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm', 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', 'ф': 'f', 'х': 'kh', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch', 'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya' },
482
+ zh: { '的': 'de', '一': 'yi', '是': 'shi', '在': 'zai', '不': 'bu', '了': 'le', '有': 'you', '和': 'he', '人': 'ren', '这': 'zhe', '中': 'zhong', '大': 'da', '为': 'wei', '上': 'shang', '个': 'ge', '国': 'guo', '我': 'wo', '以': 'yi_t', '要': 'yao', '他': 'ta', '时': 'shi_t', '来': 'lai', '用': 'yong', '们': 'men', '生': 'sheng', '到': 'dao', '作': 'zuo', '地': 'di' }
483
+ };
484
+
485
+ let transliterated = text;
486
+ const table = transliterations[sourceLang];
487
+ if (table) {
488
+ transliterated = '';
489
+ for (const ch of text) {
490
+ transliterated += table[ch] || ch;
491
+ }
492
+ }
493
+
494
+ const key = transliterated.toLowerCase()
359
495
  .replace(/[^a-z0-9\s]/g, '')
360
496
  .replace(/\s+/g, '_')
361
497
  .substring(0, 50);
@@ -559,6 +695,9 @@ class I18nTextScanner {
559
695
 
560
696
  this.sourceDir = this.config.sourceDir || './src';
561
697
 
698
+ // Source language for multi-language detection
699
+ this.sourceLanguage = args['source-language'] || this.config.sourceLanguage || 'en';
700
+
562
701
  // Resolve framework with precedence: CLI arg > config.framework.preference|string > auto-detect > fallback
563
702
  const cliFramework = args.framework;
564
703
  const cfgFramework = this.config.framework;
@@ -637,4 +776,4 @@ if (require.main === module) {
637
776
  })();
638
777
  }
639
778
 
640
- module.exports = I18nTextScanner;
779
+ module.exports = I18nTextScanner;
@@ -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) {