#!/usr/bin/env node /* eslint-disable @typescript-eslint/no-require-imports */ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); const messagesDir = path.join(__dirname, '..', 'messages'); const srcDir = path.join(__dirname, '..', 'src'); const enFile = path.join(messagesDir, 'en.json'); // Read English (source) translations const en = JSON.parse(fs.readFileSync(enFile, 'utf8')); // Flatten nested object keys function flattenKeys(obj, prefix = '') { return Object.keys(obj).reduce((acc, key) => { const fullKey = prefix ? `${prefix}.${key}` : key; if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) { return [...acc, ...flattenKeys(obj[key], fullKey)]; } return [...acc, fullKey]; }, []); } // Get all translation keys const allKeys = flattenKeys(en); console.log(`\nšŸ“‹ Checking ${allKeys.length} translation keys for usage...\n`); // Get all source files function getAllFiles(dir, extensions = ['.tsx', '.ts', '.js', '.jsx']) { let results = []; const list = fs.readdirSync(dir); list.forEach(file => { const filePath = path.join(dir, file); const stat = fs.statSync(filePath); if (stat && stat.isDirectory()) { results = results.concat(getAllFiles(filePath, extensions)); } else if (extensions.some(ext => file.endsWith(ext))) { results.push(filePath); } }); return results; } // Read all source file contents const sourceFiles = getAllFiles(srcDir); let allSourceContent = ''; sourceFiles.forEach(file => { allSourceContent += fs.readFileSync(file, 'utf8') + '\n'; }); // Also check component files that might use translations const componentsContent = allSourceContent; // Find unused keys const unusedKeys = []; const usedKeys = []; // Common patterns for translation usage: // - t("key") or t('key') // - t("namespace.key") or t('namespace.key') // - useTranslations("namespace") then t("key") // - getTranslations("namespace") then t("key") // - tNamespace("key") pattern (e.g., tCommon, tHomepage, tPrompts) // Build a map of namespace aliases from patterns like: // const tCommon = useTranslations("common") // const tHomepage = await getTranslations("homepage") // const t = useTranslations("admin.reports") -- nested namespaces const namespaceAliases = {}; const aliasPattern = /(?:const|let)\s+(t\w*)\s*=\s*(?:await\s+)?(?:useTranslations|getTranslations)\s*\(\s*["']([^"']+)["']\s*\)/g; let match; while ((match = aliasPattern.exec(allSourceContent)) !== null) { const alias = match[1]; const namespace = match[2]; // Can be "common" or "admin.reports" if (!namespaceAliases[namespace]) { namespaceAliases[namespace] = []; } if (!namespaceAliases[namespace].includes(alias)) { namespaceAliases[namespace].push(alias); } } // Also add common patterns that might be used // e.g., tCommon for "common", tHomepage for "homepage" const commonAliasPatterns = { 'common': ['tCommon'], 'homepage': ['tHomepage'], 'prompts': ['tPrompts'], 'categories': ['tCategories'], 'settings': ['tSettings'], 'auth': ['tAuth'], 'discovery': ['tDiscovery'], 'feed': ['tFeed'], 'collection': ['tCollection'], 'search': ['tSearch'], 'user': ['tUser'], 'admin': ['tAdmin'], 'tags': ['tTags'], 'version': ['tVersion'], 'vote': ['tVote'], 'subscription': ['tSubscription'], 'changeRequests': ['tChangeRequests'], 'comments': ['tComments'], 'errors': ['tErrors'], 'nav': ['tNav'], 'brand': ['tBrand'], 'about': ['tAbout'], 'ide': ['tIde'], 'profile': ['tProfile'], 'report': ['tReport'], 'promptBuilder': ['tPromptBuilder'], 'promptmasters': ['tPromptmasters'], 'connectedPrompts': ['tConnectedPrompts'], 'notifications': ['tNotifications'], 'apiDocs': ['tApiDocs'], }; Object.entries(commonAliasPatterns).forEach(([ns, aliases]) => { if (!namespaceAliases[ns]) { namespaceAliases[ns] = []; } aliases.forEach(alias => { if (!namespaceAliases[ns].includes(alias)) { namespaceAliases[ns].push(alias); } }); }); // Helper to check if a key pattern exists (with or without additional args) // Matches: t("key"), t("key", ...), t('key'), t('key', ...) // Also matches: t.rich("key", ...), t.raw("key", ...), etc. // Also matches conditional: t(condition ? "key" : "other") function checkKeyUsage(content, fnName, key) { const escapedKey = escapeRegex(key); // Patterns: fnName("key") or fnName("key", ...) // Also: fnName.rich("key", ...), fnName.raw("key", ...), fnName.markup("key", ...) // Also: ternary/conditional patterns like fnName(cond ? "key" : ...) const patterns = [ new RegExp(`${fnName}\\s*\\(\\s*["']${escapedKey}["']\\s*[,)]`), new RegExp(`${fnName}\\s*\\(\\s*\`${escapedKey}\`\\s*[,)]`), new RegExp(`${fnName}\\.rich\\s*\\(\\s*["']${escapedKey}["']\\s*[,)]`), new RegExp(`${fnName}\\.raw\\s*\\(\\s*["']${escapedKey}["']\\s*[,)]`), new RegExp(`${fnName}\\.markup\\s*\\(\\s*["']${escapedKey}["']\\s*[,)]`), // Ternary patterns: t(cond ? "key" : ...) or t(... : "key") new RegExp(`${fnName}\\s*\\([^)]*\\?\\s*["']${escapedKey}["']`), new RegExp(`${fnName}\\s*\\([^)]*:\\s*["']${escapedKey}["']`), ]; return patterns.some(pattern => pattern.test(content)); } // Escape special regex characters function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } allKeys.forEach(key => { const parts = key.split('.'); const namespace = parts[0]; const subKey = parts.slice(1).join('.'); let isUsed = false; // Check for direct full key usage: t("namespace.key") or t("namespace.key", {...}) if (checkKeyUsage(allSourceContent, 't', key)) { isUsed = true; } // Check for namespace + subkey usage with t() // e.g., useTranslations("prompts") then t("create") or t("create", {...}) if (!isUsed && subKey && checkKeyUsage(allSourceContent, 't', subKey)) { isUsed = true; } // Check for aliased namespace patterns like tCommon("error"), tHomepage("title", {...}) if (!isUsed && subKey && namespaceAliases[namespace]) { for (const alias of namespaceAliases[namespace]) { if (checkKeyUsage(allSourceContent, alias, subKey)) { isUsed = true; break; } } } // Check for nested namespace patterns like useTranslations("admin.reports") // Key: "admin.reports.markedReviewed" -> with t("markedReviewed") if (!isUsed) { for (const [nsPath, aliases] of Object.entries(namespaceAliases)) { if (nsPath.includes('.') && key.startsWith(nsPath + '.')) { const nestedSubKey = key.slice(nsPath.length + 1); for (const alias of aliases) { if (checkKeyUsage(allSourceContent, alias, nestedSubKey)) { isUsed = true; break; } } if (isUsed) break; } } } // Also check for the key as a plain string (props, etc.) if (!isUsed) { if (allSourceContent.includes(`"${key}"`) || allSourceContent.includes(`'${key}'`)) { isUsed = true; } } // Check for template literal patterns like t(`prefix.${var}`) // If key is "categories.sort.newest", check for t(`sort.${...}`) patterns if (!isUsed && subKey) { const subParts = subKey.split('.'); if (subParts.length >= 2) { // Check for patterns like t(`sort.${option}`) matching "sort.newest" const prefix = subParts.slice(0, -1).join('.'); const templatePattern = new RegExp(`t\\s*\\(\\\`${escapeRegex(prefix)}\\.\\$\\{`); if (templatePattern.test(allSourceContent)) { isUsed = true; } // Also check with aliases if (!isUsed && namespaceAliases[namespace]) { for (const alias of namespaceAliases[namespace]) { const aliasTemplatePattern = new RegExp(`${alias}\\s*\\(\\\`${escapeRegex(prefix)}\\.\\$\\{`); if (aliasTemplatePattern.test(allSourceContent)) { isUsed = true; break; } } } } } // Check for nested namespace template patterns if (!isUsed) { for (const [nsPath, aliases] of Object.entries(namespaceAliases)) { if (nsPath.includes('.') && key.startsWith(nsPath + '.')) { const nestedSubKey = key.slice(nsPath.length + 1); const nestedParts = nestedSubKey.split('.'); if (nestedParts.length >= 2) { const prefix = nestedParts.slice(0, -1).join('.'); for (const alias of aliases) { const templatePattern = new RegExp(`${alias}\\s*\\(\\\`${escapeRegex(prefix)}\\.\\$\\{`); if (templatePattern.test(allSourceContent)) { isUsed = true; break; } } } if (isUsed) break; } } } if (isUsed) { usedKeys.push(key); } else { unusedKeys.push(key); } }); // Final verification: check if the last part of the key exists anywhere in source // If "alreadyReported" doesn't appear at all, "report.alreadyReported" is definitely unused const confirmedUnused = []; const maybeUnused = []; unusedKeys.forEach(key => { const parts = key.split('.'); const lastPart = parts[parts.length - 1]; // Check if lastPart appears as a quoted string anywhere const hasQuoted = allSourceContent.includes(`"${lastPart}"`) || allSourceContent.includes(`'${lastPart}'`) || allSourceContent.includes(`\`${lastPart}\``); if (hasQuoted) { maybeUnused.push(key); } else { confirmedUnused.push(key); } }); // Group unused keys by namespace const unusedByNamespace = {}; unusedKeys.forEach(key => { const namespace = key.split('.')[0]; if (!unusedByNamespace[namespace]) { unusedByNamespace[namespace] = []; } unusedByNamespace[namespace].push(key); }); // Group confirmed unused by namespace const confirmedByNamespace = {}; confirmedUnused.forEach(key => { const namespace = key.split('.')[0]; if (!confirmedByNamespace[namespace]) { confirmedByNamespace[namespace] = []; } confirmedByNamespace[namespace].push(key); }); // Group maybe unused by namespace const maybeByNamespace = {}; maybeUnused.forEach(key => { const namespace = key.split('.')[0]; if (!maybeByNamespace[namespace]) { maybeByNamespace[namespace] = []; } maybeByNamespace[namespace].push(key); }); // Output results if (unusedKeys.length === 0) { console.log('āœ… All translation keys are being used!\n'); } else { // Show confirmed unused first if (confirmedUnused.length > 0) { console.log(`\nšŸ—‘ļø CONFIRMED UNUSED (${confirmedUnused.length} keys - safe to remove):\n`); Object.entries(confirmedByNamespace) .sort((a, b) => b[1].length - a[1].length) .forEach(([namespace, keys]) => { console.log(`šŸ“ ${namespace} (${keys.length}):`); keys.forEach(key => { const value = key.split('.').reduce((obj, k) => obj?.[k], en); const displayValue = typeof value === 'string' ? value.length > 50 ? value.substring(0, 50) + '...' : value : '[object]'; console.log(` ${key}: "${displayValue}"`); }); }); } // Show maybe unused if (maybeUnused.length > 0) { console.log(`\nāš ļø MAYBE UNUSED (${maybeUnused.length} keys - review carefully):\n`); Object.entries(maybeByNamespace) .sort((a, b) => b[1].length - a[1].length) .forEach(([namespace, keys]) => { console.log(`šŸ“ ${namespace} (${keys.length}):`); keys.forEach(key => { const value = key.split('.').reduce((obj, k) => obj?.[k], en); const displayValue = typeof value === 'string' ? value.length > 50 ? value.substring(0, 50) + '...' : value : '[object]'; console.log(` ${key}: "${displayValue}"`); }); }); } console.log(`\nšŸ“Š Summary:`); console.log(` Total keys: ${allKeys.length}`); console.log(` Used keys: ${usedKeys.length}`); console.log(` Confirmed unused: ${confirmedUnused.length}`); console.log(` Maybe unused: ${maybeUnused.length}`); console.log(`\nšŸ’” "Confirmed unused" keys don't appear anywhere in source code.`); console.log(` "Maybe unused" keys have the final part appearing somewhere but couldn't be matched to a t() call.\n`); } // Optional: Output to file if --output flag is provided if (process.argv.includes('--output')) { const outputFile = path.join(__dirname, 'unused-translations.json'); fs.writeFileSync(outputFile, JSON.stringify({ total: allKeys.length, used: usedKeys.length, unused: unusedKeys.length, unusedKeys: unusedByNamespace }, null, 2)); console.log(`šŸ“„ Results saved to ${outputFile}\n`); }