i18ntk
Version:
đ The fastest i18n toolkit with 97% performance boost! Zero-dependency, enterprise-grade internationalization for React, Vue, Angular, Python, Java, PHP & more. Features PIN protection, auto framework detection, 7+ UI languages, and comprehensive transla
1,289 lines (1,093 loc) ⢠68.4 kB
JavaScript
#!/usr/bin/env node
/**
* I18NTK USAGE ANALYSIS TOOLKIT - Version 1.8.3
*
* This script analyzes source code to find unused translation keys,
* missing translations, and provides comprehensive translation completeness analysis.
*
* NEW in v1.8.3:
* - Enhanced placeholder key detection with validation
* - Framework-specific pattern recognition
* - Advanced translation completeness scoring
* - Security-enhanced path validation
* - Performance-optimized analysis
* - Detailed framework usage reports
*
* Features from v1.8.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 { loadTranslations, t } = require('../utils/i18n-helper');
const { getGlobalReadline, closeGlobalReadline, askHidden } = require('../utils/cli');
const { detectFramework } = require('../utils/framework-detector');
const { getExtractor } = require('../utils/extractor-manager');
const configManager = require('../utils/config-manager');
const SecurityUtils = require('../utils/security');
const AdminCLI = require('../utils/admin-cli');
const SettingsManager = require('../settings/settings-manager');
const settingsManager = new SettingsManager();
const { getUnifiedConfig, parseCommonArgs, displayHelp, validateSourceDir, displayPaths } = require('../utils/config-helper');
const I18nInitializer = require('./i18ntk-init');
const JsonOutput = require('../utils/json-output');
const SetupEnforcer = require('../utils/setup-enforcer');
// Ensure setup is complete before running
(async () => {
try {
await SetupEnforcer.checkSetupCompleteAsync();
} catch (error) {
console.error('Setup check failed:', error.message);
process.exit(1);
}
})();
loadTranslations( 'en', path.resolve(__dirname, '..', 'ui-locales'));
async function getConfig() {
return await getUnifiedConfig('usage');
}
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(); // Track all translation files
this.translationStats = new Map(); // Track translation completeness
this.extractor = getExtractor(config.extractor);
this.placeholderKeys = new Set();
this.placeholderStyles = settingsManager.getDefaultSettings().placeholderStyles || {};
// NEW: Enhanced analysis properties
this.frameworkUsage = new Map(); // Track framework usage per file
this.keyComplexity = new Map(); // Track key complexity analysis
this.startTime = Date.now(); // Track performance metrics
this.version = '1.10.1'; // Version tracking
// Use global translation function
this.rl = null;
}
// Initialize readline interface
initReadline() {
if (!this.rl) {
return getGlobalReadline();
}
}
// Close readline interface
closeReadline() {
const { closeGlobalReadline } = require('../utils/cli');
closeGlobalReadline();
}
// Prompt for user input
async prompt(question) {
const rl = getGlobalReadline();
return new Promise(resolve => {
rl.question(question, (answer) => {
resolve(answer);
});
});
}
async initialize() {
try {
const cliArgs = parseCommonArgs(process.argv.slice(2));
const defaultConfig = await getUnifiedConfig('usage', cliArgs);
this.config = { ...defaultConfig, ...(this.config || {}) };
// Load translations for UI
const uiLanguage = (this.config && this.config.uiLanguage) || 'en';
loadTranslations(uiLanguage, path.resolve(__dirname, '..', 'ui-locales'));
const projectRoot = path.resolve(this.config.projectRoot || '.');
const detected = detectFramework(projectRoot);
if (detected) {
this.config.translationPatterns = detected.patterns;
if (!this.config.includeExtensions) {
this.config.includeExtensions = ['.js', '.jsx', '.ts', '.tsx', '.py', '.pyx', '.pyi'];
}
if (!this.config.excludeDirs) {
this.config.excludeDirs = [];
}
}
this.sourceDir = this.config.sourceDir;
this.i18nDir = this.config.i18nDir;
this.sourceLanguageDir = path.join(this.i18nDir, this.config.sourceLanguage);
if (!fs.existsSync(this.i18nDir)) {
console.warn(t('usage.i18nDirectoryNotFound', { i18nDir: this.i18nDir }));
this.i18nDir = this.sourceDir;
this.config.i18nDir = this.i18nDir;
await configManager.updateConfig({ i18nDir: configManager.toRelative(this.sourceDir) });
this.sourceLanguageDir = path.join(this.i18nDir, this.config.sourceLanguage);
}
displayPaths({ sourceDir: this.sourceDir, i18nDir: this.i18nDir, outputDir: this.config.outputDir });
// Ensure translation patterns are defined
this.config = this.config || {};
this.config.translationPatterns = this.config.translationPatterns || [
/t\(['"`]([^'"`]+)['"`]/g,
/i18n\.t\(['"`]([^'"`]+)['"`]/g,
/useTranslation\(\)\.t\(['"`]([^'"`]+)['"`]/g,
/t\(`([^`]+)`\)/g,
/i18nKey=['"`]([^'"`]+)['"`]/g,
/\$t\(['"`]([^'"`]+)['"`]/g,
/getTranslation\(['"`]([^'"`]+)['"`]/g
];
this.extractor = getExtractor(this.config.extractor);
// Ensure defaults for other config values
this.config = this.config || {};
if (!Array.isArray(this.config.excludeDirs)) {
this.config.excludeDirs = ['node_modules', '.git'];
}
if (!Array.isArray(this.config.includeExtensions) && !Array.isArray(this.config.supportedExtensions)) {
this.config.includeExtensions = ['.js', '.jsx', '.ts', '.tsx', '.py', '.pyx', '.pyi'];
}
await SecurityUtils.logSecurityEvent(t('usage.analyzerInitialized'), { component: 'i18ntk-usage' });
} catch (error) {
await SecurityUtils.logSecurityEvent(t('usage.analyzerInitFailed'), { component: 'i18ntk-usage', error: error.message });
throw error;
}
}
// Normalize CLI arguments to handle both camelCase and hyphenated flags
normalizeArgs(a) {
return {
sourceDir: a.sourceDir ?? a['source-dir'],
i18nDir: a.i18nDir ?? a['i18n-dir'],
outputReport: a.outputReport ?? a['output-report'],
outputDir: a.outputDir ?? a['output-dir'],
uiLanguage: a.uiLanguage ?? a['ui-language'],
help: a.help || a.h,
noPrompt: a.noPrompt ?? a['no-prompt'],
strict: a.strict,
debug: a.debug
};
}
// Parse command line arguments
async parseArgs() {
const args = process.argv.slice(2);
const parsed = {};
for (const arg of args) {
if (arg.startsWith('--')) {
const [key, value] = arg.substring(2).split('=');
if (value !== undefined) {
parsed[key] = value;
} else {
parsed[key] = true;
}
} else if (arg.startsWith('-')) {
const key = arg.substring(1);
parsed[key] = true;
}
}
return this.normalizeArgs(parsed);
}
// NEW: Recursively discover all translation files in modular structure
async discoverTranslationFiles(baseDir, language = (this.config && this.config.sourceLanguage) || 'en') {
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 with null-safety
const excludes = Array.isArray(this.config.excludeDirs) ? this.config.excludeDirs : [];
if (!excludes.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(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 && (this.config.includeExtensions || this.config.supportedExtensions)) || ['.js', '.jsx', '.ts', '.tsx', '.py', '.pyx', '.pyi']) {
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'
];
// Null-safe extensions handling
const safeExtensions = Array.isArray(extensions) ? extensions : ['.js', '.jsx', '.ts', '.tsx', '.py', '.pyx', '.pyi'];
const skipRoot = path.resolve(this.i18nDir || '');
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()) {
const excludes = Array.isArray(this.config.excludeDirs) ? this.config.excludeDirs : [];
if (!excludes.includes(item)) {
// hard-skip the locales root to avoid reading JSON
if (skipRoot && path.resolve(itemPath).startsWith(skipRoot)) continue;
await traverse(itemPath);
}
} else if (stat.isFile()) {
// Skip JSON files entirely to prevent scanning translation files
if (itemPath.endsWith('.json')) continue;
// Include files with specified extensions, but exclude toolkit files
const ext = path.extname(item);
// Ensure extension has dot prefix
const normalizedExt = ext.startsWith('.') ? ext : `.${ext}`;
const normalizedExtensions = safeExtensions.map(ext => ext.startsWith('.') ? ext : `.${ext}`);
if (normalizedExtensions.includes(normalizedExt) && !excludeFiles.includes(item)) {
files.push(itemPath);
}
}
} catch (statError) {
// Skip files that can't be accessed
continue;
}
}
} catch (error) {
await SecurityUtils.logSecurityEvent(t('usage.fileTraversalError'), {
component: 'i18ntk-usage',
directory: currentDir,
error: error.message
});
}
};
await traverse(dir);
return files;
}
async run(options = {}) {
const { fromMenu = false } = options;
// Parse command line arguments for strict/debug flags
const args = await this.parseArgs();
const cliOptions = {
strict: process.argv.includes('--strict'),
debug: process.argv.includes('--debug')
};
if (cliOptions.debug) {
console.log('đ Debug mode enabled');
}
try {
// Ensure config is always initialized
if (!this.config) {
this.config = {};
}
// Ensure configuration is loaded - no need for .i18ntk directory check
if (!this.config) {
this.config = {};
}
// Initialize configuration properly when called from menu
if (fromMenu && !this.sourceDir) {
const baseConfig = await getUnifiedConfig('usage', args);
this.config = { ...baseConfig, ...(this.config || {}) };
const uiLanguage = (this.config && this.config.uiLanguage) || 'en';
loadTranslations(uiLanguage, path.resolve(__dirname, '..', 'ui-locales'));
if (!Array.isArray(this.config.translationPatterns)) {
this.config.translationPatterns = [
/t\(['"`]([^'"`]+)['"`]/g,
/i18n\.t\(['"`]([^'"`]+)['"`]/g,
/useTranslation\(\)\.t\(['"`]([^'"`]+)['"`]/g,
/t\(`([^`]+)`\)/g,
/i18nKey=['"`]([^'"`]+)['"`]/g,
/\$t\(['"`]([^'"`]+)['"`]/g,
/getTranslation\(['"`]([^'"`]+)['"`]/g
];
}
if (!Array.isArray(this.config.excludeDirs)) {
this.config.excludeDirs = ['node_modules', '.git'];
}
if (!Array.isArray(this.config.includeExtensions) && !Array.isArray(this.config.supportedExtensions)) {
this.config.includeExtensions = ['.js', '.jsx', '.ts', '.tsx', '.py', '.pyx', '.pyi'];
}
this.sourceDir = this.config.sourceDir;
this.i18nDir = this.config.i18nDir;
this.sourceLanguageDir = path.join(this.i18nDir, this.config.sourceLanguage);
if (fromMenu && (!this.config.sourceDir || this.config.sourceDir === this.config.i18nDir)) {
console.log('â ď¸ Go to Settings â Directory Settings or run with --source-dir');
}
} else {
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' + t('adminCli.authRequiredForOperation', { operation: 'analyze usage' }));
const pin = await askHidden(t('adminCli.enterPin'));
const isValid = await adminAuth.verifyPin(pin);
if (!isValid) {
console.log(t('adminCli.invalidPin'));
if (!fromMenu) process.exit(1);
return { success: false, error: 'Authentication failed' };
}
console.log(t('adminCli.authenticationSuccess'));
}
}
}
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);
}
if (this.sourceDir || this.i18nDir) {
await configManager.updateConfig({
sourceDir: configManager.toRelative(this.sourceDir || this.config.sourceDir),
i18nDir: configManager.toRelative(this.i18nDir || this.config.i18nDir)
});
}
// Ensure sourceDir points to source code, not locales
if (!args.sourceDir && this.config.sourceDir === this.config.i18nDir) {
// Default to common source directories if not explicitly provided
const possibleSourceDirs = ['src', 'lib', 'app', 'source'];
const projectRoot = this.config.projectRoot || '.';
for (const dir of possibleSourceDirs) {
const testPath = path.resolve(projectRoot, dir);
if (fs.existsSync(testPath)) {
this.config.sourceDir = testPath;
this.sourceDir = testPath;
break;
}
}
// If no common source directory found, use current directory
if (this.config.sourceDir === this.config.i18nDir) {
this.config.sourceDir = projectRoot;
this.sourceDir = projectRoot;
}
}
// đ§ prevent scanning locales as source
if (path.resolve(this.sourceDir) === path.resolve(this.i18nDir)) {
const fallback = path.resolve(this.config.projectRoot || '.', 'src');
console.warn(t('usage.sourceEqualsI18nWarn') ||
`â ď¸ sourceDir equals i18nDir (${this.sourceDir}). Falling back to ${fallback} for source scanning.`);
if (fs.existsSync(fallback)) {
this.sourceDir = fallback;
} else {
console.warn(`â ď¸ Fallback directory ${fallback} does not exist. Using project root for source scanning.`);
this.sourceDir = path.resolve(this.config.projectRoot || '.');
}
this.config.sourceDir = this.sourceDir;
await configManager.updateConfig({
sourceDir: configManager.toRelative(this.sourceDir)
});
}
console.log(t('usage.detectedSourceDirectory', { sourceDir: this.sourceDir }));
console.log(t('usage.detectedI18nDirectory', { i18nDir: this.i18nDir }));
// Load available translation keys first
await this.loadAvailableKeys();
// NEW: Detect framework patterns before analysis
await this.detectFrameworkPatterns();
// Perform usage analysis with enhanced features
await this.analyzeUsage();
// NEW: Validate placeholder keys
await this.validatePlaceholderKeys();
// Analyze translation completeness with enhanced scoring
await this.analyzeTranslationCompleteness();
// Calculate key complexity analysis
await this.analyzeKeyComplexity();
// Generate and display results
const unusedKeys = this.findUnusedKeys();
const missingKeys = this.findMissingKeys();
const notTranslatedStats = this.getNotTranslatedStats();
// Calculate performance metrics
const duration = Date.now() - this.startTime;
console.log('\n' + t('usage.analysisResults'));
console.log(' ' + t('usage.availableKeysCount', { count: this.availableKeys.size }));
console.log(' ' + t('usage.usedKeysCount', { count: this.usedKeys.size }));
console.log(t('usage.unusedKeysCount', { count: unusedKeys.length }));
console.log(t('usage.missingKeysCount', { count: missingKeys.length }));
console.log(t('usage.notTranslatedKeysTotal', { total: notTranslatedStats.total }));
// NEW: Display performance metrics
console.log(`\nđ Performance: ${duration}ms (${this.availableKeys.size} keys processed)`);
// NEW: Display framework usage
if (this.frameworkUsage.size > 0) {
console.log('\nđ ď¸ Framework Detection:');
const frameworkCounts = new Map();
// Aggregate framework counts
for (const [filePath, frameworkInfo] of this.frameworkUsage) {
const frameworkName = frameworkInfo.framework || 'generic';
frameworkCounts.set(frameworkName, (frameworkCounts.get(frameworkName) || 0) + 1);
}
if (frameworkCounts.size > 0) {
for (const [framework, count] of frameworkCounts) {
console.log(` ${framework}: ${count} files`);
}
} else {
console.log(' No Framework: 0 files');
}
} else {
console.log('\nđ ď¸ Framework Detection:');
console.log(' No Framework: 0 files');
}
// NEW: Display key complexity analysis
const complexityValues = Array.from(this.keyComplexity.values()).map(c => c.segments || 0);
const avgComplexity = complexityValues.length > 0 ?
complexityValues.reduce((a, b) => a + b, 0) / complexityValues.length : 0;
console.log(`\nđ Key Complexity: ${avgComplexity.toFixed(2)} avg depth`);
// Sanity check: warn if 0 used keys but available keys exist
if (this.availableKeys.size > 0 && this.usedKeys.size === 0) {
console.warn('\nâ ď¸ ' + (t('operations.usage.noUsedKeysHint') || 'Found translations but no usage in source. Check --source-dir and translationPatterns.'));
}
// Display translation completeness by language with enhanced scoring
console.log(t('common.languageCompletenessTitle'));
for (const [language, stats] of this.translationStats) {
const completeness = ((stats.translated / stats.total) * 100).toFixed(1);
const score = this.calculateTranslationScore(language, stats);
console.log(t('summary.usageReportLanguageCompleteness', {
language: language.toUpperCase(),
completeness,
translated: stats.translated,
total: stats.total
}));
}
if (args.outputReport) {
const report = this.generateUsageReport();
await this.saveReport(report, args.outputDir);
}
console.log('\n' + t('usage.analysisCompletedSuccessfully'));
if (require.main === module && !args.noPrompt) {
await this.prompt('\nPress Enter to continue...');
}
this.closeReadline();
} catch (error) {
console.error(t('usage.analysisFailedError'), error.message);
this.closeReadline();
SecurityUtils.logSecurityEvent(t('usage.usageAnalysisFailed'), {
component: 'i18ntk-usage',
error: error.message
});
throw error;
}
}
// Show help message
showHelp() {
console.log(`
đ i18ntk usage - Translation key usage analysis (v1.8.3)
Usage:
node i18ntk-usage.js [options]
npm run i18ntk:usage -- [options]
Options:
--source-dir=<path> Source code directory to scan (default: ./src)
--i18n-dir=<path> Directory containing translation files (default: ./src/i18n/locales)
--output-report Generate detailed usage report
--output-dir=<path> Directory for output reports (default: ./i18ntk-reports/usage)
--strict Show all warnings and errors during analysis
--debug Enable debug mode with stack traces
--no-prompt Skip interactive prompts (useful for CI/CD)
--validate-placeholders Enable placeholder key validation
--framework-detect Enable framework-specific pattern detection
--performance-mode Enable performance metrics tracking
--help, -h Show this help message
Examples:
node i18ntk-usage.js --source-dir=./src --i18n-dir=./translations --output-report
npm run i18ntk:usage -- --strict --debug --validate-placeholders
node i18ntk-usage.js --no-prompt --performance-mode --output-dir=./reports
Analysis Features (v1.8.3):
⢠Detects unused translation keys
⢠Identifies missing translation keys
⢠Shows translation completeness by language
⢠Reports NOT_TRANSLATED values
⢠Supports modular folder structures
⢠Enhanced placeholder key detection
⢠Framework-specific pattern recognition (React, Vue, Angular)
⢠Advanced translation completeness scoring
⢠Performance metrics and optimization tracking
⢠Key complexity analysis
⢠Security-enhanced path validation
⢠Detailed reporting with validation errors
`);
}
// NEW: Enhanced translation key loading with modular support
async getAllTranslationKeys() {
const keys = new Set();
const isStrict = process.argv.includes('--strict');
const isDebug = process.argv.includes('--debug');
try {
// Discover all translation files in the i18n directory
const translationFiles = await this.discoverTranslationFiles(this.i18nDir, this.config.sourceLanguage);
console.log(t('usage.foundTranslationFiles', { count: translationFiles.length }));
for (const fileInfo of translationFiles) {
try {
await SecurityUtils.validatePath(fileInfo.filePath);
// Check if file exists and is readable
if (!fs.existsSync(fileInfo.filePath)) {
if (isDebug || isStrict) {
console.warn(`â ď¸ File not found: ${path.basename(fileInfo.filePath)}`);
}
continue;
}
const content = await SecurityUtils.safeReadFile(fileInfo.filePath);
// Handle empty files
if (!content || content.trim() === '') {
if (isDebug || isStrict) {
console.warn(`â ď¸ Empty file: ${path.basename(fileInfo.filePath)}`);
}
continue;
}
const jsonData = await SecurityUtils.safeParseJSON(content);
// Validate JSON structure before processing
if (jsonData === null || jsonData === undefined) {
if (isDebug || isStrict) {
console.warn(`â ď¸ Null/undefined JSON data: ${path.basename(fileInfo.filePath)}`);
}
continue;
}
if (typeof jsonData !== 'object') {
if (isDebug || isStrict) {
console.warn(`â ď¸ Invalid JSON structure (not an object): ${path.basename(fileInfo.filePath)}`);
}
continue;
}
if (Array.isArray(jsonData)) {
if (isDebug || isStrict) {
console.warn(`â ď¸ Invalid JSON structure (array instead of object): ${path.basename(fileInfo.filePath)}`);
}
continue;
}
// 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));
this.collectPlaceholderKeys(jsonData, '', fileInfo.language);
if (isDebug) {
console.log(t('usage.fileInfo', { namespace: fileInfo.namespace, keys: fileKeys.length }));
}
} catch (error) {
if (isDebug || isStrict) {
console.warn(`â Failed to extract keys from ${path.basename(fileInfo.filePath)}: ${error.message}`);
}
if (isDebug) {
console.error(error.stack);
}
await SecurityUtils.logSecurityEvent(t('usage.translationFileParseError'), {
component: 'i18ntk-usage',
file: fileInfo.filePath,
error: error.message
});
}
}
} catch (error) {
await SecurityUtils.logSecurityEvent(t('usage.translationKeysLoadError'), {
component: 'i18ntk-usage',
error: error.message
});
}
return keys;
}
// Extract keys recursively from translation object
extractKeysFromObject(obj, prefix = '', namespace = '') {
const keys = [];
// Validate input object before processing
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
return keys; // Return empty array for invalid input
}
try {
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);
}
}
} catch (error) {
// Handle any unexpected errors during key extraction
console.warn(`â ď¸ Error during key extraction: ${error.message}`);
return keys;
}
return keys;
}
collectPlaceholderKeys(obj, prefix = '', language) {
const patterns = this.placeholderStyles[language] || [];
const regexes = patterns.map(p => new RegExp(p));
if (typeof obj !== 'object' || obj === null) return;
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
this.collectPlaceholderKeys(value, fullKey, language);
} else if (typeof value === 'string' && regexes.some(r => r.test(value))) {
this.placeholderKeys.add(fullKey);
}
}
}
// Extract translation keys from source code with enhanced patterns
extractKeysFromFile(filePath) {
try {
const content = SecurityUtils.safeReadFileSync(filePath);
if (!content) return [];
// Skip JSON files entirely to prevent scanning translation files
if (filePath.endsWith('.json')) return [];
const rawPatterns = Array.isArray(this.config.translationPatterns) ? this.config.translationPatterns : [];
if (rawPatterns.length === 0) return [];
return this.extractor.extract(content, rawPatterns);
// Null-safe translation patterns handling
} catch (error) {
console.warn(`${t('usage.failedToExtractKeys')} ${filePath}: ${error.message}`);
return [];
}
}
// Analyze usage in source files
async analyzeUsage() {
try {
console.log(t('usage.checkUsage.analyzing_source_files'));
// Check if source directory exists
if (!fs.existsSync(this.sourceDir)) {
throw new Error(this.t('usage.sourceDirectoryDoesNotExist', { dir: this.sourceDir }) || `Source directory not found: ${this.sourceDir}`);
}
const sourceFiles = await this.getAllFiles(this.sourceDir);
console.log(t('usage.checkUsage.found_files_in_source', { numFiles: sourceFiles.length }));
// If no files found, exit gracefully
if (sourceFiles.length === 0) {
console.warn(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(`${t('usage.failedToProcessFile')} ${filePath}: ${fileError.message}`);
continue;
}
}
console.log(t("usage.checkUsage.found_thisusedkeyssize_unique_", { usedKeysSize: this.usedKeys.size }));
console.log(t("usage.checkUsage.total_key_usages_totalkeysfoun", { totalKeysFound }));
} catch (error) {
console.error(t('usage.failedToAnalyzeUsage', { error: error.message }));
throw error;
}
}
// Load available translation keys
async loadAvailableKeys() {
console.log(t("usage.checkUsage.loading_available_translation_"));
this.availableKeys = await this.getAllTranslationKeys();
console.log(t("usage.checkUsage.found_thisavailablekeyssize_av", { availableKeysSize: this.availableKeys.size }));
if (this.placeholderKeys.size > 0) {
console.log('Placeholder translation keys detected: ' + Array.from(this.placeholderKeys).join(', '));
}
}
// NEW: Analyze translation completeness across all languages
async analyzeTranslationCompleteness() {
try {
console.log('\nđ Analyzing translation completeness...');
const isDebug = process.argv.includes('--debug');
const isStrict = process.argv.includes('--strict');
// Check if i18n directory exists
if (!fs.existsSync(this.i18nDir)) {
console.warn(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(`${t('usage.errorReadingI18nDirectory')} ${error.message}`);
return;
}
// If no languages found, exit gracefully
if (languages.size === 0) {
console.warn(t('usage.checkUsage.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)) {
if (isDebug || isStrict) {
console.warn(`â ď¸ File not found: ${path.basename(fileInfo.filePath)}`);
}
continue;
}
const content = await SecurityUtils.safeReadFile(fileInfo.filePath);
// Handle empty files
if (!content || content.trim() === '') {
if (isDebug || isStrict) {
console.warn(`â ď¸ Empty file: ${path.basename(fileInfo.filePath)}`);
}
continue;
}
const jsonData = await SecurityUtils.safeParseJSON(content);
// Validate JSON structure before processing
if (jsonData === null || jsonData === undefined) {
if (isDebug || isStrict) {
console.warn(`â ď¸ Null/undefined JSON data: ${path.basename(fileInfo.filePath)}`);
}
continue;
}
if (typeof jsonData !== 'object') {
if (isDebug || isStrict) {
console.warn(`â ď¸ Invalid JSON structure (not an object): ${path.basename(fileInfo.filePath)}`);
}
continue;
}
if (Array.isArray(jsonData)) {
if (isDebug || isStrict) {
console.warn(`â ď¸ Invalid JSON structure (array instead of object): ${path.basename(fileInfo.filePath)}`);
}
continue;
}
const stats = this.analyzeFileCompleteness(jsonData);
totalKeys += stats.total;
translatedKeys += stats.translated;
} catch (error) {
if (isDebug || isStrict) {
console.warn(`â Failed to analyze file ${path.basename(fileInfo.filePath)}: ${error.message}`);
}
if (isDebug) {
console.error(error.stack);
}
continue;
}
}
this.translationStats.set(language, {
total: totalKeys,
translated: translatedKeys,
notTranslated: totalKeys - translatedKeys
});
} catch (error) {
if (isDebug || isStrict) {
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;
// Validate input object before processing
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
return { total: 0, translated: 0 }; // Return empty stats for invalid input
}
const traverse = (current) => {
// Validate current object before processing
if (typeof current !== 'object' || current === null || Array.isArray(current)) {
return;
}
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 = `${t('summary.usageReportTitle')}\n`;
report += `${t('summary.usageReportGenerated', { timestamp })}\n`;
report += `${t('summary.usageReportSourceDir', { sourceDir: this.sourceDir })}\n`;
report += `${t('summary.usageReportI18nDir', { i18nDir: this.i18nDir })}\n`;
report += `Version: ${this.version}\n\n`;
// Performance metrics
const analysisTime = Date.now() - this.startTime;
report += `⥠Performance Metrics:\n`;
report += ` Analysis completed in: ${analysisTime}ms\n`;
report += ` Memory usage: ${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)}MB\n\n`;
// Summary
report += `${t('summary.usageReportSummary')}\n`;
report += `${'='.repeat(50)}\n`;
report += `${t('summary.usageReportSourceFilesScanned', { count: this.fileUsage.size })}\n`;
report += `${t('summary.usageReportTranslationFilesFound', { count: this.translationFiles.size })}\n`;
report += `${t('summary.usageReportAvailableKeys', { count: this.availableKeys.size })}\n`;
report += `${t('summary.usageReportUsedKeys', { count: this.usedKeys.size - dynamicKeys.length })}\n`;
report += `${t('summary.usageReportDynamicKeys', { count: dynamicKeys.length })}\n`;
report += `${t('summary.usageReportUnusedKeys', { count: unusedKeys.length })}\n`;
report += `${t('summary.usageReportMissingKeys', { count: missingKeys.length })}\n`;
report += `${t('summary.usageReportNotTranslatedKeys', { count: notTranslatedStats.total })}\n\n`;
// Framework usage analysis
if (this.frameworkUsage && this.frameworkUsage.size > 0) {
report += `đď¸ Framework Usage Analysis:\n`;
const frameworkCounts = {};
this.frameworkUsage.forEach((data, filePath) => {
const framework = data.framework;
if (!frameworkCounts[framework]) frameworkCounts[framework] = 0;
frameworkCounts[framework]++;
});
Object.entries(frameworkCounts).forEach(([framework, count]) => {
report += ` ${framework}: ${count} files\n`;
});
report += `\n`;
}
// Translation completeness with advanced scoring
report += `${t('summary.usageReportTranslationCompleteness')}\n`;
report += `${'='.repeat(50)}\n`;
for (const [language, stats] of this.translationStats) {
const translations = this.translationsByLanguage[language] || {};
const score = this.calculateTranslationScore ? this.calculateTranslationScore(language, translations) : {
completeness: ((stats.translated / stats.total) * 100).toFixed(1),
quality: ((stats.translated / stats.total) * 100).toFixed(1),
placeholderAccuracy: 100
};
report += `${t('summary.usageReportLanguageCompleteness', { language: language.toUpperCase(), completeness: score.completeness, translated: stats.translated, total: stats.total })}\n`;
report += ` Quality: ${score.quality}%\n`;
report += ` Placeholder Accuracy: ${score.placeholderAccuracy}%\n`;
if (stats.notTranslated > 0) {
report += `${t('summary.usageReportNotTranslatedInLanguage', { count: stats.notTranslated })}\n`;
}
report += `\n`;
}
// Translation files discovered
report += `${t('summary.usageReportTranslationFilesDiscovered')}\n`;
report += `${'='.repeat(50)}\n`;
for (const [filePath, fileInfo] of this.translationFiles) {
const relativePath = path.relative(this.i18nDir, filePath);
report += `${t('summary.usageReportFileInfo', { relativePath, namespace: fileInfo.namespace, type: fileInfo.type })}\n`;
}
report += `\n`;
// Key complexity analysis
if (this.keyComplexity && this.keyComplexity.size > 0) {
report += `đ Key Complexity Analysis:\n`;
const complexityStats = { simple: 0, moderate: 0, complex: 0 };
this.keyComplexity.forEach((data, key) => {
complexityStats[data.level]++;
});
report += ` Simple keys: ${complexityStats.simple}\n`;
report += ` Moderate keys: ${complexityStats.moderate}\n`;
report += ` Complex keys: ${complexityStats.complex}\n\n`;
}
// Unused keys with complexity
if (unusedKeys.length > 0) {
report += `${t('summary.usageReportUnusedTranslationKeys')}\n`;
report += `${'='.repeat(50)}\n`;
report += `${t('summary.usageReportUnusedKeysDescription')}\n\n`;
unusedKeys.slice(0, 100).forEach(key => {
const complexity = this.keyComplexity && this.keyComplexity.get(key);
const complexityLevel = complexity ? ` (${complexity.level})` : '';
report += `${t('summary.usageReportUnusedKey', { key: key + complexityLevel })}\n`;
});
if (unusedKeys.length > 100) {
report += `${t('summary.usageReportMoreUnusedKeys', { count: unusedKeys.length - 100 })}\n`;
}
report += `\n`;
}
// Missing keys with location and framework
if (missingKeys.length > 0) {
report += `${t('summary.usageReportMissingTranslationKeys')}\n`;
report += `${'='.repeat(50)}\n`;
report += `${t('summary.usageReportMissingKeysDescription')}\n\n`;
missingKeys.forEach(key => {
report += `${t('summary.usageReportMissingKey', { key })}\n`;
// Show where it's used
const usage = this.findKeyUsage(key);
usage.slice(0, 3).forEach(({ filePath }) => {
const framework = this.frameworkUsage && this.frameworkUsage.get(filePath);
const frameworkInfo = framework ? ` [${framework.framework}]` : '';
report += ` ${t('summary.usageReportUsedIn', { filePath: filePath + frameworkInfo })}\n`;
});
if (usage.length > 3) {
report += ` ${t('summary.usageReportMoreFiles', { count: usage.length - 3 })}\n`;
}
report += `\n`;
});
}
// Dynamic keys
if (dynamicKeys.length > 0) {
report += `${t('summary.usageReportDynamicTranslationKeys')}\n`;
report += `${'='.repeat(50)}\n`;
report += `${t('summary.usageReportDynamicKeysDescription')}\n\n`;
dynamicKeys.forEach(key => {
const complexity = this.keyComplexity && this.keyComplexity.get(key);
const complexityLevel = complexity ? ` (${complexity.leve