Live video on the AT Protocol
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();