Live video on the AT Protocol
at next 413 lines 13 kB view raw
1#!/usr/bin/env node 2 3/** 4 * i18n migration script 5 * Migrates extracted JSON keys to .ftl files for translation 6 * 7 * This script expects that i18next-cli has already extracted keys to JSON files. 8 * It reads those JSON files, compares them to existing .ftl files, and adds any 9 * new keys to the .ftl files. 10 * 11 * For keys with i18next context/plural suffixes (e.g., key_male, key_female, key_one, key_other), 12 * it will convert them into Fluent select expressions. 13 * 14 * Usage: 15 * node migrate-i18n.js # Report new keys 16 * node migrate-i18n.js --add-to=common # Add new keys to common.ftl 17 * node migrate-i18n.js --add-to=settings # Add new keys to settings.ftl 18 */ 19 20const fs = require("fs"); 21const path = require("path"); 22 23// Parse command line arguments 24const args = process.argv.slice(2); 25const addToNamespace = args 26 .find((arg) => arg.startsWith("--add-to=")) 27 ?.split("=")[1]; 28 29// Paths 30const COMPONENTS_ROOT = path.join(__dirname, ".."); 31const MANIFEST_PATH = path.join(COMPONENTS_ROOT, "locales/manifest.json"); 32const LOCALES_FTL_DIR = path.join(COMPONENTS_ROOT, "locales"); 33const LOCALES_JSON_DIR = path.join(COMPONENTS_ROOT, "public/locales"); 34 35// Load manifest 36const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf8")); 37 38// Plural forms that i18next uses 39const PLURAL_FORMS = ["zero", "one", "two", "few", "many", "other"]; 40 41// Separators used by i18next-cli (configured in i18next.config.js) 42const CONTEXT_SEPARATOR = "|"; 43const PLURAL_SEPARATOR = "/"; 44 45/** 46 * Group keys by base name, detecting context and plural variants 47 * Returns { baseKey: { base: true, variants: { context: Set, plurals: Set } } } 48 */ 49function groupKeysByBase(keys) { 50 const groups = {}; 51 52 for (const key of keys) { 53 if (!key.includes(CONTEXT_SEPARATOR) && !key.includes(PLURAL_SEPARATOR)) { 54 // Simple key with no variants 55 if (!groups[key]) { 56 groups[key] = { 57 base: true, 58 variants: { contexts: new Set(), plurals: new Set() }, 59 }; 60 } 61 groups[key].base = true; 62 } else { 63 // Key with variants 64 // Format: base|context/plural or base/plural or base|context 65 let baseKey = key; 66 const detectedContexts = new Set(); 67 const detectedPlurals = new Set(); 68 69 // Split by context separator first 70 if (key.includes(CONTEXT_SEPARATOR)) { 71 const contextParts = key.split(CONTEXT_SEPARATOR); 72 baseKey = contextParts[0]; 73 74 // The remaining part might have plurals 75 const contextAndPlural = contextParts[1]; 76 77 if (contextAndPlural.includes(PLURAL_SEPARATOR)) { 78 const pluralParts = contextAndPlural.split(PLURAL_SEPARATOR); 79 detectedContexts.add(pluralParts[0]); 80 pluralParts.slice(1).forEach((p) => { 81 if (PLURAL_FORMS.includes(p)) { 82 detectedPlurals.add(p); 83 } 84 }); 85 } else { 86 detectedContexts.add(contextAndPlural); 87 } 88 } else if (key.includes(PLURAL_SEPARATOR)) { 89 // No context, just plural 90 const pluralParts = key.split(PLURAL_SEPARATOR); 91 baseKey = pluralParts[0]; 92 pluralParts.slice(1).forEach((p) => { 93 if (PLURAL_FORMS.includes(p)) { 94 detectedPlurals.add(p); 95 } 96 }); 97 } 98 99 if (!groups[baseKey]) { 100 groups[baseKey] = { 101 base: false, 102 variants: { contexts: new Set(), plurals: new Set() }, 103 }; 104 } 105 106 detectedContexts.forEach((c) => groups[baseKey].variants.contexts.add(c)); 107 detectedPlurals.forEach((p) => groups[baseKey].variants.plurals.add(p)); 108 } 109 } 110 111 return groups; 112} 113 114/** 115 * Convert a group of keys into Fluent format 116 */ 117function convertToFluentFormat(baseKey, group) { 118 const hasContexts = group.variants.contexts.size > 0; 119 const hasPlurals = group.variants.plurals.size > 0; 120 121 if (!hasContexts && !hasPlurals) { 122 // Simple key 123 return `${baseKey} = ${baseKey}`; 124 } 125 126 // Build Fluent select expression 127 let selector = ""; 128 let variants = []; 129 130 if (hasContexts && hasPlurals) { 131 // Both context and plural - outer selector is context, inner is plural 132 selector = "$context"; 133 const contextsList = Array.from(group.variants.contexts).sort(); 134 const pluralsList = Array.from(group.variants.plurals).sort(); 135 136 contextsList.forEach((context, idx) => { 137 const isDefault = idx === contextsList.length - 1; 138 const prefix = isDefault ? "*" : " "; 139 140 // Build inner plural select 141 const pluralVariants = pluralsList 142 .map((p) => { 143 const pluralPrefix = p === "other" ? "*" : ""; 144 return `${pluralPrefix}[${p}] ${baseKey}`; 145 }) 146 .join(" "); 147 148 variants.push( 149 `\n ${prefix}[${context}] { $count -> ${pluralVariants} }`, 150 ); 151 }); 152 } else if (hasContexts) { 153 // Only context 154 selector = "$context"; 155 const contextsList = Array.from(group.variants.contexts).sort(); 156 contextsList.forEach((context, idx) => { 157 const isDefault = idx === contextsList.length - 1; 158 const prefix = isDefault ? "*" : " "; 159 variants.push(`\n ${prefix}[${context}] ${baseKey}`); 160 }); 161 } else if (hasPlurals) { 162 // Only plural 163 selector = "$count"; 164 const pluralsList = Array.from(group.variants.plurals).sort(); 165 pluralsList.forEach((plural) => { 166 const isDefault = plural === "other"; 167 const prefix = isDefault ? "*" : " "; 168 variants.push(`\n ${prefix}[${plural}] ${baseKey}`); 169 }); 170 } 171 172 return `# TODO: Convert to proper Fluent select expression\n${baseKey} = { ${selector} ->${variants.join("")}\n}`; 173} 174 175/** 176 * Read existing keys from .ftl files in a locale directory 177 * Returns a map of namespace -> Set of keys 178 */ 179function getExistingFtlKeys(localeDir) { 180 const keysByNamespace = {}; 181 182 if (!fs.existsSync(localeDir)) { 183 return keysByNamespace; 184 } 185 186 const ftlFiles = fs 187 .readdirSync(localeDir) 188 .filter((file) => file.endsWith(".ftl")); 189 190 for (const file of ftlFiles) { 191 const namespace = path.basename(file, ".ftl"); 192 const keys = new Set(); 193 194 const content = fs.readFileSync(path.join(localeDir, file), "utf8"); 195 const lines = content.split("\n"); 196 197 for (const line of lines) { 198 const trimmed = line.trim(); 199 const keyMatch = trimmed.match(/^([a-zA-Z][a-zA-Z0-9_-]*)\s*=/); 200 if (keyMatch) { 201 keys.add(keyMatch[1]); 202 } 203 } 204 205 keysByNamespace[namespace] = keys; 206 } 207 208 return keysByNamespace; 209} 210 211/** 212 * Get all namespaces (json files) in the locale directory 213 */ 214function getNamespaces(localeJsonDir) { 215 if (!fs.existsSync(localeJsonDir)) { 216 return []; 217 } 218 219 return fs 220 .readdirSync(localeJsonDir) 221 .filter((file) => file.endsWith(".json")) 222 .map((file) => path.basename(file, ".json")); 223} 224 225/** 226 * Add new keys to a .ftl file, converting context/plural keys to Fluent format 227 */ 228function addKeysToFtlFile(localeDir, namespace, newKeys, locale) { 229 const targetFile = path.join(localeDir, `${namespace}.ftl`); 230 231 // Create file with header if it doesn't exist 232 if (!fs.existsSync(localeDir)) { 233 fs.mkdirSync(localeDir, { recursive: true }); 234 } 235 236 if (!fs.existsSync(targetFile)) { 237 const languageName = manifest.languages[locale]?.name || locale; 238 const namespaceName = 239 namespace.charAt(0).toUpperCase() + namespace.slice(1); 240 const header = `# ${namespaceName} translations - ${languageName}\n\n`; 241 fs.writeFileSync(targetFile, header); 242 } 243 244 // Group keys by base to detect context/plural variants 245 const keyGroups = groupKeysByBase(newKeys); 246 247 // Build content 248 const fluentEntries = []; 249 for (const [baseKey, group] of Object.entries(keyGroups)) { 250 fluentEntries.push(convertToFluentFormat(baseKey, group)); 251 } 252 253 // Append new keys 254 let content = fs.readFileSync(targetFile, "utf8"); 255 256 if (!content.endsWith("\n")) { 257 content += "\n"; 258 } 259 260 content += "\n# Newly extracted keys\n"; 261 content += fluentEntries.join("\n\n") + "\n"; 262 263 fs.writeFileSync(targetFile, content); 264 265 return targetFile; 266} 267 268/** 269 * Migrate extracted JSON keys to .ftl files 270 */ 271function migrateKeysToFtl() { 272 console.log("🔄 Analyzing extracted keys..."); 273 274 const newKeysByLocaleAndNamespace = {}; // locale -> namespace -> [keys] 275 276 // Process each locale 277 for (const locale of manifest.supportedLocales) { 278 const localeJsonDir = path.join(LOCALES_JSON_DIR, locale); 279 const localeFtlDir = path.join(LOCALES_FTL_DIR, locale); 280 281 if (!fs.existsSync(localeJsonDir)) { 282 console.log(`⚠️ No JSON files found for ${locale}`); 283 continue; 284 } 285 286 // Get all namespaces (json files) 287 const namespaces = getNamespaces(localeJsonDir); 288 289 if (namespaces.length === 0) { 290 console.log(`⚠️ No namespace files found for ${locale}`); 291 continue; 292 } 293 294 // Get existing keys from .ftl files 295 const existingKeysByNamespace = getExistingFtlKeys(localeFtlDir); 296 297 // Process each namespace 298 for (const namespace of namespaces) { 299 const jsonPath = path.join(localeJsonDir, `${namespace}.json`); 300 const jsonContent = JSON.parse(fs.readFileSync(jsonPath, "utf8")); 301 const extractedKeys = Object.keys(jsonContent); 302 303 // Get existing keys for this namespace 304 const existingKeys = existingKeysByNamespace[namespace] || new Set(); 305 306 // Find new keys 307 const newKeys = extractedKeys.filter((key) => !existingKeys.has(key)); 308 309 if (newKeys.length > 0) { 310 if (!newKeysByLocaleAndNamespace[locale]) { 311 newKeysByLocaleAndNamespace[locale] = {}; 312 } 313 newKeysByLocaleAndNamespace[locale][namespace] = newKeys; 314 } 315 } 316 } 317 318 // Check if there are any new keys 319 const hasNewKeys = Object.keys(newKeysByLocaleAndNamespace).length > 0; 320 321 if (!hasNewKeys) { 322 console.log( 323 "\n🎉 No new keys found. All extracted keys already exist in .ftl files.", 324 ); 325 return; 326 } 327 328 // Display found keys 329 console.log("\n📊 New keys found:"); 330 for (const locale of Object.keys(newKeysByLocaleAndNamespace)) { 331 console.log(`\n${locale}:`); 332 for (const namespace of Object.keys(newKeysByLocaleAndNamespace[locale])) { 333 const keys = newKeysByLocaleAndNamespace[locale][namespace]; 334 console.log(` 📝 ${namespace} (${keys.length} new keys):`); 335 keys.forEach((key) => console.log(` - ${key}`)); 336 } 337 } 338 339 // If --add-to flag is provided, add keys to that namespace 340 if (addToNamespace) { 341 console.log(`\n✍️ Adding new keys to ${addToNamespace}.ftl files...`); 342 343 let totalAdded = 0; 344 const processedFiles = []; 345 346 for (const locale of Object.keys(newKeysByLocaleAndNamespace)) { 347 const localeFtlDir = path.join(LOCALES_FTL_DIR, locale); 348 const namespacesForLocale = newKeysByLocaleAndNamespace[locale]; 349 350 // Collect all new keys across all namespaces for this locale 351 const allNewKeys = []; 352 for (const namespace of Object.keys(namespacesForLocale)) { 353 allNewKeys.push(...namespacesForLocale[namespace]); 354 } 355 356 if (allNewKeys.length === 0) continue; 357 358 // Add all keys to the specified namespace 359 const targetFile = addKeysToFtlFile( 360 localeFtlDir, 361 addToNamespace, 362 allNewKeys, 363 locale, 364 ); 365 processedFiles.push(path.relative(process.cwd(), targetFile)); 366 totalAdded += allNewKeys.length; 367 368 console.log( 369 `${locale}: Added ${allNewKeys.length} keys to ${addToNamespace}.ftl`, 370 ); 371 } 372 373 console.log( 374 `\n🎉 Migration complete! Added ${totalAdded} new keys to ${addToNamespace}.ftl files.`, 375 ); 376 console.log("\nModified files:"); 377 processedFiles.forEach((file) => console.log(` 📄 ${file}`)); 378 379 console.log("\n💡 Next steps:"); 380 console.log(" 1. Review the new keys in your .ftl files"); 381 console.log( 382 " 2. Convert TODO placeholders to proper Fluent translations", 383 ); 384 console.log(" 3. Run `pnpm i18n:compile` to update compiled JSON files"); 385 } else { 386 // Just report 387 let totalNewKeys = 0; 388 const namespaceSet = new Set(); 389 390 for (const locale of Object.keys(newKeysByLocaleAndNamespace)) { 391 for (const namespace of Object.keys( 392 newKeysByLocaleAndNamespace[locale], 393 )) { 394 namespaceSet.add(namespace); 395 totalNewKeys += newKeysByLocaleAndNamespace[locale][namespace].length; 396 } 397 } 398 399 console.log( 400 `\n💡 Found ${totalNewKeys} new keys across ${namespaceSet.size} namespace(s).`, 401 ); 402 console.log("\nTo add these keys to a specific namespace file, run:"); 403 Array.from(namespaceSet).forEach((ns) => { 404 console.log(` node migrate-i18n.js --add-to=${ns}`); 405 }); 406 } 407} 408 409function main() { 410 migrateKeysToFtl(); 411} 412 413main();