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