Skip to content

Commit 896f0a1

Browse files
committed
feat: refactor transforms for clearer code
1 parent 981f702 commit 896f0a1

15 files changed

Lines changed: 2103 additions & 1812 deletions

File tree

cmp/compiler/src/metadata/manager.ts

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -147,16 +147,7 @@ export function upsertEntry(
147147
metadata: MetadataSchema,
148148
entry: TranslationEntry,
149149
): MetadataSchema {
150-
const existing = metadata.entries[entry.hash];
151-
152-
if (existing) {
153-
metadata.entries[entry.hash] = {
154-
...existing,
155-
lastSeenAt: new Date().toISOString(),
156-
};
157-
} else {
158-
metadata.entries[entry.hash] = entry;
159-
}
150+
metadata.entries[entry.hash] = entry;
160151

161152
return metadata;
162153
}
@@ -193,21 +184,16 @@ export function hasEntry(metadata: MetadataSchema, hash: string): boolean {
193184
}
194185

195186
/**
196-
* Remove stale entries (entries not seen in a while)
197-
* This is useful for cleanup but should be done carefully
187+
* Remove entries by hash
198188
*/
199-
export function removeStaleEntries(
189+
export function removeEntries(
200190
metadata: MetadataSchema,
201-
maxAgeMs: number = 30 * 24 * 60 * 60 * 1000, // 30 days default
191+
hashesToRemove: Set<string>,
202192
): MetadataSchema {
203-
const now = Date.now();
204193
const filtered: Record<string, TranslationEntry> = {};
205194

206195
for (const [hash, entry] of Object.entries(metadata.entries)) {
207-
const lastSeen = entry.lastSeenAt || entry.addedAt;
208-
const age = now - new Date(lastSeen).getTime();
209-
210-
if (age < maxAgeMs) {
196+
if (!hashesToRemove.has(hash)) {
211197
filtered[hash] = entry;
212198
}
213199
}
Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
/**
2+
* Build-time translation processor
3+
*
4+
* Handles translation generation and validation at build time
5+
* Supports two modes:
6+
* - "translate": Generate all translations, fail if translation fails
7+
* - "cache-only": Validate cache completeness, fail if incomplete
8+
*/
9+
10+
import fs from "fs/promises";
11+
import path from "path";
12+
import type { LingoConfig, MetadataSchema } from "../types";
13+
import { logger } from "../utils/logger";
14+
import { getCachePath } from "../utils/path-helpers";
15+
import {
16+
startTranslationServer,
17+
type TranslationServer,
18+
} from "../translation-server";
19+
import { loadMetadata } from "../metadata/manager";
20+
21+
export interface BuildTranslationOptions {
22+
/**
23+
* Lingo configuration
24+
*/
25+
config: LingoConfig;
26+
27+
/**
28+
* Build mode (overrides config.buildMode if provided)
29+
* Can be set via LINGO_BUILD_MODE environment variable
30+
*/
31+
buildMode?: "translate" | "cache-only";
32+
33+
/**
34+
* Output directory for static translation files
35+
* If not provided, files won't be generated
36+
*/
37+
publicOutputPath?: string;
38+
}
39+
40+
export interface BuildTranslationResult {
41+
/**
42+
* Whether the build succeeded
43+
*/
44+
success: boolean;
45+
46+
/**
47+
* Error message if build failed
48+
*/
49+
error?: string;
50+
51+
/**
52+
* Translation statistics per locale
53+
*/
54+
stats: Record<
55+
string,
56+
{
57+
total: number;
58+
translated: number;
59+
failed: number;
60+
}
61+
>;
62+
}
63+
64+
/**
65+
* Process translations at build time
66+
*
67+
* @throws Error if validation or translation fails (causes build to fail)
68+
*/
69+
export async function processBuildTranslations(
70+
options: BuildTranslationOptions,
71+
): Promise<BuildTranslationResult> {
72+
const { config, publicOutputPath } = options;
73+
74+
// Determine build mode (env var > options > config)
75+
const buildMode =
76+
(process.env.LINGO_BUILD_MODE as "translate" | "cache-only") ||
77+
options.buildMode ||
78+
config.buildMode;
79+
80+
logger.info(`🌍 Build mode: ${buildMode}`);
81+
82+
// Load metadata
83+
const metadata = await loadMetadata(config);
84+
85+
if (!metadata || Object.keys(metadata.entries).length === 0) {
86+
logger.info("No translations to process (metadata is empty)");
87+
return {
88+
success: true,
89+
stats: {},
90+
};
91+
}
92+
93+
const totalEntries = Object.keys(metadata.entries).length;
94+
logger.info(`📊 Found ${totalEntries} translatable entries`);
95+
96+
// Handle cache-only mode
97+
if (buildMode === "cache-only") {
98+
logger.info("🔍 Validating translation cache...");
99+
await validateCache(config, metadata);
100+
logger.info("✅ Cache validation passed");
101+
102+
// Copy cache to public directory if requested
103+
if (publicOutputPath) {
104+
await copyStaticFiles(config, publicOutputPath);
105+
}
106+
107+
return {
108+
success: true,
109+
stats: buildCacheStats(config, metadata),
110+
};
111+
}
112+
113+
// Handle translate mode
114+
logger.info("🔄 Generating translations...");
115+
let translationServer: TranslationServer | undefined;
116+
117+
try {
118+
translationServer = await startTranslationServer({
119+
startPort: config.dev.serverStartPort,
120+
onError: (err) => {
121+
logger.error("Translation server error:", err);
122+
},
123+
config,
124+
});
125+
126+
logger.info(
127+
`Processing translations for ${config.targetLocales.length} locale(s)...`,
128+
);
129+
130+
const stats: BuildTranslationResult["stats"] = {};
131+
const errors: Array<{ locale: string; error: string }> = [];
132+
133+
// Translate all locales in parallel
134+
// TODO (AleksandrSl 07/12/2025): We have to include the sourceLocale too.
135+
const localePromises = config.targetLocales.map(async (locale) => {
136+
logger.info(`Translating to ${locale}...`);
137+
138+
const result = await translationServer!.translateAll(locale);
139+
140+
stats[locale] = {
141+
total: totalEntries,
142+
translated: Object.keys(result.translations).length,
143+
failed: result.errors.length,
144+
};
145+
146+
if (result.errors.length > 0) {
147+
logger.warn(
148+
`⚠️ ${result.errors.length} translation error(s) for ${locale}`,
149+
);
150+
errors.push({
151+
locale,
152+
error: `${result.errors.length} translation(s) failed`,
153+
});
154+
} else {
155+
logger.info(`✅ ${locale} completed successfully`);
156+
}
157+
});
158+
159+
await Promise.all(localePromises);
160+
161+
// Fail build if any translations failed in translate mode
162+
if (errors.length > 0) {
163+
const errorMsg = formatTranslationErrors(errors);
164+
logger.error(errorMsg);
165+
throw new Error(errorMsg);
166+
}
167+
168+
// Copy cache to public directory if requested
169+
if (publicOutputPath) {
170+
await copyStaticFiles(config, publicOutputPath);
171+
}
172+
173+
logger.info("✅ Translation generation completed successfully");
174+
175+
return {
176+
success: true,
177+
stats,
178+
};
179+
} catch (error) {
180+
logger.error("❌ Translation generation failed:", error);
181+
throw error;
182+
} finally {
183+
if (translationServer) {
184+
await translationServer.stop();
185+
logger.info("🛑 Translation server stopped");
186+
}
187+
}
188+
}
189+
190+
/**
191+
* Validate that all required translations exist in cache
192+
* @throws Error if cache is incomplete or missing
193+
*/
194+
async function validateCache(
195+
config: LingoConfig,
196+
metadata: MetadataSchema,
197+
): Promise<void> {
198+
const allHashes = Object.keys(metadata.entries);
199+
const missingLocales: string[] = [];
200+
const incompleteLocales: Array<{
201+
locale: string;
202+
missing: number;
203+
total: number;
204+
}> = [];
205+
206+
for (const locale of config.targetLocales) {
207+
const cacheFilePath = getCachePath(config, locale);
208+
209+
try {
210+
const cacheContent = await fs.readFile(cacheFilePath, "utf-8");
211+
const cache = JSON.parse(cacheContent) as Record<string, string>;
212+
213+
// Check if all hashes exist in cache
214+
const missingHashes = allHashes.filter((hash) => !cache[hash]);
215+
216+
if (missingHashes.length > 0) {
217+
incompleteLocales.push({
218+
locale,
219+
missing: missingHashes.length,
220+
total: allHashes.length,
221+
});
222+
223+
// Log first few missing hashes for debugging
224+
logger.debug(
225+
`Missing hashes in ${locale}: ${missingHashes.slice(0, 5).join(", ")}${
226+
missingHashes.length > 5 ? "..." : ""
227+
}`,
228+
);
229+
}
230+
} catch (error) {
231+
missingLocales.push(locale);
232+
logger.debug(`Cache file not found for ${locale}: ${cacheFilePath}`);
233+
}
234+
}
235+
236+
if (missingLocales.length > 0 || incompleteLocales.length > 0) {
237+
const errorMsg = formatCacheValidationError(
238+
missingLocales,
239+
incompleteLocales,
240+
);
241+
throw new Error(errorMsg);
242+
}
243+
}
244+
245+
/**
246+
* Build statistics from cache files
247+
*/
248+
function buildCacheStats(
249+
config: LingoConfig,
250+
metadata: MetadataSchema,
251+
): BuildTranslationResult["stats"] {
252+
const totalEntries = Object.keys(metadata.entries).length;
253+
const stats: BuildTranslationResult["stats"] = {};
254+
255+
for (const locale of config.targetLocales) {
256+
stats[locale] = {
257+
total: totalEntries,
258+
translated: totalEntries, // Assumed complete if validation passed
259+
failed: 0,
260+
};
261+
}
262+
263+
return stats;
264+
}
265+
266+
/**
267+
* Copy cached translation files to public directory
268+
*/
269+
async function copyStaticFiles(
270+
config: LingoConfig,
271+
publicOutputPath: string,
272+
): Promise<void> {
273+
logger.info(`📦 Generating static translation files in ${publicOutputPath}`);
274+
275+
await fs.mkdir(publicOutputPath, { recursive: true });
276+
277+
for (const locale of config.targetLocales) {
278+
const cacheFilePath = getCachePath(config, locale);
279+
const publicFilePath = path.join(publicOutputPath, `${locale}.json`);
280+
281+
try {
282+
await fs.copyFile(cacheFilePath, publicFilePath);
283+
logger.info(`✓ Generated ${locale}.json`);
284+
} catch (error) {
285+
logger.error(`Failed to copy ${locale}.json:`, error);
286+
throw new Error(`Failed to generate static file for ${locale}: ${error}`);
287+
}
288+
}
289+
}
290+
291+
/**
292+
* Format cache validation error message
293+
*/
294+
function formatCacheValidationError(
295+
missingLocales: string[],
296+
incompleteLocales: Array<{ locale: string; missing: number; total: number }>,
297+
): string {
298+
let msg = "❌ Cache validation failed in cache-only mode:\n\n";
299+
300+
if (missingLocales.length > 0) {
301+
msg += ` 📁 Missing cache files:\n`;
302+
msg += missingLocales.map((locale) => ` - ${locale}.json`).join("\n");
303+
msg += "\n\n";
304+
}
305+
306+
if (incompleteLocales.length > 0) {
307+
msg += ` 📊 Incomplete cache:\n`;
308+
msg += incompleteLocales
309+
.map(
310+
(item) =>
311+
` - ${item.locale}: ${item.missing}/${item.total} translations missing`,
312+
)
313+
.join("\n");
314+
msg += "\n\n";
315+
}
316+
317+
msg += ` 💡 To fix:\n`;
318+
msg += ` 1. Set LINGO_BUILD_MODE=translate to generate translations\n`;
319+
msg += ` 2. Commit the generated .lingo/cache/*.json files\n`;
320+
msg += ` 3. Ensure translation API keys are available if generating translations`;
321+
322+
return msg;
323+
}
324+
325+
/**
326+
* Format translation error message
327+
*/
328+
function formatTranslationErrors(
329+
errors: Array<{ locale: string; error: string }>,
330+
): string {
331+
let msg = "❌ Translation generation failed:\n\n";
332+
333+
msg += errors.map((err) => ` - ${err.locale}: ${err.error}`).join("\n");
334+
335+
msg += "\n\n";
336+
msg += ` 💡 Translation errors must be resolved in "translate" mode.\n`;
337+
msg += ` Check translation server logs for details.`;
338+
339+
return msg;
340+
}

0 commit comments

Comments
 (0)