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.
- package/CHANGELOG.md +29 -2
- package/README.md +157 -15
- package/SECURITY.md +14 -8
- package/main/i18ntk-backup.js +305 -62
- package/main/i18ntk-scanner.js +188 -49
- package/main/i18ntk-sizing.js +223 -29
- 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 +2 -2
- package/runtime/i18ntk.d.ts +22 -16
- package/runtime/index.d.ts +9 -7
- package/runtime/index.js +240 -50
- package/ui-locales/en.json +1 -1
- package/utils/translate/protection.js +147 -6
- package/utils/watch-locales.js +183 -36
package/main/i18ntk-scanner.js
CHANGED
|
@@ -109,49 +109,64 @@ class I18nTextScanner {
|
|
|
109
109
|
const args = process.argv.slice(2);
|
|
110
110
|
const parsed = {};
|
|
111
111
|
|
|
112
|
-
args.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
break;
|
|
130
|
-
case '
|
|
131
|
-
parsed.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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 '
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
break;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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.
|
|
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
|
|
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;
|
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) {
|