i18ntk
Version:
i18n Tool Kit - Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, auto translation, fixing, reporting, and runtime translation loading.
1,248 lines (1,080 loc) ⢠98.8 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');
const { resolveUsageSourceDir } = require('../utils/usage-source');
const { analyzeSourceForUsageInsights } = require('../utils/usage-insights');
const { discoverLocales } = require('../utils/locale-discovery');
const { isInteractive } = require('../utils/prompt-helper');
// 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');
}
function toBool(v) {
return v === true || v === 'true' || v === '1';
}
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.keyUsageLocations = new Map();
this.hardcodedTextCandidates = [];
this.namespaceRecommendations = [];
this.unresolvedDynamicReferences = [];
this.translationValueIndex = 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
// Dead key detection properties
this.deadKeys = new Map();
this.cleanupMode = false;
this.dryRunDelete = false;
this._sourceCommentsSet = null;
this._dynamicallyReferencedKeys = new Set();
this._dynamicPrefixes = new Set();
this._resolvedDynamicExpansions = new Map();
this._clientBoundaryIssues = [];
this._mojibakeIssues = [];
this._copyFormatters = [];
this._telemetryLiterals = [];
this._localWrapperRefs = [];
// 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 (!SecurityUtils.safeExistsSync(this.i18nDir, process.cwd())) {
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);
}
// Path display deferred until after CLI arg overrides are applied in run()
// Ensure translation patterns are defined
this.config = this.config || {};
this.config.translationPatterns = this.config.translationPatterns || [
/(?<![\w$.])t\s*\(['"`]([^'"`]+)['"`]/g,
/(?<![\w$.])tx\s*\(['"`]([^'"`]+)['"`]/g,
/\.tx\s*\(['"`]([^'"`]+)['"`]/g,
/(?<=[)\s])\.tx\s*\(['"`]([^'"`]+)['"`]/g,
/i18n\.t\(['"`]([^'"`]+)['"`]/g,
/useTranslation\(\)\.t\(['"`]([^'"`]+)['"`]/g,
/(?<![\w$.])t\s*\(`([^`]+)`\)/g,
/(?<![\w$.])tx\s*\(`([^`]+)`\)/g,
/\.tx\s*\(`([^`]+)`\)/g,
/i18nKey=['"`]([^'"`]+)['"`]/g,
/\$t\(['"`]([^'"`]+)['"`]/g,
/(?<![\w$.])getTranslation\s*\(['"`]([^'"`]+)['"`]/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', '.vue', '.svelte', '.py', '.pyx', '.pyi'];
}
await SecurityUtils.logSecurityEvent(t('usage.analyzerInitialized'), 'info', { component: 'i18ntk-usage' });
} catch (error) {
await SecurityUtils.logSecurityEvent(t('usage.analyzerInitFailed'), 'error', { 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'] ?? a['code-dir'] ?? a['source-code-dir'],
i18nDir: a.i18nDir ?? a['i18n-dir'] ?? a['locales-dir'],
sourceLanguage: a.sourceLanguage ?? a['source-language'] ?? a['source-locale'],
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,
cleanup: a.cleanup ?? a['cleanup'],
dryRunDelete: a.dryRunDelete ?? a['dry-run-delete'],
strictUnused: a.strictUnused ?? a['strict-unused'],
json: a.json,
prune: a.prune,
pruneKeep: parseInt(a['prune-keep'] || a.pruneKeep, 10) || 10
};
}
// Parse command line arguments
async parseArgs() {
const args = process.argv.slice(2);
const parsed = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('--')) {
let [key, value] = arg.substring(2).split('=');
if (value === undefined && args[i + 1] && !args[i + 1].startsWith('-')) {
value = args[++i];
}
if (key === 'code-dir' || key === 'source-code-dir') key = 'source-dir';
if (key === 'locales-dir') key = 'i18n-dir';
if (key === 'source-locale') key = 'source-language';
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 || !SecurityUtils.safeExistsSync(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'), 'error', {
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 || !SecurityUtils.safeExistsSync(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'), 'error', {
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');
}
if (toBool(args.cleanup)) {
this.cleanupMode = true;
}
if (toBool(args.dryRunDelete)) {
this.dryRunDelete = true;
}
if (toBool(args.prune)) {
const outputDir = args.outputDir || this.config.outputDir || './i18ntk-reports/usage';
this.pruneReports(outputDir, args.pruneKeep || 10);
return;
}
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 = [
/(?<![\w$.])t\s*\(['"`]([^'"`]+)['"`]/g,
/(?<![\w$.])tx\s*\(['"`]([^'"`]+)['"`]/g,
/\.tx\s*\(['"`]([^'"`]+)['"`]/g,
/i18n\.t\(['"`]([^'"`]+)['"`]/g,
/useTranslation\(\)\.t\(['"`]([^'"`]+)['"`]/g,
/(?<![\w$.])t\s*\(`([^`]+)`\)/g,
/(?<![\w$.])tx\s*\(`([^`]+)`\)/g,
/\.tx\s*\(`([^`]+)`\)/g,
/i18nKey=['"`]([^'"`]+)['"`]/g,
/\$t\(['"`]([^'"`]+)['"`]/g,
/(?<![\w$.])getTranslation\s*\(['"`]([^'"`]+)['"`]/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 (args.sourceLanguage) {
this.config.sourceLanguage = args.sourceLanguage;
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)
});
}
const usageSource = resolveUsageSourceDir({
sourceDir: this.sourceDir || this.config.sourceDir,
i18nDir: this.i18nDir || this.config.i18nDir,
projectRoot: this.config.projectRoot || process.cwd(),
explicitSourceDir: Boolean(args.sourceDir),
});
if (usageSource.reason) {
console.warn(t('usage.sourceEqualsI18nWarn', { reason: usageSource.reason }) || `Warning: ${usageSource.reason}`);
}
this.sourceDir = usageSource.sourceDir;
this.config.sourceDir = usageSource.sourceDir;
if (this.sourceDir) {
await configManager.updateConfig({
sourceDir: configManager.toRelative(this.sourceDir)
});
}
// đ§ prevent scanning locales as source
if (this.sourceDir && !args.sourceDir && 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 (SecurityUtils.safeExistsSync(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)
});
}
displayPaths({ sourceDir: this.sourceDir, i18nDir: this.i18nDir, outputDir: this.config.outputDir });
console.log(t('usage.detectedSourceDirectory', { sourceDir: this.sourceDir || t('usage.noSourceDirectoryConfigured') || '(none)' }));
console.log(t('usage.detectedI18nDirectory', { i18nDir: this.i18nDir }));
// Load available translation keys first
await this.loadAvailableKeys();
// 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 (this.cleanupMode) {
this._buildSourceCommentsSet();
const deadKeys = this.findDeadKeys();
console.log('\n' + t('usage.deadKeysDetectionTitle'));
console.log(t('usage.deadKeysCount', { count: deadKeys.length }));
const highConfidence = deadKeys.filter(dk => dk.confidence >= 0.8).length;
const mediumConfidence = deadKeys.filter(dk => dk.confidence >= 0.4 && dk.confidence < 0.8).length;
const lowConfidence = deadKeys.filter(dk => dk.confidence < 0.4).length;
console.log(t('usage.deadKeysConfidenceBreakdown', { high: highConfidence, medium: mediumConfidence, low: lowConfidence }));
if (deadKeys.length > 0) {
console.log('\n' + t('usage.deadKeysSample'));
deadKeys.slice(0, 10).forEach(dk => {
console.log(` ${dk.key} [${(dk.confidence * 100).toFixed(0)}%] - ${dk.reason}`);
});
if (deadKeys.length > 10) {
console.log(t('usage.deadKeysMore', { count: deadKeys.length - 10 }));
}
}
if (this.dryRunDelete) {
const reportPath = this.saveDeadKeysReport(deadKeys, args.outputDir || this.config.outputDir || './i18ntk-reports/usage');
console.log(t('usage.deadKeysReportSaved', { path: reportPath }));
}
}
if (args.outputReport) {
const confidenceFilteredUnused = this._computeConfidenceFilteredUnused(unusedKeys);
const report = this.generateUsageReport(confidenceFilteredUnused);
if (args.json) {
await this.saveJsonReport(report, args.outputDir);
} else {
await this.saveReport(report, args.outputDir);
}
}
if (args.json && !args.outputReport) {
const confidenceFilteredUnused = this._computeConfidenceFilteredUnused(unusedKeys);
const jsonReport = this.generateJsonReport(confidenceFilteredUnused);
console.log(JSON.stringify(jsonReport, null, 2));
}
if (this._mojibakeIssues.length > 0) {
console.log('\nđ¤ Locale Quality - Mojibake Artifacts:');
this._mojibakeIssues.forEach(issue => {
console.log(` â ď¸ ${issue.key} [${issue.locale}]: ${issue.artifact}`);
});
}
if (this._clientBoundaryIssues.length > 0) {
console.log('\nđŚ Client-Boundary Warnings:');
this._clientBoundaryIssues.forEach(issue => {
console.log(` â ď¸ ${issue.filePath}: ${issue.message}`);
});
}
if (this._copyFormatters.length > 0) {
const suspected = this._copyFormatters.filter(cf => cf.type === 'suspectedCopyFormatter');
if (suspected.length > 0) {
console.log('\nđ§ Suspected Copy Formatters:');
suspected.forEach(cf => {
console.log(` â ď¸ ${cf.filePath}:${cf.line} - ${cf.message}`);
});
}
}
if (toBool(args.strictUnused)) {
const deadKeys = this.findDeadKeys();
const strictUnused = deadKeys.filter(dk => dk.confidence >= 0.8);
const strictCount = strictUnused.length;
console.log(`\nđ Strict-Unused Mode: ${strictCount} high-confidence dead keys (filtered from ${deadKeys.length} total candidates)`);
}
console.log('\n' + t('usage.analysisCompletedSuccessfully'));
if (require.main === module && isInteractive(args)) {
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'), 'error', {
component: 'i18ntk-usage',
error: error.message
});
throw error;
}
}
// Show help message
showHelp() {
console.log(`
đ i18ntk usage - Translation key usage analysis (v1.10.1)
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
--strict-unused Only report high-confidence (>80%) unused keys
--json Output results as JSON (to console or with --output-report)
--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
--cleanup Enable dead key detection for cleanup mode
--dry-run-delete Save dead keys report without deleting (requires --cleanup)
--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
node i18ntk-usage.js --cleanup --dry-run-delete
Analysis Features (v1.10.1):
⢠Detects unused translation keys with confidence scoring
⢠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
⢠Known-key literal matching with source file locations
⢠Namespace/file naming recommendations (for example app/shop -> shop.json)
⢠Hardcoded user-facing text candidates with suggested translation keys
⢠Security-enhanced path validation
⢠Detailed reporting with validation errors
⢠Dead key detection with confidence scoring
⢠Locale JSON import key detection
⢠Client-boundary warnings ("use client" files importing locale JSON)
⢠Copy formatter detection (local "tx" that doesn't call translation runtime)
⢠Mojibake artifact detection in translations
⢠Confidence-split unused key reporting (confirmed / likely / possibly used)
⢠JSON output format (--json)
`);
}
// 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 (!SecurityUtils.safeExistsSync(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'), 'error', {
component: 'i18ntk-usage',
file: fileInfo.filePath,
error: error.message
});
}
}
} catch (error) {
await SecurityUtils.logSecurityEvent(t('usage.translationKeysLoadError'), 'error', {
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);
if (typeof value === 'string') {
const normalizedValue = value.replace(/\s+/g, ' ').trim();
if (normalizedValue && !this.translationValueIndex.has(normalizedValue)) {
this.translationValueIndex.set(normalizedValue, fullKey);
}
}
}
}
} catch (error) {
// Handle any unexpected errors during key extraction
console.warn(`â ď¸ Error during key extraction: ${error.message}`);
return keys;
}
return keys;
}
_getNestedValue(obj, key) {
if (!obj || typeof obj !== 'object') return undefined;
const parts = key.split('.');
let current = obj;
for (const part of parts) {
if (current == null || typeof current !== 'object') return undefined;
current = current[part];
}
return typeof current === 'string' ? current : undefined;
}
collectPlaceholderKeys(obj, prefix = '', language) {
const patterns = this.placeholderStyles[language] || [];
const regexes = patterns.reduce((compiled, pattern) => {
try {
compiled.push(new RegExp(pattern));
} catch (error) {
SecurityUtils.logSecurityEvent('Invalid placeholder regex pattern skipped', 'warn', {
component: 'i18ntk-usage',
language,
pattern,
error: error.message
});
}
return compiled;
}, []);
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, path.dirname(filePath), 'utf8');
if (!content) return [];
return this.extractKeysFromContent(content, filePath);
// Null-safe translation patterns handling
} catch (error) {
console.warn(`${t('usage.failedToExtractKeys')} ${filePath}: ${error.message}`);
return [];
}
}
extractKeysFromContent(content, filePath = '') {
if (!content) return [];
if (filePath && 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);
}
recordUsageInsights(relativePath, insights) {
const keys = [];
const seen = new Set();
for (const ref of insights.keyReferences || []) {
if (!ref.key || seen.has(ref.key)) continue;
seen.add(ref.key);
keys.push(ref.key);
this.usedKeys.add(ref.key);
if (ref.matchType === 'dynamic-template' || ref.matchType === 'dynamic-variable') {
this._dynamicallyReferencedKeys.add(ref.key);
if (!this._resolvedDynamicExpansions.has(ref.key)) {
this._resolvedDynamicExpansions.set(ref.key, []);
}
this._resolvedDynamicExpansions.get(ref.key).push(relativePath);
}
if (ref.matchType === 'literal-telemetry') {
if (!this._telemetryLiterals) this._telemetryLiterals = [];
this._telemetryLiterals.push({
filePath: relativePath,
key: ref.key,
line: ref.line,
column: ref.column,
contextNote: ref.context?.contextNote,
});
}
if (ref.matchType === 'local-wrapper') {
if (!this._localWrapperRefs) this._localWrapperRefs = [];
this._localWrapperRefs.push({
filePath: relativePath,
key: ref.key,
line: ref.line,
wrapperName: ref.wrapperName,
});
}
if (!this.keyUsageLocations.has(ref.key)) {
this.keyUsageLocations.set(ref.key, []);
}
this.keyUsageLocations.get(ref.key).push({
filePath: relativePath,
line: ref.line,
column: ref.column,
matchType: ref.matchType,
});
}
if (keys.length > 0) {
this.fileUsage.set(relativePath, keys);
}
if (insights.namespaceRecommendation) {
this.namespaceRecommendations.push({
filePath: relativePath,
...insights.namespaceRecommendation,
});
}
if (Array.isArray(insights.hardcodedTexts) && insights.hardcodedTexts.length > 0) {
this.hardcodedTextCandidates.push(...insights.hardcodedTexts);
}
if (Array.isArray(insights.unresolvedDynamicReferences) && insights.unresolvedDynamicReferences.length > 0) {
this.unresolvedDynamicReferences.push(...insights.unresolvedDynamicReferences.map(ref => ({
filePath: relativePath,
...ref,
})));
for (const ref of insights.unresolvedDynamicReferences) {
if (ref.prefix) {
this._dynamicPrefixes.add(ref.prefix);
}
}
}
if (Array.isArray(insights.clientBoundaryIssues) && insights.clientBoundaryIssues.length > 0) {
if (!this._clientBoundaryIssues) this._clientBoundaryIssues = [];
this._clientBoundaryIssues.push(...insights.clientBoundaryIssues);
}
if (Array.isArray(insights.copyFormatters) && insights.copyFormatters.length > 0) {
if (!this._copyFormatters) this._copyFormatters = [];
this._copyFormatters.push(...insights.copyFormatters.map(cf => ({
filePath: relativePath,
...cf,
})));
}
return keys.length;
}
// Analyze usage in source files
async analyzeUsage() {
try {
console.log(t('usage.checkUsage.analyzing_source_files'));
if (!this.sourceDir) {
console.warn(t('usage.noSourceFilesFound'));
return;
}
// Check if source directory exists
if (!SecurityUtils.safeExistsSync(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 content = SecurityUtils.safeReadFileSync(filePath, path.dirname(filePath), 'utf8');
if (!content) continue;
const relativePath = path.relative(this.sourceDir, filePath);
const directKeys = this.extractKeysFromContent(content, filePath);
const insights = analyzeSourceForUsageInsights({
content,
relativePath,
availableKeys: this.availableKeys,
directKeys,
translationValueIndex: this.translationValueIndex,
});
this.detectFrameworkPatterns(content, relativePath);
totalKeysFound += this.recordUsageInsights(relativePath, insights);
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 }));
if (this.keyUsageLocations.size > 0) {
console.log(`đ Indexed ${this.keyUsageLocations.size} keys with source file locations`);
}
if (this.namespaceRecommendations.length > 0) {
console.log(`đ§ Namespace recommendations: ${this.namespaceRecommendations.length}`);
}
if (this.hardcodedTextCandidates.length > 0) {
console.log(`đ Hardcoded text candidates: ${this.hardcodedTextCandidates.length}`);
}
if (this.unresolvedDynamicReferences.length > 0) {
console.log(`đ§Š Unresolved dynamic key expressions: ${this.unresolvedDynamicReferences.length}`);
}
} 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 (!SecurityUtils.safeExistsSync(this.i18nDir, process.cwd())) {
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 SecurityUtils.safeExistsSync(itemPath) && fs.statSync(itemPath).isDirectory();
} catch (error) {
return false;
}
});
for (const lang of allLanguageDirs) {
if (['en', 'de', 'es', 'fr', 'it', 'pt', 'nl', 'pl', 'sv', 'uk', 'cs', 'tr', 'ru', 'ja', 'ko', 'zh', 'ar', 'hi', 'th', 'vi', 'he', 'el', 'hu'].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', 'it', 'pt', 'nl', 'pl', 'sv', 'uk', 'cs', 'tr', 'ru', 'ja', 'ko', 'zh', 'ar', 'hi', 'th', 'vi', 'he', 'el', 'hu'].includes(lang));
directFiles.forEach(lang => languages.add(lang));
discoverLocales(this.i18nDir, { excludeDirs: this.config.excludeDirs })
.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 (!SecurityUtils.safeExistsSync(fileInfo.filePath)) {
if (isDebug || isStrict) {
console.war