Live video on the AT Protocol
at eli/rtmprec 336 lines 9.8 kB view raw
1#!/usr/bin/env node 2 3/** 4 * i18n key extraction script 5 * 1. Scans the codebase for i18n keys (t('key'), Trans components, etc) 6 * 2. Extracts keys into namespace JSON files (common.json, settings.json, etc) 7 * 3. Migrates new keys to corresponding .ftl files for translation 8 * 9 * Usage: 10 * node extract-i18n.js # Extract keys and report new ones 11 * node extract-i18n.js --add-to=common # Add new keys to common.ftl 12 * node extract-i18n.js --add-to=settings # Add new keys to settings.ftl 13 */ 14 15const { execSync } = require("child_process"); 16const fs = require("fs"); 17const path = require("path"); 18 19// Parse command line arguments 20const args = process.argv.slice(2); 21const addToNamespace = args 22 .find((arg) => arg.startsWith("--add-to=")) 23 ?.split("=")[1]; 24 25// Paths 26const COMPONENTS_ROOT = path.join(__dirname, ".."); 27const APP_ROOT = path.join(__dirname, "..", "..", "app"); 28const MANIFEST_PATH = path.join(COMPONENTS_ROOT, "locales/manifest.json"); 29const LOCALES_FTL_DIR = path.join(COMPONENTS_ROOT, "locales"); 30const LOCALES_JSON_DIR = path.join(COMPONENTS_ROOT, "public/locales"); 31 32// Load manifest 33const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf8")); 34 35// Configuration for i18next-parser 36const parserConfig = { 37 contextSeparator: "_", 38 createOldCatalogs: false, 39 defaultNamespace: "messages", 40 defaultValue: "", 41 indentation: 2, 42 keepRemoved: true, 43 keySeparator: false, 44 namespaceSeparator: false, 45 46 lexers: { 47 js: ["JavascriptLexer"], 48 ts: ["JavascriptLexer"], 49 jsx: ["JsxLexer"], 50 tsx: ["JsxLexer"], 51 html: false, 52 htm: false, 53 handlebars: false, 54 hbs: false, 55 }, 56 57 locales: manifest.supportedLocales, 58 output: path.join(LOCALES_JSON_DIR, "$LOCALE/$NAMESPACE.json"), 59 input: [ 60 path.join(COMPONENTS_ROOT, "src/**/*.{js,jsx,ts,tsx}"), 61 path.join(APP_ROOT, "src/**/*.{js,jsx,ts,tsx}"), 62 path.join(APP_ROOT, "components/**/*.{js,jsx,ts,tsx}"), 63 "!**/node_modules/**", 64 "!**/dist/**", 65 "!**/*.test.{js,jsx,ts,tsx}", 66 "!**/*.spec.{js,jsx,ts,tsx}", 67 ], 68 69 verbose: true, 70 sort: true, 71 failOnWarnings: false, 72 failOnUpdate: false, 73}; 74 75/** 76 * Extract keys from codebase using i18next-parser 77 */ 78function extractKeys() { 79 const configPath = path.join(__dirname, ".i18next-parser.config.js"); 80 const configContent = `module.exports = ${JSON.stringify(parserConfig, null, 2)};`; 81 82 try { 83 fs.writeFileSync(configPath, configContent); 84 console.log("🔍 Extracting i18n keys from codebase..."); 85 86 execSync(`npx i18next-parser --config ${configPath}`, { 87 stdio: "inherit", 88 cwd: __dirname, 89 }); 90 91 console.log("✅ Keys extracted successfully!"); 92 return true; 93 } catch (error) { 94 console.error("❌ Error extracting i18n keys:", error.message); 95 return false; 96 } finally { 97 if (fs.existsSync(configPath)) { 98 fs.unlinkSync(configPath); 99 } 100 } 101} 102 103/** 104 * Read existing keys from .ftl files in a locale directory 105 * Returns a map of namespace -> Set of keys 106 */ 107function getExistingFtlKeys(localeDir) { 108 const keysByNamespace = {}; 109 110 if (!fs.existsSync(localeDir)) { 111 return keysByNamespace; 112 } 113 114 const ftlFiles = fs 115 .readdirSync(localeDir) 116 .filter((file) => file.endsWith(".ftl")); 117 118 for (const file of ftlFiles) { 119 const namespace = path.basename(file, ".ftl"); 120 const keys = new Set(); 121 122 const content = fs.readFileSync(path.join(localeDir, file), "utf8"); 123 const lines = content.split("\n"); 124 125 for (const line of lines) { 126 const trimmed = line.trim(); 127 const keyMatch = trimmed.match(/^([a-zA-Z][a-zA-Z0-9_-]*)\s*=/); 128 if (keyMatch) { 129 keys.add(keyMatch[1]); 130 } 131 } 132 133 keysByNamespace[namespace] = keys; 134 } 135 136 return keysByNamespace; 137} 138 139/** 140 * Get all namespaces (json files) in the locale directory 141 */ 142function getNamespaces(localeJsonDir) { 143 if (!fs.existsSync(localeJsonDir)) { 144 return []; 145 } 146 147 return fs 148 .readdirSync(localeJsonDir) 149 .filter((file) => file.endsWith(".json")) 150 .map((file) => path.basename(file, ".json")); 151} 152 153/** 154 * Add new keys to a .ftl file 155 */ 156function addKeysToFtlFile(localeDir, namespace, newKeys, locale) { 157 const targetFile = path.join(localeDir, `${namespace}.ftl`); 158 159 // Create file with header if it doesn't exist 160 if (!fs.existsSync(localeDir)) { 161 fs.mkdirSync(localeDir, { recursive: true }); 162 } 163 164 if (!fs.existsSync(targetFile)) { 165 const languageName = manifest.languages[locale]?.name || locale; 166 const namespaceName = 167 namespace.charAt(0).toUpperCase() + namespace.slice(1); 168 const header = `# ${namespaceName} translations - ${languageName}\n\n`; 169 fs.writeFileSync(targetFile, header); 170 } 171 172 // Append new keys 173 let content = fs.readFileSync(targetFile, "utf8"); 174 175 if (!content.endsWith("\n")) { 176 content += "\n"; 177 } 178 179 content += "\n# Newly extracted keys\n"; 180 content += newKeys.map((key) => `${key} = ${key}`).join("\n") + "\n"; 181 182 fs.writeFileSync(targetFile, content); 183 184 return targetFile; 185} 186 187/** 188 * Migrate extracted JSON keys to .ftl files 189 */ 190function migrateKeysToFtl() { 191 console.log("\n🔄 Analyzing extracted keys..."); 192 193 const newKeysByLocaleAndNamespace = {}; // locale -> namespace -> [keys] 194 195 // Process each locale 196 for (const locale of manifest.supportedLocales) { 197 const localeJsonDir = path.join(LOCALES_JSON_DIR, locale); 198 const localeFtlDir = path.join(LOCALES_FTL_DIR, locale); 199 200 if (!fs.existsSync(localeJsonDir)) { 201 console.log(`⚠️ No JSON files found for ${locale}`); 202 continue; 203 } 204 205 // Get all namespaces (json files) 206 const namespaces = getNamespaces(localeJsonDir); 207 208 if (namespaces.length === 0) { 209 console.log(`⚠️ No namespace files found for ${locale}`); 210 continue; 211 } 212 213 // Get existing keys from .ftl files 214 const existingKeysByNamespace = getExistingFtlKeys(localeFtlDir); 215 216 // Process each namespace 217 for (const namespace of namespaces) { 218 const jsonPath = path.join(localeJsonDir, `${namespace}.json`); 219 const jsonContent = JSON.parse(fs.readFileSync(jsonPath, "utf8")); 220 const extractedKeys = Object.keys(jsonContent); 221 222 // Get existing keys for this namespace 223 const existingKeys = existingKeysByNamespace[namespace] || new Set(); 224 225 // Find new keys 226 const newKeys = extractedKeys.filter((key) => !existingKeys.has(key)); 227 228 if (newKeys.length > 0) { 229 if (!newKeysByLocaleAndNamespace[locale]) { 230 newKeysByLocaleAndNamespace[locale] = {}; 231 } 232 newKeysByLocaleAndNamespace[locale][namespace] = newKeys; 233 } 234 } 235 } 236 237 // Check if there are any new keys 238 const hasNewKeys = Object.keys(newKeysByLocaleAndNamespace).length > 0; 239 240 if (!hasNewKeys) { 241 console.log( 242 "\n🎉 No new keys found. All extracted keys already exist in .ftl files.", 243 ); 244 return; 245 } 246 247 // Display found keys 248 console.log("\n📊 New keys found:"); 249 for (const locale of Object.keys(newKeysByLocaleAndNamespace)) { 250 console.log(`\n${locale}:`); 251 for (const namespace of Object.keys(newKeysByLocaleAndNamespace[locale])) { 252 const keys = newKeysByLocaleAndNamespace[locale][namespace]; 253 console.log(` 📝 ${namespace} (${keys.length} new keys):`); 254 keys.forEach((key) => console.log(` - ${key}`)); 255 } 256 } 257 258 // If --add-to flag is provided, add keys to that namespace 259 if (addToNamespace) { 260 console.log(`\n✍️ Adding new keys to ${addToNamespace}.ftl files...`); 261 262 let totalAdded = 0; 263 const processedFiles = []; 264 265 for (const locale of Object.keys(newKeysByLocaleAndNamespace)) { 266 const localeFtlDir = path.join(LOCALES_FTL_DIR, locale); 267 const namespacesForLocale = newKeysByLocaleAndNamespace[locale]; 268 269 // Collect all new keys across all namespaces for this locale 270 const allNewKeys = []; 271 for (const namespace of Object.keys(namespacesForLocale)) { 272 allNewKeys.push(...namespacesForLocale[namespace]); 273 } 274 275 if (allNewKeys.length === 0) continue; 276 277 // Add all keys to the specified namespace 278 const targetFile = addKeysToFtlFile( 279 localeFtlDir, 280 addToNamespace, 281 allNewKeys, 282 locale, 283 ); 284 processedFiles.push(path.relative(process.cwd(), targetFile)); 285 totalAdded += allNewKeys.length; 286 287 console.log( 288 `${locale}: Added ${allNewKeys.length} keys to ${addToNamespace}.ftl`, 289 ); 290 } 291 292 console.log( 293 `\n🎉 Migration complete! Added ${totalAdded} new keys to ${addToNamespace}.ftl files.`, 294 ); 295 console.log("\nModified files:"); 296 processedFiles.forEach((file) => console.log(` 📄 ${file}`)); 297 298 console.log("\n💡 Next steps:"); 299 console.log(" 1. Review the new keys in your .ftl files"); 300 console.log(" 2. Replace placeholder values with actual translations"); 301 console.log(" 3. Run `pnpm i18n:compile` to update compiled JSON files"); 302 } else { 303 // Just report 304 let totalNewKeys = 0; 305 const namespaceSet = new Set(); 306 307 for (const locale of Object.keys(newKeysByLocaleAndNamespace)) { 308 for (const namespace of Object.keys( 309 newKeysByLocaleAndNamespace[locale], 310 )) { 311 namespaceSet.add(namespace); 312 totalNewKeys += newKeysByLocaleAndNamespace[locale][namespace].length; 313 } 314 } 315 316 console.log( 317 `\n💡 Found ${totalNewKeys} new keys across ${namespaceSet.size} namespace(s).`, 318 ); 319 console.log("\nTo add these keys to a specific namespace file, run:"); 320 Array.from(namespaceSet).forEach((ns) => { 321 console.log(` node extract-i18n.js --add-to=${ns}`); 322 }); 323 } 324} 325 326function main() { 327 const success = extractKeys(); 328 329 if (success) { 330 migrateKeysToFtl(); 331 } else { 332 process.exit(1); 333 } 334} 335 336main();