fork
Configure Feed
Select the types of activity you want to include in your feed.
Live video on the AT Protocol
fork
Configure Feed
Select the types of activity you want to include in your feed.
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();