UNPKG

i18ntk

Version:

i18n Tool Kit - Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, auto translation, fixing, reporting, and runtime translation loading.

1,304 lines (1,138 loc) 53.2 kB
#!/usr/bin/env node /** * I18n Sizing Analyzer * * Analyzes translation file sizes, character counts, and provides sizing statistics * for different languages to help with UI layout planning and optimization. * * Features: * - File size analysis for all translation files * - Character count statistics per language * - Key-level size comparison across languages * - UI layout impact assessment * - Size optimization recommendations * * Usage: * i18ntk sizing [options] * * Options: * --source-dir <dir> Source directory containing translation files (default: ./locales) * --languages <langs> Comma-separated list of languages to analyze (default: all) * --output-report Generate detailed sizing report * --format <format> Output format: json, csv, table (default: table) * --threshold <number> Size difference threshold for warnings (default: 50%) * --detailed Generate detailed report with more information * --help Show this help message * * Examples: * i18ntk sizing --output-report * i18ntk sizing --languages=en,de,fr --format=json * i18ntk sizing --threshold=30 --output-report */ const fs = require('fs'); const path = require('path'); const { loadTranslations, t } = require('../utils/i18n-helper'); const configManager = require('../settings/settings-manager'); const projectConfigManager = require('../utils/config-manager'); const SecurityUtils = require('../utils/security'); const { getUnifiedConfig } = require('../utils/config-helper'); const { logger } = require('../utils/logger'); const { getGlobalReadline, closeGlobalReadline } = require('../utils/cli'); const SetupEnforcer = require('../utils/setup-enforcer'); // Ensure setup is complete before running (only when executed directly) if (require.main === module) { (async () => { try { await SetupEnforcer.checkSetupCompleteAsync(); } catch (error) { console.error('Setup check failed:', error.message); process.exit(1); } })(); } loadTranslations(); // Get configuration from settings manager function getConfig() { const settings = configManager.loadSettings ? configManager.loadSettings() : (configManager.getConfig ? configManager.getConfig() : {}); // Check for per-script directory override, fallback to global sourceDir const sourceDir = settings.scriptDirectories?.sizing || settings.sourceDir || './locales'; return { projectRoot: settings.projectRoot || '.', sourceDir: sourceDir, i18nDir: settings.i18nDir || settings.sourceDir || './locales', outputDir: settings.outputDir || './i18ntk-reports', threshold: settings.processing?.sizingThreshold || 50, uiLanguage: settings.language || 'en', sourceLanguage: settings.sourceLanguage || 'en' }; } class I18nSizingAnalyzer { constructor(options = {}) { const config = getConfig(); const projectRoot = path.resolve(config.projectRoot || '.'); this.sourceDir = path.resolve(projectRoot, options.sourceDir || config.sourceDir); this.outputDir = path.resolve(projectRoot, options.outputDir || config.outputDir); this.languages = options.languages || []; this.threshold = options.threshold || config.threshold; // Size difference threshold in percentage this.format = options.format || 'table'; this.outputReport = options.outputReport || false; this.sourceLanguage = options.sourceLanguage || config.sourceLanguage || 'en'; this.detailed = options.detailed || false; this.detailedKeys = options.detailedKeys || false; this.predictExpansion = options.predictExpansion || false; this.rl = null; this.expansionPredictions = null; // Initialize i18n with UI language from config const uiLanguage = options.uiLanguage || config.uiLanguage || 'en'; loadTranslations(uiLanguage, path.resolve(__dirname, '..', 'ui-locales')); this.stats = { files: {}, languages: {}, keys: {}, summary: {} }; } // Initialize readline interface initReadline() { return getGlobalReadline(); } // Prompt for user input async prompt(question) { const rl = getGlobalReadline(); return new Promise(resolve => rl.question(question, resolve)); } // Close readline interface closeReadline() { closeGlobalReadline(); } // Get available language files getLanguageFiles() { const validatedSourceDir = SecurityUtils.validatePath(this.sourceDir, process.cwd()); if (!validatedSourceDir) { throw new Error(t("sizing.invalidSourceDirectoryError", { sourceDir: this.sourceDir })); } if (!SecurityUtils.safeExistsSync(validatedSourceDir)) { throw new Error(t("sizing.sourceDirectoryNotFoundError", { sourceDir: validatedSourceDir })); } const files = []; const items = SecurityUtils.safeReaddirSync(validatedSourceDir); // Check for nested language directories for (const item of items) { const itemPath = SecurityUtils.validatePath(path.join(validatedSourceDir, item), process.cwd()); if (!itemPath) continue; const stat = SecurityUtils.safeStatSync(itemPath); if (!stat) continue; if (stat.isDirectory()) { // This is a language directory, combine all JSON files const langFiles = this.collectJsonFiles(itemPath); if (langFiles.length > 0) { files.push({ language: item, file: `${item}/*.json`, path: itemPath, files: langFiles }); } } else if (item.endsWith('.json')) { // Direct JSON file in root const lang = path.basename(item, '.json'); files.push({ language: lang, file: item, path: itemPath, files: [itemPath], isSingleFile: true }); } } if (this.languages.length > 0) { return files .filter(f => this.languages.includes(f.language)) .sort((a, b) => a.language.localeCompare(b.language)); } return files.sort((a, b) => a.language.localeCompare(b.language)); } collectJsonFiles(dirPath) { const jsonFiles = []; const entries = SecurityUtils.safeReaddirSync(dirPath, process.cwd(), { withFileTypes: true }); entries.forEach(entry => { const entryPath = SecurityUtils.validatePath(path.join(dirPath, entry.name), process.cwd()); if (!entryPath) return; if (entry.isDirectory()) { jsonFiles.push(...this.collectJsonFiles(entryPath)); } else if (entry.isFile() && entry.name.endsWith('.json')) { jsonFiles.push(entryPath); } }); return jsonFiles.sort((a, b) => this.getFileSortName(dirPath, a).localeCompare(this.getFileSortName(dirPath, b))); } getLanguageFileName(languageEntry, filePath) { if (languageEntry.isSingleFile) { return path.basename(filePath); } return this.getFileSortName(languageEntry.path, filePath); } getFileSortName(basePath, filePath) { return path.relative(basePath, filePath).replace(/\\/g, '/'); } createTable(columns, rows) { const widths = columns.map(column => { return Math.max( column.label.length, ...rows.map(row => String(row[column.key] ?? '').length) ); }); const formatCell = (value, width, align = 'left') => { const text = String(value ?? ''); return align === 'right' ? text.padStart(width) : text.padEnd(width); }; const header = columns.map((column, index) => formatCell(column.label, widths[index], column.align)).join(' '); const separator = widths.map(width => '-'.repeat(width)).join(' '); const body = rows.map(row => columns .map((column, index) => formatCell(row[column.key], widths[index], column.align)) .join(' ')); return [header, separator, ...body].join('\n'); } // Analyze file sizes analyzeFileSizes(files) { logger.info(t("sizing.analyzing_file_sizes")); files.forEach(languageEntry => { const { language, file, path: filePath, files: langFiles } = languageEntry; if (langFiles) { // Handle nested directory structure let totalSize = 0; let totalLines = 0; let totalCharacters = 0; let lastModified = new Date(0); const perFiles = {}; langFiles.forEach(langFile => { const stats = SecurityUtils.safeStatSync(langFile, process.cwd()); if (!stats) return; let content = SecurityUtils.safeReadFileSync(langFile, process.cwd(), 'utf8'); if (typeof content !== "string") content = ""; totalSize += stats.size; totalLines += content.split('\n').length; totalCharacters += content.length; if (stats.mtime > lastModified) { lastModified = stats.mtime; } const relativeName = this.getLanguageFileName(languageEntry, langFile); perFiles[relativeName] = { file: relativeName, path: langFile, size: stats.size, sizeKB: (stats.size / 1024).toFixed(2), lines: content.split('\n').length, characters: content.length, lastModified: stats.mtime }; }); this.stats.files[language] = { file, size: totalSize, sizeKB: (totalSize / 1024).toFixed(2), lines: totalLines, characters: totalCharacters, lastModified: lastModified, fileCount: Object.keys(perFiles).length, perFiles }; } else { // Handle single file structure const stats = SecurityUtils.safeStatSync(filePath, process.cwd()); if (!stats) return; let content = SecurityUtils.safeReadFileSync(filePath, process.cwd(), 'utf8'); if (typeof content !== "string") content = ""; this.stats.files[language] = { file, size: stats.size, sizeKB: (stats.size / 1024).toFixed(2), lines: content.split('\n').length, characters: content.length, lastModified: stats.mtime, fileCount: 1, perFiles: { [file]: { file, path: filePath, size: stats.size, sizeKB: (stats.size / 1024).toFixed(2), lines: content.split('\n').length, characters: content.length, lastModified: stats.mtime } } }; } }); } // Analyze translation content analyzeTranslationContent(files) { logger.info(t("sizing.analyzing_translation_content")); files.forEach(languageEntry => { const { language, path: filePath, files: langFiles } = languageEntry; try { let combinedContent = {}; const fileAnalyses = {}; if (langFiles && !languageEntry.isSingleFile) { // Handle nested directory structure - combine all JSON files langFiles.forEach(langFile => { const rawContent = SecurityUtils.safeReadFileSync(langFile, process.cwd(), 'utf8'); const fileContent = SecurityUtils.safeParseJSON(rawContent); if (fileContent) { const fileName = this.getLanguageFileName(languageEntry, langFile); const combinedKey = fileName.replace(/\.json$/i, ''); combinedContent[combinedKey] = fileContent; const fileAnalysis = this.analyzeTranslationObject(fileContent, ''); fileAnalyses[fileName] = { totalKeys: fileAnalysis.keyCount, totalCharacters: fileAnalysis.charCount, averageKeyLength: fileAnalysis.keyCount > 0 ? fileAnalysis.charCount / fileAnalysis.keyCount : 0, maxKeyLength: fileAnalysis.maxLength, minKeyLength: fileAnalysis.minLength, emptyKeys: fileAnalysis.emptyKeys, longKeys: fileAnalysis.longKeys }; } }); } else { // Handle single file structure const singleFilePath = languageEntry.isSingleFile && langFiles ? langFiles[0] : filePath; const rawContent = SecurityUtils.safeReadFileSync(singleFilePath, process.cwd(), 'utf8'); combinedContent = SecurityUtils.safeParseJSON(rawContent) || {}; const fileAnalysis = this.analyzeTranslationObject(combinedContent, ''); fileAnalyses[path.basename(singleFilePath)] = { totalKeys: fileAnalysis.keyCount, totalCharacters: fileAnalysis.charCount, averageKeyLength: fileAnalysis.keyCount > 0 ? fileAnalysis.charCount / fileAnalysis.keyCount : 0, maxKeyLength: fileAnalysis.maxLength, minKeyLength: fileAnalysis.minLength, emptyKeys: fileAnalysis.emptyKeys, longKeys: fileAnalysis.longKeys }; } const analysis = this.analyzeTranslationObject(combinedContent, ''); this.stats.languages[language] = { totalKeys: analysis.keyCount, totalCharacters: analysis.charCount, averageKeyLength: analysis.keyCount > 0 ? analysis.charCount / analysis.keyCount : 0, maxKeyLength: analysis.maxLength, minKeyLength: analysis.minLength, emptyKeys: analysis.emptyKeys, longKeys: analysis.longKeys, files: fileAnalyses }; // Store individual key sizes for comparison Object.entries(analysis.keys).forEach(([key, value]) => { if (!this.stats.keys[key]) { this.stats.keys[key] = {}; } this.stats.keys[key][language] = { length: value.length, characters: value.length }; }); } catch (error) { logger.error(t("sizing.failed_to_parse_language_error", { language, errorMessage: error.message })); } }); } // Recursively analyze translation object analyzeTranslationObject(obj, prefix = '') { let keyCount = 0; let charCount = 0; let maxLength = 0; let minLength = Infinity; let emptyKeys = 0; let longKeys = 0; const keys = {}; const traverse = (current, currentPrefix) => { Object.entries(current).forEach(([key, value]) => { const fullKey = currentPrefix ? `${currentPrefix}.${key}` : key; if (typeof value === 'string') { keyCount++; charCount += value.length; maxLength = Math.max(maxLength, value.length); minLength = Math.min(minLength, value.length); if (value.length === 0) emptyKeys++; if (value.length > 100) longKeys++; keys[fullKey] = value; } else if (typeof value === 'object' && value !== null) { traverse(value, fullKey); } }); }; traverse(obj, prefix); return { keyCount, charCount, maxLength: maxLength === 0 ? 0 : maxLength, minLength: minLength === Infinity ? 0 : minLength, emptyKeys, longKeys, keys }; } // Generate size comparison analysis generateSizeComparison() { logger.info(t("sizing.generating_size_comparisons")); const languages = Object.keys(this.stats.languages); const baseLanguage = languages.includes(this.sourceLanguage) ? this.sourceLanguage : languages[0]; if (!baseLanguage) { logger.warn(t("sizing.no_languages_found_for_comparison")); return; } this.stats.summary = { baseLanguage, totalLanguages: languages.length, sizeVariations: {}, problematicKeys: [], recommendations: [] }; // Compare each language to base language languages.forEach(lang => { if (lang === baseLanguage) return; const baseStats = this.stats.languages[baseLanguage]; const langStats = this.stats.languages[lang]; const sizeDiff = baseStats.totalCharacters > 0 ? ((langStats.totalCharacters - baseStats.totalCharacters) / baseStats.totalCharacters) * 100 : 0; this.stats.summary.sizeVariations[lang] = { characterDifference: langStats.totalCharacters - baseStats.totalCharacters, percentageDifference: sizeDiff.toFixed(2), isProblematic: Math.abs(sizeDiff) > this.threshold }; }); // Find problematic keys (significant size differences) Object.entries(this.stats.keys).forEach(([key, langData]) => { const baseLang = langData[baseLanguage]; if (!baseLang) return; const variations = []; Object.entries(langData).forEach(([lang, data]) => { if (lang === baseLanguage) return; const diff = baseLang.length > 0 ? ((data.length - baseLang.length) / baseLang.length) * 100 : 0; if (Math.abs(diff) > this.threshold) { variations.push({ language: lang, characterDifference: data.length - baseLang.length, percentageDifference: diff.toFixed(2), baseLength: baseLang.length, currentLength: data.length }); } }); if (variations.length > 0) { this.stats.summary.problematicKeys.push({ key, variations }); } }); this.generateFileComparison(); // Generate recommendations this.generateRecommendations(); // Generate expansion predictions if requested if (this.predictExpansion) { this.generateExpansionPredictions(); } } generateFileComparison() { const languages = Object.keys(this.stats.files); const baseLanguage = this.stats.summary.baseLanguage || languages[0]; const fileSets = {}; const allFiles = new Set(); languages.forEach(language => { const files = Object.keys(this.stats.files[language]?.perFiles || {}).sort(); fileSets[language] = files; files.forEach(file => allFiles.add(file)); }); const baseFiles = new Set(fileSets[baseLanguage] || []); const commonFiles = [...allFiles].filter(file => languages.every(language => fileSets[language].includes(file))).sort(); const missingFilesByLanguage = {}; const extraFilesByLanguage = {}; languages.forEach(language => { const currentFiles = new Set(fileSets[language]); missingFilesByLanguage[language] = [...allFiles].filter(file => !currentFiles.has(file)).sort(); extraFilesByLanguage[language] = fileSets[language].filter(file => !baseFiles.has(file)).sort(); }); this.stats.summary.fileComparison = { baseLanguage, totalUniqueFiles: allFiles.size, commonFiles, fileSets, missingFilesByLanguage, extraFilesByLanguage, hasMismatches: Object.values(missingFilesByLanguage).some(files => files.length > 0) }; } // Generate optimization recommendations generateRecommendations() { const recommendations = []; // Check for large size variations Object.entries(this.stats.summary.sizeVariations).forEach(([lang, data]) => { if (data.isProblematic) { const comparison = data.percentageDifference > 0 ? t("sizing.longer") : t("sizing.shorter"); const absPercentage = Math.abs(data.percentageDifference); recommendations.push(t("sizing.review_translations", { lang, absPercentage, comparison })); } }); // Check for problematic keys if (this.stats.summary.problematicKeys.length > 0) { const problematicKeyNames = this.stats.summary.problematicKeys.map(item => item.key).join(', '); recommendations.push(t("sizing.problematic_keys", { count: this.stats.summary.problematicKeys.length, problematicKeys: problematicKeyNames })); } // Check for very long translations Object.entries(this.stats.languages).forEach(([lang, data]) => { if (data.longKeys > 0) { recommendations.push(t("sizing.long_translations", { lang, count: data.longKeys })); } }); this.stats.summary.recommendations = recommendations; } // Language pair expansion reference table (average % expansion from English) // Values represent typical character count ratio (target/source) minus 1, as percentage getExpansionReference() { return { de: 35, es: 25, fr: 20, it: 15, pt: 20, nl: 30, sv: 15, ru: 50, uk: 45, pl: 30, cs: 25, sk: 25, bg: 40, sr: 40, ja: -40, zh: -45, ko: -35, th: -30, vi: -20, km: -5, ar: 15, he: 10, fa: 20, tr: 10, fi: 25, hu: 20, el: 15, da: 10, nb: 10, ro: 15, id: 5, ms: 5, hi: 15, bn: 20, ta: 10, te: 15, mr: 20, gu: 20, ml: 25, kn: 15 }; } classifyExpansionRisk(ratio) { const absRatio = Math.abs(ratio); if (absRatio < 30) return { tier: 'safe', label: 'Safe', color: 'green' }; if (absRatio < 50) return { tier: 'warning', label: 'Warning', color: 'yellow' }; return { tier: 'critical', label: 'Critical', color: 'red' }; } generateExpansionPredictions() { const languages = Object.keys(this.stats.languages); if (languages.length < 2) return; const baseLanguage = this.stats.summary.baseLanguage || this.config.sourceLanguage || 'en'; const expansionRef = this.getExpansionReference(); const predictions = { baseLanguage, perLanguage: {}, perKey: {}, topExpandedKeys: [], summary: { safe: 0, warning: 0, critical: 0, total: 0 } }; for (const lang of languages) { if (lang === baseLanguage) continue; const baseStats = this.stats.languages[baseLanguage]; const langStats = this.stats.languages[lang]; if (!baseStats || !langStats) continue; const actualRatio = baseStats.totalCharacters > 0 ? ((langStats.totalCharacters - baseStats.totalCharacters) / baseStats.totalCharacters) * 100 : 0; const referenceRatio = expansionRef[lang] || 10; const risk = this.classifyExpansionRisk(actualRatio); predictions.perLanguage[lang] = { actualExpansionPercent: Math.round(actualRatio * 100) / 100, referenceExpansionPercent: referenceRatio, riskTier: risk.tier, riskLabel: risk.label, totalKeys: langStats.totalKeys, totalChars: langStats.totalCharacters }; } const keyEntries = []; for (const [key, langData] of Object.entries(this.stats.keys)) { const baseData = langData[baseLanguage]; if (!baseData || baseData.length === 0) continue; const keyPredictions = {}; for (const [lang, data] of Object.entries(langData)) { if (lang === baseLanguage) continue; const ratio = ((data.length - baseData.length) / baseData.length) * 100; const risk = this.classifyExpansionRisk(ratio); keyPredictions[lang] = { sourceLength: baseData.length, targetLength: data.length, expansionPercent: Math.round(ratio * 100) / 100, riskTier: risk.tier }; predictions.summary[risk.tier]++; predictions.summary.total++; } const maxExpansion = Math.max(...Object.values(keyPredictions).map(p => Math.abs(p.expansionPercent))); keyEntries.push({ key, maxExpansion, predictions: keyPredictions }); } keyEntries.sort((a, b) => b.maxExpansion - a.maxExpansion); predictions.topExpandedKeys = keyEntries.slice(0, 30); predictions.perKey = Object.fromEntries(keyEntries.slice(0, 200).map(e => [e.key, e.predictions])); this.expansionPredictions = predictions; this.stats.expansionPredictions = predictions; } displayExpansionPredictions() { if (!this.expansionPredictions) return; const p = this.expansionPredictions; console.log('\n' + '='.repeat(60)); console.log(' EXPANSION PREDICTION ANALYSIS'); console.log('='.repeat(60)); console.log(` Base Language: ${p.baseLanguage}`); console.log(` Total Key-Language Pairs Analyzed: ${p.summary.total}`); console.log(` Safe (<30%): ${p.summary.safe} | Warning (30-50%): ${p.summary.warning} | Critical (>50%): ${p.summary.critical}`); console.log('\n PER-LANGUAGE EXPANSION RATIOS:'); const langRows = Object.entries(p.perLanguage).map(([lang, data]) => ({ language: lang, actual: `${data.actualExpansionPercent > 0 ? '+' : ''}${data.actualExpansionPercent}%`, reference: `${data.referenceExpansionPercent > 0 ? '+' : ''}${data.referenceExpansionPercent}%`, risk: data.riskLabel })); console.log(this.createTable([ { key: 'language', label: 'Language' }, { key: 'actual', label: 'Actual', align: 'right' }, { key: 'reference', label: 'Reference', align: 'right' }, { key: 'risk', label: 'Risk Tier' } ], langRows)); if (p.topExpandedKeys.length > 0) { console.log('\n TOP EXPANDED KEYS (highest risk of UI overflow):'); const keyRows = p.topExpandedKeys.slice(0, 15).map(entry => { const langs = Object.entries(entry.predictions).map(([l, d]) => `${l}:${d.expansionPercent > 0 ? '+' : ''}${d.expansionPercent}%` ).join(' '); return { key: entry.key, maxExp: `${entry.maxExpansion > 0 ? '+' : ''}${Math.round(entry.maxExpansion)}%`, languages: langs }; }); console.log(this.createTable([ { key: 'key', label: 'Key' }, { key: 'maxExp', label: 'Max Exp', align: 'right' }, { key: 'languages', label: 'Per-Language' } ], keyRows)); } console.log('\n RECOMMENDATIONS:'); if (p.summary.critical > 0) { console.log(` - ${p.summary.critical} key-language pairs have >50% expansion — review UI layouts for truncation risk`); } if (p.summary.warning > 0) { console.log(` - ${p.summary.warning} key-language pairs have 30-50% expansion — test on target languages`); } console.log(' - Use the reference expansion ratios to plan UI element sizing for unsupported languages'); } // Display concise folder-level results displayFolderResults() { console.log("\n" + t("sizing.sizing_analysis_results")); console.log(t("sizing.lineSeparator")); // Folder-level summary table console.log("\n" + t("sizing.folder_summary_title")); const folderRows = Object.entries(this.stats.files).map(([lang, data]) => { const langData = this.stats.languages[lang]; return { language: lang, files: data.fileCount, sizeKB: data.sizeKB, totalKeys: langData.totalKeys, avgLength: langData.averageKeyLength.toFixed(1), totalChars: langData.totalCharacters }; }); console.log(this.createTable([ { key: 'language', label: 'Language' }, { key: 'files', label: 'Files', align: 'right' }, { key: 'sizeKB', label: 'Size(KB)', align: 'right' }, { key: 'totalKeys', label: 'Keys', align: 'right' }, { key: 'avgLength', label: 'Avg Len', align: 'right' }, { key: 'totalChars', label: 'Total Chars', align: 'right' } ], folderRows)); // Language comparison summary console.log("\n" + t("sizing.language_comparison_title")); const comparisonRows = Object.entries(this.stats.summary.sizeVariations || {}).map(([lang, data]) => ({ language: lang, charDiff: `${data.characterDifference > 0 ? '+' : ''}${data.characterDifference}`, percent: `${data.percentageDifference > 0 ? '+' : ''}${data.percentageDifference}%`, status: data.isProblematic ? 'WARN' : 'OK' })); if (comparisonRows.length > 0) { console.log(this.createTable([ { key: 'language', label: 'Language' }, { key: 'charDiff', label: 'Char Diff', align: 'right' }, { key: 'percent', label: 'Diff %', align: 'right' }, { key: 'status', label: 'Status' } ], comparisonRows)); } else { console.log("No language comparison available."); } this.displayFileComparison(); if (this.detailed) { this.displayPerFileResults(); } // Summary stats console.log("\n" + t("sizing.summary_stats", { totalLanguages: Object.keys(this.stats.languages).length, totalKeys: Object.keys(this.stats.keys).length, reportPath: this.outputDir })); if (this.detailedKeys) { this.displayDetailedKeys(); } if (this.predictExpansion && this.expansionPredictions) { this.displayExpansionPredictions(); } } displayFileComparison() { const comparison = this.stats.summary.fileComparison; if (!comparison) return; console.log("\nFile Set Comparison"); const rows = Object.keys(this.stats.files).map(language => ({ language, files: this.stats.files[language].fileCount, missing: comparison.missingFilesByLanguage[language]?.length || 0, extra: comparison.extraFilesByLanguage[language]?.length || 0 })); console.log(this.createTable([ { key: 'language', label: 'Language' }, { key: 'files', label: 'Files', align: 'right' }, { key: 'missing', label: 'Missing', align: 'right' }, { key: 'extra', label: 'Extra vs Base', align: 'right' } ], rows)); if (comparison.hasMismatches) { Object.entries(comparison.missingFilesByLanguage).forEach(([language, files]) => { if (files.length > 0) { console.log(`${language} missing: ${files.join(', ')}`); } }); } } displayPerFileResults() { console.log("\nPer-File Analysis"); const rows = []; Object.entries(this.stats.languages).forEach(([language, languageData]) => { Object.entries(languageData.files || {}).forEach(([fileName, fileData]) => { const fileSizeData = this.stats.files[language]?.perFiles?.[fileName] || {}; rows.push({ language, file: fileName, sizeKB: fileSizeData.sizeKB || '0.00', keys: fileData.totalKeys, avgLength: fileData.averageKeyLength.toFixed(1), totalChars: fileData.totalCharacters }); }); }); if (rows.length === 0) { console.log("No per-file analysis available."); return; } console.log(this.createTable([ { key: 'language', label: 'Language' }, { key: 'file', label: 'File' }, { key: 'sizeKB', label: 'Size(KB)', align: 'right' }, { key: 'keys', label: 'Keys', align: 'right' }, { key: 'avgLength', label: 'Avg Len', align: 'right' }, { key: 'totalChars', label: 'Total Chars', align: 'right' } ], rows)); } // Display detailed key analysis (only when explicitly requested) displayDetailedKeys() { console.log("\n" + t("sizing.detailed_key_analysis_title")); console.log(t("sizing.lineSeparator")); let counter = 0; Object.entries(this.stats.keys).forEach(([key, data]) => { if (counter >= 50) { console.log(t("sizing.too_many_keys_warning")); return; } console.log(t("sizing.key_analysis_header", { key })); Object.entries(data).forEach(([lang, keyData]) => { const length = keyData.length; const isEmpty = length === 0; const isLong = length > this.threshold; const status = isEmpty ? t("sizing.status_empty") : isLong ? t("sizing.status_long") : t("sizing.status_ok"); console.log(t("sizing.key_analysis_detail", { lang, length, status, translation: `${length} chars` })); }); console.log(""); counter++; }); } // Generate human-readable report async generateHumanReadableReport() { if (!this.outputReport) return; logger.info(t("sizing.generating_detailed_report")); const validatedOutputDir = SecurityUtils.validatePath(this.outputDir, process.cwd()); if (!validatedOutputDir) { throw new Error(t("sizing.invalidOutputDirectoryError", { outputDir: this.outputDir })); } // Ensure output directory exists if (!SecurityUtils.safeExistsSync(validatedOutputDir)) { SecurityUtils.safeMkdirSync(validatedOutputDir, process.cwd(), { recursive: true }); } const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); // Generate human-readable text report const textReportPath = SecurityUtils.validatePath(path.join(validatedOutputDir, `sizing-analysis-${timestamp}.txt`), process.cwd()); if (!textReportPath) { throw new Error(t("sizing.invalidReportFileError")); } let textReport = this.generateTextReport(timestamp); const textSuccess = await SecurityUtils.safeWriteFile(textReportPath, textReport, process.cwd()); if (textSuccess) { logger.info(t("sizing.human_report_saved", { reportPath: textReportPath })); } // Generate JSON for programmatic access const jsonReportPath = SecurityUtils.validatePath(path.join(validatedOutputDir, `sizing-analysis-${timestamp}.json`), process.cwd()); if (!jsonReportPath) { throw new Error(t("sizing.invalidReportFileError")); } const report = { timestamp: new Date().toISOString(), configuration: { sourceDir: this.sourceDir, languages: this.languages, threshold: this.threshold }, analysis: this.stats, metadata: { totalFiles: Object.values(this.stats.files).reduce((total, data) => total + (data.fileCount || 0), 0), totalLanguages: Object.keys(this.stats.languages).length, totalKeys: Object.keys(this.stats.keys).length } }; const jsonSuccess = await SecurityUtils.safeWriteFile(jsonReportPath, JSON.stringify(report, null, 2), process.cwd()); if (jsonSuccess) { SecurityUtils.logSecurityEvent('Sizing report saved', 'info', { jsonReportPath }); } // Generate CSV if requested if (this.format === 'csv') { await this.generateCSVReport(timestamp); } } // Generate human-readable text report generateTextReport(timestamp) { let report = `# i18n Sizing Analysis Report Generated: ${new Date().toISOString()} ## Configuration - Source Directory: ${this.sourceDir} - Languages: ${(this.languages.length > 0 ? this.languages : Object.keys(this.stats.languages)).join(', ')} - Threshold: ${this.threshold}% ## Summary Statistics - Total Languages: ${Object.keys(this.stats.languages).length} - Total Translation Keys: ${Object.keys(this.stats.keys).length} - Total Files: ${Object.values(this.stats.files).reduce((total, data) => total + (data.fileCount || 0), 0)} ## Language Overview `; Object.entries(this.stats.languages).forEach(([lang, data]) => { const fileData = this.stats.files[lang] || { sizeKB: 0, lines: 0, characters: 0 }; report += ` ### ${lang.toUpperCase()} - Files: ${fileData.fileCount || 0} - File Size: ${fileData.sizeKB} KB - Lines: ${fileData.lines} - Total Characters: ${fileData.characters} - Translation Keys: ${data.totalKeys} - Average Key Length: ${data.averageKeyLength.toFixed(1)} characters - Empty Translations: ${data.emptyKeys} - Long Keys (> ${this.threshold} chars): ${data.longKeys} `; }); const fileComparison = this.stats.summary.fileComparison; if (fileComparison) { report += ` ## File Set Comparison - Base Language: ${fileComparison.baseLanguage} - Unique Files: ${fileComparison.totalUniqueFiles} - Common Files: ${fileComparison.commonFiles.length} - Mismatches: ${fileComparison.hasMismatches ? 'Yes' : 'No'} `; Object.entries(fileComparison.missingFilesByLanguage).forEach(([lang, files]) => { report += `- ${lang}: ${files.length} missing${files.length > 0 ? ` (${files.join(', ')})` : ''}\n`; }); } report += ` ## Per-File Analysis `; Object.entries(this.stats.languages).forEach(([lang, data]) => { Object.entries(data.files || {}).forEach(([fileName, fileData]) => { const sizeData = this.stats.files[lang]?.perFiles?.[fileName] || {}; report += `- ${lang}/${fileName}: ${fileData.totalKeys} keys, ${fileData.totalCharacters} chars, ${fileData.averageKeyLength.toFixed(1)} avg chars, ${sizeData.sizeKB || '0.00'} KB\n`; }); }); // Size variations if (this.stats.summary.sizeVariations && Object.keys(this.stats.summary.sizeVariations).length > 0) { report += ` ## Size Variations (vs ${this.stats.summary.baseLanguage}) `; Object.entries(this.stats.summary.sizeVariations).forEach(([lang, data]) => { report += `- ${lang}: ${data.characterDifference > 0 ? '+' : ''}${data.characterDifference} chars (${data.percentageDifference > 0 ? '+' : ''}${data.percentageDifference}%) ${data.isProblematic ? 'PROBLEMATIC' : 'OK'}\n`; }); } // Problematic keys if (this.stats.summary.problematicKeys.length > 0) { report += ` ## Problematic Keys (${this.stats.summary.problematicKeys.length}) `; this.stats.summary.problematicKeys.forEach((item, index) => { report += `${index + 1}. ${item.key}\n`; item.variations.forEach(v => { report += ` ${v.language}: ${v.characterDifference > 0 ? '+' : ''}${v.characterDifference} chars (${v.percentageDifference > 0 ? '+' : ''}${v.percentageDifference}%)\n`; }); report += '\n'; }); } // Recommendations if (this.stats.summary.recommendations.length > 0) { report += ` ## Recommendations `; this.stats.summary.recommendations.forEach((rec, index) => { report += `${index + 1}. ${rec}\n`; }); } // Detailed key analysis if (this.detailedKeys) { report += ` ## Detailed Key Analysis `; Object.entries(this.stats.keys).forEach(([key, data]) => { report += ` ### ${key} `; Object.entries(data).forEach(([lang, keyData]) => { const length = keyData.length; const isEmpty = length === 0; const isLong = length > this.threshold; const status = isEmpty ? 'EMPTY' : isLong ? 'LONG' : 'OK'; report += `- ${lang}: ${length} chars [${status}]\n`; }); }); } // Expansion predictions if (this.expansionPredictions) { const ep = this.expansionPredictions; report += ` ## Expansion Prediction Analysis ### Summary - Base Language: ${ep.baseLanguage} - Safe (<30%): ${ep.summary.safe} - Warning (30-50%): ${ep.summary.warning} - Critical (>50%): ${ep.summary.critical} - Total Pairs: ${ep.summary.total} ### Per-Language Ratios `; Object.entries(ep.perLanguage).forEach(([lang, data]) => { report += `- ${lang}: ${data.actualExpansionPercent > 0 ? '+' : ''}${data.actualExpansionPercent}% (ref: ${data.referenceExpansionPercent > 0 ? '+' : ''}${data.referenceExpansionPercent}%) [${data.riskLabel}]\n`; }); if (ep.topExpandedKeys.length > 0) { report += ` ### Top Expanded Keys `; ep.topExpandedKeys.slice(0, 30).forEach((entry, idx) => { report += `${idx + 1}. ${entry.key} (max: ${entry.maxExpansion > 0 ? '+' : ''}${Math.round(entry.maxExpansion)}%)\n`; Object.entries(entry.predictions).forEach(([l, d]) => { report += ` ${l}: ${d.sourceLength} → ${d.targetLength} chars (${d.expansionPercent > 0 ? '+' : ''}${d.expansionPercent}%) [${d.riskTier}]\n`; }); report += '\n'; }); } } return report; } // Generate CSV report async generateCSVReport(timestamp) { const validatedOutputDir = SecurityUtils.validatePath(this.outputDir, process.cwd()); if (!validatedOutputDir) { throw new Error(t("sizing.invalidOutputDirectoryError", { outputDir: this.outputDir })); } const csvPath = SecurityUtils.validatePath(path.join(validatedOutputDir, `sizing-analysis-${timestamp}.csv`), process.cwd()); if (!csvPath) { throw new Error(t("sizing.invalidCsvFileError")); } let csvContent = 'Language,File Count,File Size (KB),Lines,Characters,Total Keys,Avg Key Length,Max Key Length,Empty Keys,Long Keys\n'; Object.entries(this.stats.files).forEach(([lang]) => { const fileData = this.stats.files[lang]; const langData = this.stats.languages[lang]; csvContent += `${lang},${fileData.fileCount},${fileData.sizeKB},${fileData.lines},${fileData.characters},${langData.totalKeys},${langData.averageKeyLength.toFixed(1)},${langData.maxKeyLength},${langData.emptyKeys},${langData.longKeys}\n`; }); const success = await SecurityUtils.safeWriteFile(csvPath, csvContent, process.cwd()); if (success) { logger.info(t("sizing.csv_report_saved_to", { csvPath })); SecurityUtils.logSecurityEvent('CSV report saved', 'info', { csvPath }); } else { throw new Error(t("sizing.failedToSaveCsvError")); } } // Parse command line arguments without yargs parseArgs() { const args = process.argv.slice(2); const options = { 'source-dir': './locales', 'languages': '', 'output-report': true, 'format': 'table', 'threshold': 50, 'source-language': '', 'detailed': false, 'detailed-keys': false, 'output-dir': './i18ntk-reports', 'help': false, 's': './locales', 'l': '', 'o': true, 'f': 'table', 't': 50, 'd': false, sourceDirExplicit: false, outputDirExplicit: false }; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--help' || arg === '-h') { options.help = true; continue; } // Handle --key=value format const keyValueMatch = arg.match(/^--?([^=]+)=(.+)$/); if (keyValueMatch) { const key = keyValueMatch[1]; const value = keyValueMatch[2]; if (key === 'source-dir' || key === 'locales-dir' || key === 'i18n-dir' || key === 's') { options['source-dir'] = value; options.s = value; options.sourceDirExplicit = true; } else if (key === 'languages' || key === 'l') { options.languages = value; options.l = value; } else if (key === 'output-report' || key === 'o') { options['output-report'] = value.toLowerCase() !== 'false'; options.o = options['output-report']; } else if (key === 'format' || key === 'f') { if (['json', 'csv', 'table'].includes(value)) { options.format = value; options.f = value; } } else if (key === 'threshold' || key === 't') { const numValue = parseInt(value); if (!isNaN(numValue)) { options.threshold = numValue; options.t = numValue; } } else if (key === 'source-language' || key === 'source-locale') { options['source-language'] = value; } else if (key === 'detailed' || key === 'd') { options.detailed = value.toLowerCase() !== 'false'; options.d = options.detailed; } else if (key === 'detailed-keys') { options['detailed-keys'] = value.toLowerCase() !== 'false'; } else if (key === 'predict-expansion') { options['predict-expansion'] = value.toLowerCase() !== 'false'; } else if (key === 'output-dir') { options['output-dir'] = value; options.outputDirExplicit = true; } continue; } // Handle --key value format const match = arg.match(/^--?(.+)/); if (match) { const key = match[1]; const nextArg = args[i + 1]; if (key === 'source-dir' || key === 'locales-dir' || key === 'i18n-dir' || key === 's') { options['source-dir'] = nextArg || options['source-dir']; options.s = options['source-dir']; options.sourceDirExplicit = true; if (nextArg && !nextArg.startsWith('-')) i++; } else if (key === 'languages' || key === 'l') { options.languages = nextArg || options.languages; options.l = options.languages; if (nextArg && !nextArg.startsWith('-')) i++; } else if (key === 'output-report' || key === 'o') { if (nextArg && !nextArg.startsWith('-') && ['true', 'false'].includes(nextArg.toLowerCase())) { options['output-report'] = nextArg.toLowerCase() !== 'false'; options.o = options['output-report']; i++; } else { options['output-report'] = true; options.o = true; } } else if (key === 'format' || key === 'f') { const value = nextArg || options.format; if (['json', 'csv', 'table'].includes(value)) { options.format = value; options.f = value; } if (nextArg && !nextArg.startsWith('-') && ['json', 'csv', 'table'].includes(nextArg)) i++; } else if (key === 'threshold' || key === 't') { const value = parseInt(nextArg); if (!isNaN(value)) { options.threshold = value; options.t = value; } if (nextArg && !nextArg.startsWith('-') && !isNaN(parseInt(nextArg))) i++; } else if (key === 'source-language' || key === 'source-locale') { options['source-language'] = nextArg || options['source-language']; if (nextArg && !nextArg.startsWith('-')) i++; } else if (key === 'detailed' || key === 'd') { if (nextArg && !nextArg.startsWith('-') && ['true', 'false'].includes(nextArg.toLowerCase())) { options.detailed = nextArg.toLowerCase() !== 'false'; options.d = options.detailed; i++; } else { options.detailed = true; options.d = true; } } else if (key === 'detailed-keys') { if (nextArg && !nextArg.startsWith('-') && ['true', 'false'].includes(nextArg.toLowerCase())) { options['detailed-keys'] = nextArg.toLowerCase() !== 'false'; i++; } else { options['detailed-keys'] = true; } } else if (key === 'predict-expansion') { if (nextArg && !nextArg.startsWith('-') && ['true', 'false'].includes(nextArg.toLowerCase())) { options['predict-expansion'] = nextArg.toLowerCase() !== 'false'; i++; } else { options['predict-expansion'] = true; } } else if (key === 'output-dir') { options['output-dir'] = nextArg || options['output-dir']; options.outputDirExplicit = true; if (nextArg && !nextArg.startsWith('-')) i++; } } } if (options.help) { console.log(` I18NTK Sizing Analysis Tool Usage: i18ntk-sizing [options] Options: -s, --locales-dir <dir> Locale directory containing translation files (default: ./locales) --i18n-dir <dir> Alias for --locales-dir --source-dir <dir> Legacy alias for --locales-dir -l, --languages <langs> Comma-separated list of languages to analyze -o, --output-report Generate detailed sizing report (default: true) -f, --format <format> Output format: json, csv, table (default: table) -t, --threshold <number> Size difference threshold for warnings (%) (default: 50) --source-locale <code> Source locale baseline for comparisons (default: en) --source-language <code> Legacy alias for --source-locale -d, --detailed Generate detailed report with more information --detailed-keys Show detailed key-level analysis --predict-expansion Predict UI layout expansion risk per language --output-dir <dir> Output directory for reports (default: ./i18ntk-reports) --help Show this help message `); process.exit(0); } return options; } // Add run method for compatibility with manager async run(options = {}) { const { fromMenu = false } = options; const args = this.parseArgs(); const commonArgs = {}; if (args.sourceDirExplicit) commonArgs.sourceDir = args['source-dir']; if (args.outputDirExplicit) commonArgs.outputDir = args['output-dir']; if (args['source-language']) commonArgs.sourceLanguage = args['source-language']; const persistedConfig = projectConfigManager.getConfig(); const config = await getUnifiedConfig('sizing', commonArgs); const projectRoot = config.projectRoot || persistedConfig.projectRoot || process.cwd(); const configuredSizingDir = persistedConfig.scriptDirectories?.sizing; const translationDir = args.sourceDirExplicit ? args['source-dir'] : (configuredSizingDir || persistedConfig.i18nDir || persistedConfig.sourceDir || config.i18nDir || config.sourceDir || './locales'); this.sourceDir = path.resolve(projectRoot, translationDir); this.outputDir = path.resolve(config.projectRoot || '.', config.outputDir || './i18ntk-reports'); this.threshold = args.threshold ?? config.processing?.sizingThreshold ?? 50; this.languages = args.languages ? args.languages.split(',').map(l => l.trim()) : []; this.outputReport = args['output-report'] !== undefined ? args['output-report'] : false; this.format = args.format || 'table'; this.detailed = ar