i18ntk
Version:
i18ntk (i18n Toolkit) - A comprehensive, enterprise-grade internationalization (i18n) management toolkit for JavaScript/TypeScript projects with advanced analysis, validation, and automation features
1,322 lines (1,114 loc) • 47 kB
JavaScript
/**
* I18N USAGE ANALYSIS TOOLKIT - Version 1.4.3
*
* This script analyzes source code to find unused translation keys,
* missing translations, and provides comprehensive translation completeness analysis.
*
* NEW in v1.4.3:
* - Modular folder structure support
* - Recursive translation file discovery
* - NOT_TRANSLATED analysis
* - Enhanced reporting with completeness statistics
*
* Usage:
* npm run i18ntk:usage
* npm run i18ntk:usage -- --source-dir=./src
* npm run i18ntk:usage -- --i18n-dir=./src/i18n/locales
* npm run i18ntk:usage -- --output-report
*
* Alternative direct usage:
* node i18ntk-usage.js
*/
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const { loadTranslations, t } = require('../utils/i18n-helper');
const settingsManager = require('../settings/settings-manager');
const SecurityUtils = require('../utils/security');
const AdminCLI = require('../utils/admin-cli');
// Enhanced configuration that prioritizes settings over auto-detection
async function getConfig() {
try {
const settings = settingsManager.getSettings();
// Use per-script directory configuration if available, fallback to global settings
let sourceDir = settings.scriptDirectories?.usage || settings.sourceDir;
let i18nDir = settings.scriptDirectories?.usage || settings.i18nDir;
// Auto-detect only if settings don't specify directories
if (!sourceDir) {
const possibleSourceDirs = [
'./main', // Primary source directory for this project
'./src',
'./app',
'./components',
'./pages',
'./views',
'./client',
'./frontend',
'./' // Current directory as fallback
];
for (const dir of possibleSourceDirs) {
if (fs.existsSync(dir)) {
try {
const files = fs.readdirSync(dir);
const hasCodeFiles = files.some(file =>
['.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte'].includes(path.extname(file))
);
if (hasCodeFiles) {
sourceDir = dir;
break;
}
} catch (error) {
// Continue checking
}
}
}
sourceDir = sourceDir || './src'; // Final fallback
}
if (!i18nDir) {
const possibleI18nDirs = [
'./locales',
'./src/locales',
'./src/i18n',
'./src/i18n/locales',
'./app/locales',
'./app/i18n',
'./public/locales',
'./assets/locales',
'./translations',
'./lang'
];
for (const dir of possibleI18nDirs) {
if (fs.existsSync(dir)) {
try {
const items = fs.readdirSync(dir);
const hasLanguageDirs = items.some(item => {
const itemPath = path.join(dir, item);
if (fs.statSync(itemPath).isDirectory()) {
return ['en', 'de', 'es', 'fr', 'ru', 'ja', 'zh'].includes(item);
}
return item.endsWith('.json');
});
if (hasLanguageDirs) {
i18nDir = dir;
break;
}
} catch (error) {
// Continue checking
}
}
}
i18nDir = i18nDir || './locales'; // Final fallback
}
const config = {
sourceDir: sourceDir,
i18nDir: i18nDir,
sourceLanguage: settings.sourceLanguage || 'en',
outputDir: settings.outputDir || './i18ntk-reports',
excludeDirs: settings.processing?.excludeDirs || [
'node_modules', '.git', 'dist', 'build', '.next', '.nuxt',
'i18ntk-reports', 'reports', 'dev', 'utils', 'test', 'tests'
],
includeExtensions: settings.processing?.includeExtensions || ['.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte'],
translationPatterns: settings.processing?.translationPatterns || [
/t\(['"`]([^'"`]+)['"`]\)/g,
/\$t\(['"`]([^'"`]+)['"`]\)/g,
/i18n\.t\(['"`]([^'"`]+)['"`]\)/g,
/useTranslation\(\).*t\(['"`]([^'"`]+)['"`]\)/g
]
};
console.log(t('usage.detectedSourceDirectory', { sourceDir: config.sourceDir }));
console.log(t('usage.detectedI18nDirectory', { i18nDir: config.i18nDir }));
return config;
} catch (error) {
throw new Error(`Configuration error: ${error.message}`);
}
}
class I18nUsageAnalyzer {
constructor(config = {}) {
this.config = config;
this.sourceDir = null;
this.i18nDir = null;
this.sourceLanguageDir = null;
// Initialize class properties
this.availableKeys = new Set();
this.usedKeys = new Set();
this.fileUsage = new Map();
this.translationFiles = new Map(); // New: Track all translation files
this.translationStats = new Map(); // New: Track translation completeness
// Initialize UI i18n for console messages
const UIi18n = require('./i18ntk-ui');
this.ui = new UIi18n();
this.t = this.ui.t.bind(this.ui);
// Initialize readline interface
this.rl = null;
}
// Initialize readline interface
initReadline() {
if (!this.rl) {
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
}
return this.rl;
}
// Close readline interface
closeReadline() {
if (this.rl) {
this.rl.close();
this.rl = null;
}
}
// Prompt for user input
async prompt(question) {
const rl = this.rl || this.initReadline();
return new Promise((resolve) => {
rl.question(question, resolve);
});
}
async initialize() {
try {
const defaultConfig = await getConfig();
this.config = { ...defaultConfig, ...this.config };
// Resolve paths
this.sourceDir = path.resolve(this.config.sourceDir);
this.i18nDir = path.resolve(this.config.i18nDir);
this.sourceLanguageDir = path.join(this.i18nDir, this.config.sourceLanguage);
// Verify translation function
if (typeof this.t !== 'function') {
throw new Error(this.t('validate.translationFunctionNotInitialized') || 'Translation function not properly initialized');
}
await SecurityUtils.logSecurityEvent(this.t('usage.analyzerInitialized'), { component: 'i18ntk-usage' });
} catch (error) {
await SecurityUtils.logSecurityEvent(this.t('usage.analyzerInitFailed'), { component: 'i18ntk-usage', error: error.message });
throw error;
}
}
// Parse command line arguments
async parseArgs() {
try {
const args = process.argv.slice(2);
const parsed = {};
// Convert array to object for processing
const argsObj = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('--')) {
const key = arg.substring(2);
if (key.includes('=')) {
const [k, v] = key.split('=', 2);
argsObj[k] = v;
} else {
argsObj[key] = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : true;
}
}
}
const validatedArgs = await SecurityUtils.validateCommandArgs(argsObj);
// Process validated arguments
for (const [key, value] of Object.entries(validatedArgs)) {
if (key === 'source-dir' && value) {
const sanitized = await SecurityUtils.sanitizeInput(value);
const validated = SecurityUtils.validatePath(sanitized, process.cwd());
if (validated) {
parsed.sourceDir = validated;
}
} else if (key === 'i18n-dir' && value) {
const sanitized = await SecurityUtils.sanitizeInput(value);
const validated = SecurityUtils.validatePath(sanitized, process.cwd());
if (validated) {
parsed.i18nDir = validated;
}
} else if (key === 'output-dir' && value) {
const sanitized = await SecurityUtils.sanitizeInput(value);
const validated = SecurityUtils.validatePath(sanitized, process.cwd());
if (validated) {
parsed.outputDir = validated;
}
} else if (key === 'help') {
parsed.help = true;
} else if (key === 'no-prompt') {
parsed.noPrompt = true;
}
}
await SecurityUtils.logSecurityEvent(this.t('usage.argsParsed'), { component: 'i18ntk-usage', args: parsed });
return parsed;
} catch (error) {
await SecurityUtils.logSecurityEvent(this.t('usage.argsParseFailed'), { component: 'i18ntk-usage', error: error.message });
throw error;
}
}
// NEW: Recursively discover all translation files in modular structure
async discoverTranslationFiles(baseDir, language = this.config.sourceLanguage) {
const translationFiles = [];
const traverse = async (currentDir) => {
try {
const absoluteDir = path.resolve(currentDir);
const validatedPath = SecurityUtils.validatePath(absoluteDir, process.cwd());
if (!validatedPath || !fs.existsSync(validatedPath)) {
return;
}
const items = fs.readdirSync(validatedPath);
for (const item of items) {
const itemPath = path.join(validatedPath, item);
try {
const stat = fs.statSync(itemPath);
if (stat.isDirectory()) {
// Skip excluded directories
if (!this.config.excludeDirs.includes(item)) {
await traverse(itemPath);
}
} else if (stat.isFile()) {
// Look for translation files:
// 1. Direct language files: en.json, de.json, etc.
// 2. Language directory files: en/common.json, de/auth.json, etc.
// 3. Nested modular files: components/en.json, features/auth/en.json, etc.
const fileName = path.basename(item, '.json');
const parentDir = path.basename(path.dirname(itemPath));
if (item.endsWith('.json')) {
// Case 1: Direct language files (en.json)
if (fileName === language) {
translationFiles.push({
filePath: itemPath,
namespace: path.relative(baseDir, path.dirname(itemPath)).replace(/[\\/]/g, '.') || 'root',
language: language,
type: 'direct'
});
}
// Case 2: Files in language directories (en/common.json)
else if (parentDir === language) {
translationFiles.push({
filePath: itemPath,
namespace: fileName,
language: language,
type: 'namespaced'
});
}
}
}
} catch (statError) {
// Skip files that can't be accessed
continue;
}
}
} catch (error) {
await SecurityUtils.logSecurityEvent(this.t('usage.translationDiscoveryError'), {
component: 'i18ntk-usage',
directory: currentDir,
error: error.message
});
}
};
await traverse(baseDir);
return translationFiles;
}
// Get all files recursively from a directory with enhanced filtering
async getAllFiles(dir, extensions = this.config.includeExtensions) {
const files = [];
// Enhanced list of toolkit files to exclude from analysis
const excludeFiles = [
'console-translations.js', 'console-key-checker.js',
'complete-console-translations.js', 'detect-language-mismatches.js',
'export-missing-keys.js', 'maintain-language-purity.js',
'native-translations.js', 'settings-cli.js', 'settings-manager.js',
'test-complete-system.js', 'test-console-i18n.js', 'test-features.js',
'translate-mismatches.js', 'i18ntk-ui.js', 'update-console-i18n.js',
'validate-language-purity.js', 'debugger.js', 'admin-auth.js',
'admin-cli.js'
];
const traverse = async (currentDir) => {
try {
const absoluteDir = path.resolve(currentDir);
const validatedPath = SecurityUtils.validatePath(absoluteDir, process.cwd());
if (!validatedPath || !fs.existsSync(validatedPath)) {
return;
}
const items = fs.readdirSync(validatedPath);
for (const item of items) {
const itemPath = path.join(validatedPath, item);
try {
const stat = fs.statSync(itemPath);
if (stat.isDirectory()) {
// Skip excluded directories
if (!this.config.excludeDirs.includes(item)) {
await traverse(itemPath);
}
} else if (stat.isFile()) {
// Include files with specified extensions, but exclude toolkit files
const ext = path.extname(item);
if (extensions.includes(ext) && !excludeFiles.includes(item)) {
files.push(itemPath);
}
}
} catch (statError) {
// Skip files that can't be accessed
continue;
}
}
} catch (error) {
await SecurityUtils.logSecurityEvent(this.t('usage.fileTraversalError'), {
component: 'i18ntk-usage',
directory: currentDir,
error: error.message
});
}
};
await traverse(dir);
return files;
}
async run(options = {}) {
const { fromMenu = false } = options;
try {
await this.initialize();
// Skip admin authentication when called from menu
if (!fromMenu) {
const isCalledDirectly = require.main === module;
if (isCalledDirectly && !args.noPrompt) {
// Only check admin authentication when running directly and not in no-prompt mode
const AdminAuth = require('../utils/admin-auth');
const adminAuth = new AdminAuth();
await adminAuth.initialize();
const isRequired = await adminAuth.isAuthRequired();
if (isRequired) {
console.log('\n' + this.t('adminCli.authRequiredForOperation', { operation: 'analyze usage' }));
// Create readline interface for PIN input
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const pin = await new Promise(resolve => {
rl.question(this.t('adminCli.enterPin'), resolve);
});
const isValid = await adminAuth.verifyPin(pin);
rl.close();
if (!isValid) {
console.log(this.t('adminCli.invalidPin'));
if (!fromMenu) process.exit(1);
return { success: false, error: 'Authentication failed' };
}
console.log(this.t('adminCli.authenticationSuccess'));
}
}
}
const args = await this.parseArgs();
if (args.help) {
this.showHelp();
return;
}
// Override config with command line arguments
if (args.sourceDir) {
this.config.sourceDir = args.sourceDir;
this.sourceDir = path.resolve(args.sourceDir);
}
if (args.i18nDir) {
this.config.i18nDir = args.i18nDir;
this.i18nDir = path.resolve(args.i18nDir);
this.sourceLanguageDir = path.join(this.i18nDir, this.config.sourceLanguage);
}
console.log(this.t('usage.detectedSourceDirectory', { sourceDir: this.sourceDir }));
console.log(this.t('usage.detectedI18nDirectory', { i18nDir: this.i18nDir }));
// Load available translation keys first
await this.loadAvailableKeys();
// Perform usage analysis
await this.analyzeUsage();
// NEW: Analyze translation completeness
await this.analyzeTranslationCompleteness();
// Generate and display results
const unusedKeys = this.findUnusedKeys();
const missingKeys = this.findMissingKeys();
const notTranslatedStats = this.getNotTranslatedStats();
console.log('\n' + this.t('usage.analysisResults'));
console.log(' ' + this.t('usage.availableKeysCount', { count: this.availableKeys.size }));
console.log(' ' + this.t('usage.usedKeysCount', { count: this.usedKeys.size }));
console.log(this.t('usage.unusedKeysCount', { count: unusedKeys.length }));
console.log(this.t('usage.missingKeysCount', { count: missingKeys.length }));
console.log(this.t('usage.notTranslatedKeysTotal', { total: notTranslatedStats.total }));
// Display translation completeness by language
console.log(this.t('usage.translationCompletenessTitle'));
for (const [language, stats] of this.translationStats) {
const completeness = ((stats.translated / stats.total) * 100).toFixed(1);
console.log(this.t('usage.languageCompletenessStats', { language, completeness, translated: stats.translated, total: stats.total }));
}
if (args.outputReport) {
const report = this.generateUsageReport();
await this.saveReport(report, args.outputDir);
}
console.log('\n' + this.t('usage.analysisCompletedSuccessfully'));
if (require.main === module && !args.noPrompt) {
await this.prompt('\nPress Enter to continue...');
}
this.closeReadline();
} catch (error) {
console.error(this.t('usage.analysisFailedError'), error.message);
this.closeReadline();
await SecurityUtils.logSecurityEvent(this.t('usage.usageAnalysisFailed'), {
component: 'i18ntk-usage',
error: error.message
});
throw error;
}
}
// Show help message
showHelp() {
console.log(this.t('usage.checkUsage.help_message'));
}
// NEW: Enhanced translation key loading with modular support
async getAllTranslationKeys() {
const keys = new Set();
try {
// Discover all translation files in the i18n directory
const translationFiles = await this.discoverTranslationFiles(this.i18nDir, this.config.sourceLanguage);
console.log(this.t('usage.foundTranslationFiles', { count: translationFiles.length }));
for (const fileInfo of translationFiles) {
try {
await SecurityUtils.validatePath(fileInfo.filePath);
const content = await SecurityUtils.safeReadFile(fileInfo.filePath);
const jsonData = await SecurityUtils.safeParseJSON(content);
// Store file info for later analysis
this.translationFiles.set(fileInfo.filePath, fileInfo);
const fileKeys = this.extractKeysFromObject(jsonData, '', fileInfo.namespace);
fileKeys.forEach(key => keys.add(key));
console.log(this.t('usage.fileInfo', { namespace: fileInfo.namespace, keys: fileKeys.length }));
} catch (error) {
console.warn(this.t("usage.checkUsage.failed_to_parse_filename_error", {
fileName: path.basename(fileInfo.filePath),
errorMessage: error.message
}));
await SecurityUtils.logSecurityEvent(this.t('usage.translationFileParseError'), {
component: 'i18ntk-usage',
file: fileInfo.filePath,
error: error.message
});
}
}
} catch (error) {
await SecurityUtils.logSecurityEvent(this.t('usage.translationKeysLoadError'), {
component: 'i18ntk-usage',
error: error.message
});
}
return keys;
}
// Extract keys recursively from translation object
extractKeysFromObject(obj, prefix = '', namespace = '') {
const keys = [];
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
keys.push(...this.extractKeysFromObject(value, fullKey, namespace));
} else {
// Add dot notation key (e.g., "pagination.showing")
keys.push(fullKey);
}
}
return keys;
}
// Extract translation keys from source code with enhanced patterns
extractKeysFromFile(filePath) {
try {
const content = SecurityUtils.safeReadFileSync(filePath);
if (!content) return [];
const keys = [];
// Ensure patterns are RegExp objects with better error handling
const patterns = this.config.translationPatterns.map(pattern => {
try {
if (typeof pattern === 'string') {
return new RegExp(pattern, 'g');
}
return new RegExp(pattern.source, 'g');
} catch (patternError) {
console.warn(`${this.t('usage.invalidPattern')} ${pattern}`);
return null;
}
}).filter(Boolean);
patterns.forEach(pattern => {
try {
let match;
let matchCount = 0;
const maxMatches = 10000; // Safety limit to prevent infinite loops
// Reset regex lastIndex to ensure clean start
pattern.lastIndex = 0;
while ((match = pattern.exec(content)) !== null && matchCount < maxMatches) {
if (match && match[1]) {
keys.push(match[1]);
}
matchCount++;
// Additional safety: if lastIndex doesn't advance, break to prevent infinite loop
if (pattern.lastIndex === 0) {
break;
}
}
if (matchCount >= maxMatches) {
console.warn(`${this.t('usage.patternMatchLimitReached')} ${filePath}`);
}
} catch (execError) {
// Skip patterns that fail to execute
console.warn(`${this.t('usage.patternExecutionFailed')} ${filePath}: ${execError.message}`);
}
});
return keys;
} catch (error) {
console.warn(`${this.t('usage.failedToExtractKeys')} ${filePath}: ${error.message}`);
return [];
}
}
// Analyze usage in source files
async analyzeUsage() {
try {
console.log(this.t('usage.checkUsage.analyzing_source_files'));
// Check if source directory exists
if (!fs.existsSync(this.sourceDir)) {
throw new Error(this.t('validate.sourceLanguageDirectoryNotFound', { sourceDir: this.sourceDir }) || `Source directory not found: ${this.sourceDir}`);
}
const sourceFiles = await this.getAllFiles(this.sourceDir);
console.log(this.t('usage.checkUsage.found_files_in_source', { numFiles: sourceFiles.length }));
// If no files found, exit gracefully
if (sourceFiles.length === 0) {
console.warn(this.t('usage.noSourceFilesFound'));
return;
}
let totalKeysFound = 0;
let processedFiles = 0;
for (const filePath of sourceFiles) {
try {
const keys = this.extractKeysFromFile(filePath);
if (keys.length > 0) {
const relativePath = path.relative(this.sourceDir, filePath);
this.fileUsage.set(relativePath, keys);
keys.forEach(key => {
this.usedKeys.add(key);
totalKeysFound++;
});
}
processedFiles++;
// Progress indicator for large numbers of files
if (sourceFiles.length > 10 && processedFiles % Math.ceil(sourceFiles.length / 10) === 0) {
console.log(t('usage.processedFiles', { processedFiles, totalFiles: sourceFiles.length }));
}
} catch (fileError) {
console.warn(`${this.t('usage.failedToProcessFile')} ${filePath}: ${fileError.message}`);
continue;
}
}
console.log(this.t("usage.checkUsage.found_thisusedkeyssize_unique_", { usedKeysSize: this.usedKeys.size }));
console.log(this.t("usage.checkUsage.total_key_usages_totalkeysfoun", { totalKeysFound }));
} catch (error) {
console.error(this.t('usage.failedToAnalyzeUsage', { error: error.message }));
throw error;
}
}
// Load available translation keys
async loadAvailableKeys() {
console.log(this.t("usage.checkUsage.loading_available_translation_"));
this.availableKeys = await this.getAllTranslationKeys();
console.log(this.t("usage.checkUsage.found_thisavailablekeyssize_av", { availableKeysSize: this.availableKeys.size }));
}
// NEW: Analyze translation completeness across all languages
async analyzeTranslationCompleteness() {
try {
console.log('\n' + this.t('usage.analyzingTranslationCompleteness'));
// Check if i18n directory exists
if (!fs.existsSync(this.i18nDir)) {
console.warn(this.t('usage.i18nDirectoryNotFound', { i18nDir: this.i18nDir }));
return;
}
// Get all available languages
const languages = new Set();
try {
// Discover translation files for all languages
const allLanguageDirs = fs.readdirSync(this.i18nDir)
.filter(item => {
try {
const itemPath = path.join(this.i18nDir, item);
return fs.existsSync(itemPath) && fs.statSync(itemPath).isDirectory();
} catch (error) {
return false;
}
});
for (const lang of allLanguageDirs) {
if (['en', 'de', 'es', 'fr', 'ru', 'ja', 'zh'].includes(lang)) {
languages.add(lang);
}
}
// Also check for direct language files (en.json, de.json, etc.)
const directFiles = fs.readdirSync(this.i18nDir)
.filter(file => file.endsWith('.json'))
.map(file => path.basename(file, '.json'))
.filter(lang => ['en', 'de', 'es', 'fr', 'ru', 'ja', 'zh'].includes(lang));
directFiles.forEach(lang => languages.add(lang));
} catch (error) {
console.warn(`${this.t('usage.errorReadingI18nDirectory')} ${error.message}`);
return;
}
// If no languages found, exit gracefully
if (languages.size === 0) {
console.warn(t('usage.noTranslationLanguagesFound'));
return;
}
// Analyze each language
for (const language of languages) {
try {
const translationFiles = await this.discoverTranslationFiles(this.i18nDir, language);
let totalKeys = 0;
let translatedKeys = 0;
for (const fileInfo of translationFiles) {
try {
if (!fs.existsSync(fileInfo.filePath)) {
continue;
}
const content = await SecurityUtils.safeReadFile(fileInfo.filePath);
const jsonData = await SecurityUtils.safeParseJSON(content);
const stats = this.analyzeFileCompleteness(jsonData);
totalKeys += stats.total;
translatedKeys += stats.translated;
} catch (error) {
console.warn(t('usage.failedToAnalyzeFile', { filePath: fileInfo.filePath, error: error.message }));
continue;
}
}
this.translationStats.set(language, {
total: totalKeys,
translated: translatedKeys,
notTranslated: totalKeys - translatedKeys
});
} catch (error) {
console.warn(t('usage.failedToAnalyzeLanguage', { language, error: error.message }));
continue;
}
}
} catch (error) {
console.warn(t('usage.translationCompletenessAnalysisFailed', { error: error.message }));
// Don't throw error, just continue with the rest of the analysis
}
}
// NEW: Analyze completeness of a single translation file
analyzeFileCompleteness(obj) {
let total = 0;
let translated = 0;
const traverse = (current) => {
for (const [key, value] of Object.entries(current)) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
traverse(value);
} else {
total++;
if (value !== 'NOT_TRANSLATED' && value !== '(NOT TRANSLATED)' &&
value !== 'TRANSLATED' && value !== '(TRANSLATED)' &&
value && value.toString().trim() !== '') {
translated++;
}
}
}
};
traverse(obj);
return { total, translated };
}
// NEW: Get statistics about NOT_TRANSLATED values
getNotTranslatedStats() {
let total = 0;
const byLanguage = new Map();
for (const [language, stats] of this.translationStats) {
const notTranslated = stats.notTranslated;
total += notTranslated;
byLanguage.set(language, notTranslated);
}
return { total, byLanguage };
}
// Find unused keys
findUnusedKeys() {
const unused = [];
for (const key of this.availableKeys) {
let isUsed = false;
// Check exact match
if (this.usedKeys.has(key)) {
isUsed = true;
} else {
// Check if any dynamic key could match this
for (const usedKey of this.usedKeys) {
if (usedKey.endsWith('*')) {
const prefix = usedKey.slice(0, -1);
if (key.startsWith(prefix)) {
isUsed = true;
break;
}
}
}
}
if (!isUsed) {
unused.push(key);
}
}
return unused;
}
// Find missing keys (used but not available)
findMissingKeys() {
const missing = [];
for (const key of this.usedKeys) {
// Skip dynamic keys for missing check
if (key.endsWith('*')) {
continue;
}
if (!this.availableKeys.has(key)) {
missing.push(key);
}
}
return missing;
}
// Find files that use specific keys
findKeyUsage(searchKey) {
const usage = [];
for (const [filePath, keys] of this.fileUsage) {
const matchingKeys = keys.filter(key => {
if (key.endsWith('*')) {
const prefix = key.slice(0, -1);
return searchKey.startsWith(prefix);
}
return key === searchKey;
});
if (matchingKeys.length > 0) {
usage.push({ filePath, keys: matchingKeys });
}
}
return usage;
}
// Enhanced usage report generation
generateUsageReport() {
const unusedKeys = this.findUnusedKeys();
const missingKeys = this.findMissingKeys();
const dynamicKeys = Array.from(this.usedKeys).filter(key => key.endsWith('*'));
const notTranslatedStats = this.getNotTranslatedStats();
const timestamp = new Date().toISOString();
let report = `${this.t('summary.usageReportTitle')}\n`;
report += `${this.t('summary.usageReportGenerated', { timestamp })}\n`;
report += `${this.t('summary.usageReportSourceDir', { sourceDir: this.sourceDir })}\n`;
report += `${this.t('summary.usageReportI18nDir', { i18nDir: this.i18nDir })}\n\n`;
// Summary
report += `${this.t('summary.usageReportSummary')}\n`;
report += `${'='.repeat(50)}\n`;
report += `${this.t('summary.usageReportSourceFilesScanned', { count: this.fileUsage.size })}\n`;
report += `${this.t('summary.usageReportTranslationFilesFound', { count: this.translationFiles.size })}\n`;
report += `${this.t('summary.usageReportAvailableKeys', { count: this.availableKeys.size })}\n`;
report += `${this.t('summary.usageReportUsedKeys', { count: this.usedKeys.size - dynamicKeys.length })}\n`;
report += `${this.t('summary.usageReportDynamicKeys', { count: dynamicKeys.length })}\n`;
report += `${this.t('summary.usageReportUnusedKeys', { count: unusedKeys.length })}\n`;
report += `${this.t('summary.usageReportMissingKeys', { count: missingKeys.length })}\n`;
report += `${this.t('summary.usageReportNotTranslatedKeys', { count: notTranslatedStats.total })}\n\n`;
// Translation completeness
report += `${this.t('summary.usageReportTranslationCompleteness')}\n`;
report += `${'='.repeat(50)}\n`;
for (const [language, stats] of this.translationStats) {
const completeness = ((stats.translated / stats.total) * 100).toFixed(1);
report += `${this.t('summary.usageReportLanguageCompleteness', { language: language.toUpperCase(), completeness, translated: stats.translated, total: stats.total })}\n`;
if (stats.notTranslated > 0) {
report += `${this.t('summary.usageReportNotTranslatedInLanguage', { count: stats.notTranslated })}\n`;
}
}
report += `\n`;
// Translation files discovered
report += `${this.t('summary.usageReportTranslationFilesDiscovered')}\n`;
report += `${'='.repeat(50)}\n`;
for (const [filePath, fileInfo] of this.translationFiles) {
const relativePath = path.relative(this.i18nDir, filePath);
report += `${this.t('summary.usageReportFileInfo', { relativePath, namespace: fileInfo.namespace, type: fileInfo.type })}\n`;
}
report += `\n`;
// Unused keys
if (unusedKeys.length > 0) {
report += `${this.t('summary.usageReportUnusedTranslationKeys')}\n`;
report += `${'='.repeat(50)}\n`;
report += `${this.t('summary.usageReportUnusedKeysDescription')}\n\n`;
unusedKeys.slice(0, 100).forEach(key => {
report += `${this.t('summary.usageReportUnusedKey', { key })}\n`;
});
if (unusedKeys.length > 100) {
report += `${this.t('summary.usageReportMoreUnusedKeys', { count: unusedKeys.length - 100 })}\n`;
}
report += `\n`;
}
// Missing keys
if (missingKeys.length > 0) {
report += `${this.t('summary.usageReportMissingTranslationKeys')}\n`;
report += `${'='.repeat(50)}\n`;
report += `${this.t('summary.usageReportMissingKeysDescription')}\n\n`;
missingKeys.forEach(key => {
report += `${this.t('summary.usageReportMissingKey', { key })}\n`;
// Show where it's used
const usage = this.findKeyUsage(key);
usage.slice(0, 3).forEach(({ filePath }) => {
report += ` ${this.t('summary.usageReportUsedIn', { filePath })}\n`;
});
if (usage.length > 3) {
report += ` ${this.t('summary.usageReportMoreFiles', { count: usage.length - 3 })}\n`;
}
report += `\n`;
});
}
// Dynamic keys
if (dynamicKeys.length > 0) {
report += `${this.t('summary.usageReportDynamicTranslationKeys')}\n`;
report += `${'='.repeat(50)}\n`;
report += `${this.t('summary.usageReportDynamicKeysDescription')}\n\n`;
dynamicKeys.forEach(key => {
report += `${this.t('summary.usageReportDynamicKey', { key })}\n`;
// Show where it's used
const usage = this.findKeyUsage(key);
usage.slice(0, 2).forEach(({ filePath }) => {
report += ` ${this.t('summary.usageReportUsedIn', { filePath })}\n`;
});
report += `\n`;
});
}
// File usage breakdown
report += `${this.t('summary.usageReportFileUsageBreakdown')}\n`;
report += `${'='.repeat(50)}\n`;
const sortedFiles = Array.from(this.fileUsage.entries())
.sort(([,a], [,b]) => b.length - a.length)
.slice(0, 20);
sortedFiles.forEach(([filePath, keys]) => {
report += `${this.t('summary.usageReportFileUsage', { filePath, count: keys.length })}\n`;
});
if (this.fileUsage.size > 20) {
report += `${this.t('summary.usageReportMoreFiles', { count: this.fileUsage.size - 20 })}\n`;
}
return report;
}
// Save report to file
async saveReport(report, outputDir = './i18ntk-reports/usage') {
try {
// Ensure output directory exists
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `usage-analysis-${timestamp}.txt`;
const filepath = path.join(outputDir, filename);
await SecurityUtils.safeWriteFile(filepath, report);
console.log(this.t('usage.reportSavedTo', { reportPath: filepath }));
return filepath;
} catch (error) {
console.error(this.t('usage.failedToSaveReport', { error: error.message }));
}
}
// Main analysis process
async analyze() {
try {
// Initialize if not already done
if (!this.sourceDir || !this.t) {
await this.initialize();
}
await SecurityUtils.logSecurityEvent('analysis_started', { component: 'i18ntk-usage' });
console.log(this.t('usage.checkUsage.title'));
console.log(this.t("usage.checkUsage.message"));
// Parse command line arguments
const args = await this.parseArgs();
// Show help if requested
if (args.help) {
this.showHelp();
return { success: true, help: true };
}
if (args.sourceDir) {
this.config.sourceDir = args.sourceDir;
this.sourceDir = path.resolve(this.config.sourceDir);
}
if (args.i18nDir) {
this.config.i18nDir = args.i18nDir;
this.i18nDir = path.resolve(this.config.i18nDir);
this.sourceLanguageDir = path.join(this.i18nDir, this.config.sourceLanguage);
}
if (args.outputDir) {
this.config.outputDir = args.outputDir;
this.outputDir = path.resolve(this.config.outputDir);
}
console.log(this.t("usage.checkUsage.source_directory_thissourcedir", { sourceDir: this.sourceDir }));
console.log(this.t("usage.checkUsage.i18n_directory_thisi18ndir", { i18nDir: this.i18nDir }));
// Validate directories
await SecurityUtils.validatePath(this.sourceDir);
await SecurityUtils.validatePath(this.i18nDir);
if (!fs.existsSync(this.sourceDir)) {
throw new Error(this.t('validate.sourceLanguageDirectoryNotFound', { sourceDir: this.sourceDir }) || `Source directory not found: ${this.sourceDir}`);
}
if (!fs.existsSync(this.i18nDir)) {
throw new Error(this.t('validate.i18nDirectoryNotFound', { i18nDir: this.i18nDir }) || `I18n directory not found: ${this.i18nDir}`);
}
// Load available keys
await this.loadAvailableKeys();
// Analyze usage
await this.analyzeUsage();
// NEW: Analyze translation completeness
await this.analyzeTranslationCompleteness();
// Display results
// Generate analysis results
const unusedKeys = this.findUnusedKeys();
const missingKeys = this.findMissingKeys();
const dynamicKeys = Array.from(this.usedKeys).filter(key => key.endsWith('*'));
const notTranslatedStats = this.getNotTranslatedStats();
// Display results
console.log(this.t("usage.checkUsage.n"));
console.log(this.t("usage.checkUsage.usage_analysis_results"));
console.log(this.t("usage.checkUsage.message"));
console.log(this.t("usage.checkUsage.source_files_scanned_thisfileu", { fileUsageSize: this.fileUsage.size }));
console.log(this.t("usage.checkUsage.available_translation_keys_thi", { availableKeysSize: this.availableKeys.size }));
console.log(this.t("usage.checkUsage.used_translation_keys_thisused", { usedKeysSize: this.usedKeys.size - dynamicKeys.length }));
console.log(this.t("usage.checkUsage.dynamic_keys_detected_dynamick", { dynamicKeysLength: dynamicKeys.length }));
console.log(this.t("usage.checkUsage.unused_keys_unusedkeyslength", { unusedKeysLength: unusedKeys.length }));
console.log(this.t("usage.checkUsage.missing_keys_missingkeyslength", { missingKeysLength: missingKeys.length }));
console.log(this.t('usage.notTranslatedKeysTotal', { total: notTranslatedStats.total }));
// Removed redundant hardcoded console output to avoid duplication
// The translation completeness and not translated keys count are already logged below
// Display translation completeness
console.log(this.t("usage.checkUsage.translation_completeness_title"));
for (const [language, stats] of this.translationStats) {
const completeness = ((stats.translated / stats.total) * 100).toFixed(1);
console.log(this.t("usage.checkUsage.language_completeness_stats", {
language: language.toUpperCase(),
completeness,
translated: stats.translated,
total: stats.total
}));
}
// Show some examples
if (unusedKeys.length > 0) {
console.log(this.t("usage.checkUsage.n_sample_unused_keys"));
unusedKeys.slice(0, 5).forEach(key => {
console.log(this.t("usage.checkUsage.key", { key }));
});
if (unusedKeys.length > 5) {
console.log(this.t("usage.checkUsage.and_unusedkeyslength_5_more", { count: unusedKeys.length - 5 }));
}
}
if (missingKeys.length > 0) {
console.log(this.t("usage.checkUsage.n_sample_missing_keys"));
missingKeys.slice(0, 5).forEach(key => {
console.log(this.t("usage.checkUsage.key", { key }));
});
if (missingKeys.length > 5) {
console.log(this.t("usage.checkUsage.and_missingkeyslength_5_more", { count: missingKeys.length - 5 }));
}
}
// Generate and save report if requested
if (args.outputReport) {
console.log(this.t("usage.checkUsage.n_generating_detailed_report"));
const report = this.generateUsageReport();
const reportPath = await this.saveReport(report);
console.log(this.t("usage.checkUsage.report_saved_reportpath", { reportPath }));
}
// Recommendations
console.log(this.t("usage.checkUsage.n_recommendations"));
console.log(this.t("usage.checkUsage.message"));
if (unusedKeys.length > 0) {
console.log(this.t("usage.checkUsage.consider_removing_unused_trans"));
}
if (missingKeys.length > 0) {
console.log(this.t("usage.checkUsage.add_missing_translation_keys_t"));
}
if (dynamicKeys.length > 0) {
console.log(this.t("usage.checkUsage.review_dynamic_keys_manually_t"));
}
if (notTranslatedStats.total > 0) {
console.log(this.t('usage.reviewNotTranslatedKeys', { total: notTranslatedStats.total }));
}
if (unusedKeys.length === 0 && missingKeys.length === 0 && notTranslatedStats.total === 0) {
console.log(this.t("usage.checkUsage.all_translation_keys_are_prope"));
}
console.log(this.t("usage.checkUsage.n_next_steps"));
console.log(this.t("usage.checkUsage.1_review_the_analysis_results"));
if (args.outputReport) {
console.log(this.t("usage.checkUsage.2_check_the_detailed_report_fo"));
} else {
console.log(this.t("usage.checkUsage.2_run_with_outputreport_for_de"));
}
console.log(this.t("usage.checkUsage.3_remove_unused_keys_or_add_mi"));
console.log(this.t("usage.checkUsage.4_rerun_analysis_to_verify_imp"));
await SecurityUtils.logSecurityEvent('analysis_completed', {
component: 'i18ntk-usage',
stats: {
availableKeys: this.availableKeys.size,
usedKeys: this.usedKeys.size - dynamicKeys.length,
dynamicKeys: dynamicKeys.length,
unusedKeys: unusedKeys.length,
missingKeys: missingKeys.length,
filesScanned: this.fileUsage.size,
notTranslatedKeys: notTranslatedStats.total
}
});
// Close readline interface to prevent hanging
this.closeReadline();
return {
success: true,
stats: {
availableKeys: this.availableKeys.size,
usedKeys: this.usedKeys.size - dynamicKeys.length,
dynamicKeys: dynamicKeys.length,
unusedKeys: unusedKeys.length,
missingKeys: missingKeys.length,
filesScanned: this.fileUsage.size,
notTranslatedKeys: notTranslatedStats.total,
translationCompleteness: Object.fromEntries(this.translationStats)
},
unusedKeys,
missingKeys,
dynamicKeys,
notTranslatedStats
};
} catch (error) {
console.error(this.t("checkUsage.usage_analysis_failed"));
console.error(error.message);
await SecurityUtils.logSecurityEvent('analysis_failed', {
component: 'i18ntk-usage',
error: error.message
});
// Close readline interface to prevent hanging
this.closeReadline();
return {
success: false,
error: error.message
};
}
}
}
// Run if called directly
if (require.main === module) {
const analyzer = new I18nUsageAnalyzer();
// Check if we're being called from the menu system (stdin has data)
// In that case, we should run with default settings without prompting
const hasStdinData = !process.stdin.isTTY;
if (hasStdinData) {
// When called from menu, consume stdin data and run with defaults
process.stdin.resume();
process.stdin.on('data', () => {});
process.stdin.on('end', () => {
// Run analysis with default settings (no prompts)
analyzer.analyze()
.then((result) => {
if (result.success) {
console.log(analyzer.t('usage.analysisCompletedSuccessfully'));
process.exit(0);
} else {
console.error(analyzer.t('usage.analysisFailed', { error: result.error }));
process.exit(1);
}
})
.catch((error) => {
console.error(analyzer.t('usage.analysisFailed', { error: error.message }));
process.exit(1);
});
});
} else {
// Normal direct execution
analyzer.analyze()
.then((result) => {
if (result.success) {
console.log('\n' + analyzer.t('usage.analysisCompletedSuccessfully'));
process.exit(0);
} else {
console.error('\n' + analyzer.t('usage.analysisFailed', { error: result.error }));
process.exit(1);
}
})
.catch((error) => {
console.error('\n' + analyzer.t('usage.analysisFailed', { error: error.message }));
process.exit(1);
});
}
}
module.exports = I18nUsageAnalyzer;