Live video on the AT Protocol
1#!/usr/bin/env node
2
3const fs = require("fs");
4const path = require("path");
5const { parse: parseFtl } = require("@fluent/syntax");
6
7// Load language manifest
8const MANIFEST_PATH = path.join(__dirname, "..", "locales", "manifest.json");
9const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf-8"));
10
11// Configuration
12const LOCALES_SOURCE_DIR = path.join(__dirname, "..", "locales");
13const LOCALES_OUTPUT_DIR = path.join(__dirname, "..", "public", "locales");
14
15/**
16 * Find all .ftl files in a directory (non-recursive, just top-level)
17 */
18function findFtlFiles(dir) {
19 const files = [];
20 const entries = fs.readdirSync(dir, { withFileTypes: true });
21
22 for (const entry of entries) {
23 if (entry.isFile() && entry.name.endsWith(".ftl")) {
24 files.push({
25 path: path.join(dir, entry.name),
26 namespace: path.basename(entry.name, ".ftl"),
27 });
28 }
29 }
30
31 return files;
32}
33
34/**
35 * Parse FTL content using @fluent/syntax and extract messages
36 */
37function extractMessagesFromFtl(content) {
38 const messages = {};
39
40 try {
41 const resource = parseFtl(content);
42
43 for (const entry of resource.body) {
44 // Only process Message entries (not Comments, Terms, etc.)
45 if (entry.type === "Message" && entry.id) {
46 const key = entry.id.name;
47
48 // For now, just serialize the pattern back to FTL format
49 // i18next-fluent will handle the actual parsing at runtime
50 if (entry.value) {
51 messages[key] = serializePattern(entry.value);
52 }
53 }
54 }
55 } catch (error) {
56 console.error("Error parsing FTL:", error.message);
57 throw error;
58 }
59
60 return messages;
61}
62
63/**
64 * Serialize a Fluent pattern back to FTL string format
65 * This is a simple serializer - @fluent/syntax has a full serializer
66 * but we just need the pattern text for JSON storage
67 */
68function serializePattern(pattern) {
69 if (!pattern || !pattern.elements) {
70 return "";
71 }
72
73 let result = "";
74
75 for (const element of pattern.elements) {
76 if (element.type === "TextElement") {
77 result += element.value;
78 } else if (element.type === "Placeable") {
79 result += serializePlaceable(element);
80 }
81 }
82
83 return result;
84}
85
86/**
87 * Serialize a placeable (variables, select expressions, etc.)
88 */
89function serializePlaceable(placeable) {
90 if (!placeable.expression) {
91 return "{}";
92 }
93
94 const expr = placeable.expression;
95
96 if (expr.type === "VariableReference") {
97 return `{$${expr.id.name}}`;
98 } else if (expr.type === "SelectExpression") {
99 let result = `{${serializeInlineExpression(expr.selector)} ->`;
100
101 for (const variant of expr.variants) {
102 const key =
103 variant.key.type === "Identifier"
104 ? `[${variant.key.name}]`
105 : `[${variant.key.value}]`;
106
107 result += `\n ${variant.default ? "*" : ""}${key} ${serializePattern(variant.value)}`;
108 }
109
110 result += "\n }";
111 return result;
112 } else if (expr.type === "FunctionReference") {
113 const args = expr.arguments.positional
114 .map((arg) => serializeInlineExpression(arg))
115 .join(", ");
116 return `{${expr.id.name}(${args})}`;
117 }
118
119 return "{}";
120}
121
122/**
123 * Serialize inline expressions (for function arguments, etc.)
124 */
125function serializeInlineExpression(expr) {
126 if (expr.type === "VariableReference") {
127 return `$${expr.id.name}`;
128 } else if (expr.type === "NumberLiteral") {
129 return expr.value;
130 } else if (expr.type === "StringLiteral") {
131 return `"${expr.value}"`;
132 }
133
134 return "";
135}
136
137/**
138 * Main compilation function
139 */
140function compileTranslations() {
141 console.log("🌍 Compiling translation files...");
142
143 if (!fs.existsSync(LOCALES_SOURCE_DIR)) {
144 console.error(`❌ Locales directory not found: ${LOCALES_SOURCE_DIR}`);
145 process.exit(1);
146 }
147
148 // Create output directory if it doesn't exist
149 if (!fs.existsSync(LOCALES_OUTPUT_DIR)) {
150 fs.mkdirSync(LOCALES_OUTPUT_DIR, { recursive: true });
151 }
152
153 // Get supported locales from manifest
154 const manifestLocales = manifest.supportedLocales;
155 const locales = manifestLocales.filter((locale) => {
156 const localeDir = path.join(LOCALES_SOURCE_DIR, locale);
157 return fs.existsSync(localeDir) && fs.statSync(localeDir).isDirectory();
158 });
159
160 if (locales.length === 0) {
161 console.error(`❌ No locale directories found in ${LOCALES_SOURCE_DIR}`);
162 process.exit(1);
163 }
164
165 let totalFiles = 0;
166
167 // Process each locale
168 for (const locale of locales) {
169 const localeSourceDir = path.join(LOCALES_SOURCE_DIR, locale);
170 const localeOutputDir = path.join(LOCALES_OUTPUT_DIR, locale);
171
172 console.log(`📦 Processing locale: ${locale}`);
173
174 // Create locale output directory
175 if (!fs.existsSync(localeOutputDir)) {
176 fs.mkdirSync(localeOutputDir, { recursive: true });
177 }
178
179 // Find all .ftl files for this locale
180 const ftlFiles = findFtlFiles(localeSourceDir);
181
182 if (ftlFiles.length === 0) {
183 console.warn(`⚠️ No .ftl files found in ${localeSourceDir}`);
184 continue;
185 }
186
187 // Process each .ftl file as a separate namespace
188 for (const { path: ftlPath, namespace } of ftlFiles) {
189 try {
190 const content = fs.readFileSync(ftlPath, "utf-8");
191 const messages = extractMessagesFromFtl(content);
192
193 if (Object.keys(messages).length === 0) {
194 console.warn(`⚠️ No messages found in ${ftlPath}`);
195 continue;
196 }
197
198 // Write namespace JSON file
199 const outputPath = path.join(localeOutputDir, `${namespace}.json`);
200 fs.writeFileSync(
201 outputPath,
202 JSON.stringify(messages, null, 2),
203 "utf-8",
204 );
205
206 console.log(
207 ` ✅ ${namespace}: ${Object.keys(messages).length} keys → ${path.relative(process.cwd(), outputPath)}`,
208 );
209 totalFiles++;
210 } catch (error) {
211 console.error(`❌ Error processing ${ftlPath}:`, error.message);
212 }
213 }
214 }
215
216 console.log(
217 `🎉 Compilation complete! ${totalFiles} namespace files generated.`,
218 );
219}
220
221/**
222 * Copy compiled translations to app/public/locales
223 */
224function copyToApp() {
225 const appPublicLocales = path.join(
226 __dirname,
227 "..",
228 "..",
229 "app",
230 "public",
231 "locales",
232 );
233
234 console.log("\n📋 Copying translations to app...");
235
236 // Remove old locales directory in app
237 if (fs.existsSync(appPublicLocales)) {
238 fs.rmSync(appPublicLocales, { recursive: true, force: true });
239 }
240
241 // Copy compiled locales to app
242 fs.cpSync(LOCALES_OUTPUT_DIR, appPublicLocales, { recursive: true });
243
244 console.log(`✅ Copied to ${path.relative(process.cwd(), appPublicLocales)}`);
245}
246
247// Run the compilation
248compileTranslations();
249
250// Copy to app if it exists
251const appPath = path.join(__dirname, "..", "..", "app");
252if (fs.existsSync(appPath)) {
253 copyToApp();
254}