/** * Script to find missing i18n keys in Jan components * * Usage: * node scripts/find-missing-i18n-key.js [options] * * Options: * --locale= Only check a specific locale (e.g. --locale=id) * --file= Only check a specific file (e.g. --file=common.json) * --help Show this help message */ const fs = require("fs") const path = require("path") // Parse command-line arguments const args = process.argv.slice(2).reduce((acc, arg) => { if (arg === "--help") { acc.help = true } else if (arg.startsWith("--locale=")) { acc.locale = arg.split("=")[1] } else if (arg.startsWith("--file=")) { acc.file = arg.split("=")[1] } return acc }, {}) // Display help information if (args.help) { console.log(` Find missing i18n translations in Jan A useful script to identify whether the i18n keys used in component files exist in all language files. Usage: node scripts/find-missing-i18n-key.js [options] Options: --locale= Only check a specific language (e.g., --locale=id) --file= Only check a specific file (e.g., --file=common.json) --help Display help information Output: - Generate a report of missing translations `) process.exit(0) } // Directories to traverse and their corresponding locales const DIRS = { components: { path: path.join(__dirname, "../web-app/src/components"), localesDir: path.join(__dirname, "../web-app/src/locales"), }, containers: { path: path.join(__dirname, "../web-app/src/containers"), localesDir: path.join(__dirname, "../web-app/src/locales"), }, routes: { path: path.join(__dirname, "../web-app/src/routes"), localesDir: path.join(__dirname, "../web-app/src/locales"), }, } // Regular expressions to match i18n keys const i18nPatterns = [ /{t\("([^"]+)"\)}/g, // Match {t("key")} format /i18nKey="([^"]+)"/g, // Match i18nKey="key" format /\bt\(\s*["']([^"']+)["']\s*(?:,\s*[^)]+)?\)/g, // Match t("key") format with optional parameters - simplified and more robust ] // Get all language directories for a specific locales directory function getLocaleDirs(localesDir) { try { const allLocales = fs.readdirSync(localesDir).filter((file) => { const stats = fs.statSync(path.join(localesDir, file)) return stats.isDirectory() // Do not exclude any language directories }) // Filter to a specific language if specified return args.locale ? allLocales.filter((locale) => locale === args.locale) : allLocales } catch (error) { if (error.code === "ENOENT") { console.warn(`Warning: Locales directory not found: ${localesDir}`) return [] } throw error } } // Get the value from JSON by path function getValueByPath(obj, path) { const parts = path.split(".") let current = obj for (const part of parts) { if (current === undefined || current === null) { return undefined } current = current[part] } return current } // Check if the key exists in all language files, return a list of missing language files function checkKeyInLocales(key, localeDirs, localesDir) { // Handle namespace:key format (e.g., "common:save" or "settings:general") let namespace, keyPath if (key.includes(":")) { [namespace, keyPath] = key.split(":", 2) } else if (key.includes(".")) { // Handle namespace.key format const parts = key.split(".") // Check if the first part is a known namespace const knownNamespaces = ['common', 'settings', 'systemMonitor', 'chat', 'hub', 'providers', 'assistants', 'mcpServers', 'mcp-servers', 'toolApproval', 'tool-approval', 'updater', 'setup', 'logs', 'provider'] if (knownNamespaces.includes(parts[0])) { namespace = parts[0] keyPath = parts.slice(1).join(".") } else { // Default to common namespace if no known namespace is found namespace = "common" keyPath = key } } else { // No dots, default to common namespace namespace = "common" keyPath = key } const missingLocales = [] // Map namespace to actual filename const namespaceToFile = { 'systemMonitor': 'system-monitor', 'mcpServers': 'mcp-servers', 'mcp-servers': 'mcp-servers', 'toolApproval': 'tool-approval', 'tool-approval': 'tool-approval' } const fileName = namespaceToFile[namespace] || namespace localeDirs.forEach((locale) => { const filePath = path.join(localesDir, locale, `${fileName}.json`) if (!fs.existsSync(filePath)) { missingLocales.push(`${locale}/${fileName}.json`) return } try { const json = JSON.parse(fs.readFileSync(filePath, "utf8")) // Jan's localization files have flat structure // e.g., common.json has { "save": "Save", "cancel": "Cancel" } // not nested like { "common": { "save": "Save" } } const valueToCheck = getValueByPath(json, keyPath) if (valueToCheck === undefined) { missingLocales.push(`${locale}/${fileName}.json`) } } catch (error) { console.warn(`Warning: Could not parse ${filePath}: ${error.message}`) missingLocales.push(`${locale}/${fileName}.json`) } }) return missingLocales } // Recursively traverse the directory function findMissingI18nKeys() { const results = [] function walk(dir, baseDir, localeDirs, localesDir) { if (!fs.existsSync(dir)) { console.warn(`Warning: Directory not found: ${dir}`) return } const files = fs.readdirSync(dir) for (const file of files) { const filePath = path.join(dir, file) const stat = fs.statSync(filePath) // Exclude test files, __mocks__ directory, and node_modules if (filePath.includes(".test.") || filePath.includes("__mocks__") || filePath.includes("node_modules") || filePath.includes(".spec.")) { continue } if (stat.isDirectory()) { walk(filePath, baseDir, localeDirs, localesDir) // Recursively traverse subdirectories } else if (stat.isFile() && [".ts", ".tsx", ".js", ".jsx"].includes(path.extname(filePath))) { const content = fs.readFileSync(filePath, "utf8") // Match all i18n keys for (const pattern of i18nPatterns) { let match while ((match = pattern.exec(content)) !== null) { const key = match[1] // Skip empty keys or keys that look like variables/invalid if (!key || key.includes("${") || key.includes("{{") || key.startsWith("$") || key.length < 2 || key === "." || key === "," || key === "-" || !/^[a-zA-Z]/.test(key)) { continue } const missingLocales = checkKeyInLocales(key, localeDirs, localesDir) if (missingLocales.length > 0) { results.push({ key, missingLocales, file: path.relative(baseDir, filePath), }) } } } } } } // Walk through all directories Object.entries(DIRS).forEach(([name, config]) => { const localeDirs = getLocaleDirs(config.localesDir) if (localeDirs.length > 0) { console.log(`\nChecking ${name} directory with ${localeDirs.length} languages: ${localeDirs.join(", ")}`) walk(config.path, config.path, localeDirs, config.localesDir) } }) return results } // Execute and output the results function main() { try { if (args.locale) { // Check if the specified locale exists in the locales directory const localesDir = path.join(__dirname, "../web-app/src/locales") const localeDirs = getLocaleDirs(localesDir) if (!localeDirs.includes(args.locale)) { console.error(`Error: Language '${args.locale}' not found in ${localesDir}`) process.exit(1) } } const missingKeys = findMissingI18nKeys() if (missingKeys.length === 0) { console.log("\nāœ… All i18n keys are present!") return } console.log("\nMissing i18n keys:\n") // Group by file for better readability const groupedByFile = {} missingKeys.forEach(({ key, missingLocales, file }) => { if (!groupedByFile[file]) { groupedByFile[file] = [] } groupedByFile[file].push({ key, missingLocales }) }) Object.entries(groupedByFile).forEach(([file, keys]) => { console.log(`šŸ“ File: ${file}`) keys.forEach(({ key, missingLocales }) => { console.log(` šŸ”‘ Key: ${key}`) console.log(" āŒ Missing in:") missingLocales.forEach((locale) => console.log(` - ${locale}`)) console.log("") }) console.log("-------------------") }) console.log("\nšŸ’” To fix missing translations:") console.log("1. Add the missing keys to the appropriate locale files") console.log("2. Use yq commands for efficient updates:") console.log(" yq -i '.namespace.key = \"Translation\"' web-app/src/locales//.json") console.log("3. Run this script again to verify all keys are present") // Exit code 1 indicates missing keys process.exit(1) } catch (error) { console.error("Error:", error.message) console.error(error.stack) process.exit(1) } } main()