@lingo.dev/compiler 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +273 -2
  2. package/build/metadata/manager.mjs +12 -12
  3. package/build/metadata/manager.mjs.map +1 -1
  4. package/build/plugin/build-translator.cjs +3 -3
  5. package/build/plugin/build-translator.mjs +6 -6
  6. package/build/plugin/build-translator.mjs.map +1 -1
  7. package/build/plugin/next.cjs +3 -3
  8. package/build/plugin/next.d.cts.map +1 -1
  9. package/build/plugin/next.d.mts.map +1 -1
  10. package/build/plugin/next.mjs +3 -3
  11. package/build/plugin/next.mjs.map +1 -1
  12. package/build/plugin/unplugin.cjs +3 -2
  13. package/build/plugin/unplugin.d.cts.map +1 -1
  14. package/build/plugin/unplugin.d.mts.map +1 -1
  15. package/build/plugin/unplugin.mjs +3 -2
  16. package/build/plugin/unplugin.mjs.map +1 -1
  17. package/build/react/server/ServerLingoProvider.d.cts +2 -2
  18. package/build/react/shared/LocaleSwitcher.d.cts +2 -2
  19. package/build/translation-server/translation-server.cjs +7 -17
  20. package/build/translation-server/translation-server.mjs +7 -17
  21. package/build/translation-server/translation-server.mjs.map +1 -1
  22. package/build/translators/cache-factory.mjs.map +1 -1
  23. package/build/translators/lingo/model-factory.cjs +5 -10
  24. package/build/translators/lingo/model-factory.mjs +5 -10
  25. package/build/translators/lingo/model-factory.mjs.map +1 -1
  26. package/build/translators/lingo/provider-details.cjs +69 -0
  27. package/build/translators/lingo/provider-details.mjs +69 -0
  28. package/build/translators/lingo/provider-details.mjs.map +1 -0
  29. package/build/translators/lingo/{service.cjs → translator.cjs} +11 -13
  30. package/build/translators/lingo/{service.mjs → translator.mjs} +12 -14
  31. package/build/translators/lingo/translator.mjs.map +1 -0
  32. package/build/translators/local-cache.mjs +8 -8
  33. package/build/translators/local-cache.mjs.map +1 -1
  34. package/build/translators/memory-cache.cjs +47 -0
  35. package/build/translators/memory-cache.mjs +47 -0
  36. package/build/translators/memory-cache.mjs.map +1 -0
  37. package/build/translators/pluralization/service.cjs +19 -44
  38. package/build/translators/pluralization/service.mjs +19 -44
  39. package/build/translators/pluralization/service.mjs.map +1 -1
  40. package/build/translators/pseudotranslator/index.cjs +2 -10
  41. package/build/translators/pseudotranslator/index.mjs +2 -10
  42. package/build/translators/pseudotranslator/index.mjs.map +1 -1
  43. package/build/translators/translation-service.cjs +55 -57
  44. package/build/translators/translation-service.mjs +55 -57
  45. package/build/translators/translation-service.mjs.map +1 -1
  46. package/package.json +7 -7
  47. package/build/translators/lingo/service.mjs.map +0 -1
  48. package/build/translators/translator-factory.cjs +0 -49
  49. package/build/translators/translator-factory.mjs +0 -50
  50. package/build/translators/translator-factory.mjs.map +0 -1
package/README.md CHANGED
@@ -13,7 +13,10 @@ automatically transforms React components to inject translation calls.
13
13
  - **Opt-in or automatic** - Configure whether to require `'use i18n'` directive or transform all files
14
14
  - **Multi-bundler support** - Works with Vite, Webpack and Next.js (both Webpack and Turbopack builds)
15
15
  - **Translation server** - On-demand translation generation during development
16
- - **AI-powered translations** - Support for multiple LLM providers and Lingo.dev Engine
16
+ - **AI-powered translations** - Support for multiple LLM providers (OpenAI, Anthropic, Google Gemini, Groq, Mistral, OpenRouter, Ollama) and Lingo.dev Engine
17
+ - **Manual overrides** - Override AI translations for specific locales using `data-lingo-override` attribute
18
+ - **Custom locale resolvers** - Provide your own locale detection and persistence logic
19
+ - **Automatic pluralization** - Detects and converts messages to ICU MessageFormat
17
20
 
18
21
  ## Getting started
19
22
 
@@ -58,7 +61,7 @@ Install the package - `pnpm install @lingo.dev/compiler`
58
61
  }
59
62
  ```
60
63
 
61
- See `demo/vite-react-spa` for the working example
64
+ See `demo/new-compiler-vite-react-spa` for the working example
62
65
 
63
66
  ### Next.js
64
67
 
@@ -99,6 +102,274 @@ See `demo/vite-react-spa` for the working example
99
102
  }
100
103
  ```
101
104
 
105
+ See `demo/new-compiler-next16` for the working example
106
+
107
+ ## Configuration Options
108
+
109
+ ### Core Configuration
110
+
111
+ | Option | Type | Default | Description |
112
+ |--------|------|---------|-------------|
113
+ | `sourceRoot` | `string` | `"src"` | Root directory of the source code |
114
+ | `lingoDir` | `string` | `"lingo"` | Directory for lingo files (`.lingo/`) |
115
+ | `sourceLocale` | `LocaleCode` | **(required)** | Source locale (e.g., `"en"`, `"en-US"`) |
116
+ | `targetLocales` | `LocaleCode[]` | **(required)** | Target locales to translate to |
117
+ | `useDirective` | `boolean` | `false` | Whether to require `'use i18n'` directive |
118
+ | `models` | `string \| Record<string, string>` | `"lingo.dev"` | Model configuration (see below) |
119
+ | `prompt` | `string` | `undefined` | Custom translation prompt |
120
+ | `buildMode` | `"translate" \| "cache-only"` | `"translate"` | Build mode (see below) |
121
+
122
+ ### Development Configuration
123
+
124
+ Configure development-specific behavior via the `dev` option:
125
+
126
+ ```ts
127
+ {
128
+ dev: {
129
+ // Use pseudotranslator (fake translations) instead of real AI
130
+ usePseudotranslator: boolean; // default: false
131
+
132
+ // Starting port for translation server
133
+ translationServerStartPort: number; // default: 60000
134
+
135
+ // Custom translation server URL (advanced)
136
+ translationServerUrl?: string;
137
+ }
138
+ }
139
+ ```
140
+
141
+ ### Locale Persistence
142
+
143
+ Configure how locale changes are persisted:
144
+
145
+ ```ts
146
+ {
147
+ localePersistence: {
148
+ type: "cookie",
149
+ config: {
150
+ name: string; // default: "locale"
151
+ maxAge: number; // default: 31536000 (1 year)
152
+ }
153
+ }
154
+ }
155
+ ```
156
+
157
+ ### Pluralization
158
+
159
+ Configure automatic pluralization detection:
160
+
161
+ ```ts
162
+ {
163
+ pluralization: {
164
+ enabled: boolean; // default: true
165
+ model: string; // default: "groq:llama-3.1-8b-instant"
166
+ }
167
+ }
168
+ ```
169
+
170
+ ## Using LLMs for Translation
171
+
172
+ ### Lingo.dev Engine (Recommended)
173
+
174
+ The simplest way to get started is using Lingo.dev Engine:
175
+
176
+ ```ts
177
+ {
178
+ models: "lingo.dev"
179
+ }
180
+ ```
181
+
182
+ Set your API key in `.env`:
183
+
184
+ ```bash
185
+ LINGODOTDEV_API_KEY=your_api_key_here
186
+ ```
187
+
188
+ Get your API key at [lingo.dev](https://lingo.dev)
189
+
190
+ ### Direct LLM Providers
191
+
192
+ You can use any supported LLM provider directly. Configure using locale-pair mapping:
193
+
194
+ ```ts
195
+ {
196
+ models: {
197
+ // Specific locale pair
198
+ "en:es": "google:gemini-2.0-flash",
199
+
200
+ // Wildcard patterns
201
+ "*:de": "groq:llama3-8b-8192", // All translations to German
202
+ "en:*": "openai:gpt-4o", // All translations from English
203
+ "*:*": "anthropic:claude-3-5-sonnet" // Fallback for all other pairs
204
+ }
205
+ }
206
+ ```
207
+
208
+ **Supported Providers:**
209
+
210
+ | Provider | Model String Format | Environment Variable | Get API Key |
211
+ |----------|---------------------|---------------------|-------------|
212
+ | **OpenAI** | `openai:gpt-4o` | `OPENAI_API_KEY` | [platform.openai.com](https://platform.openai.com/account/api-keys) |
213
+ | **Anthropic** | `anthropic:claude-3-5-sonnet` | `ANTHROPIC_API_KEY` | [console.anthropic.com](https://console.anthropic.com/get-api-key) |
214
+ | **Google** | `google:gemini-2.0-flash` | `GOOGLE_API_KEY` | [ai.google.dev](https://ai.google.dev/) |
215
+ | **Groq** | `groq:llama3-8b-8192` | `GROQ_API_KEY` | [groq.com](https://groq.com) |
216
+ | **Mistral** | `mistral:mistral-large` | `MISTRAL_API_KEY` | [console.mistral.ai](https://console.mistral.ai) |
217
+ | **OpenRouter** | `openrouter:anthropic/claude-3.5-sonnet` | `OPENROUTER_API_KEY` | [openrouter.ai](https://openrouter.ai) |
218
+ | **Ollama** | `ollama:llama2` | *(none required)* | [ollama.com/download](https://ollama.com/download) |
219
+
220
+ **Example with multiple providers:**
221
+
222
+ ```ts
223
+ {
224
+ sourceLocale: "en",
225
+ targetLocales: ["es", "de", "fr", "ja"],
226
+ models: {
227
+ "en:es": "groq:llama3-8b-8192", // Fast & cheap for Spanish
228
+ "en:de": "google:gemini-2.0-flash", // Good for German
229
+ "*:*": "openai:gpt-4o" // High quality for everything else
230
+ }
231
+ }
232
+ ```
233
+
234
+ ### Custom Translation Prompts
235
+
236
+ Customize the translation prompt using placeholders:
237
+
238
+ ```ts
239
+ {
240
+ models: "lingo.dev",
241
+ prompt: "Translate from {SOURCE_LOCALE} to {TARGET_LOCALE}. Use a formal tone and preserve all technical terms."
242
+ }
243
+ ```
244
+
245
+ ## Manual Translation Overrides
246
+
247
+ Override AI-generated translations for specific text using the `data-lingo-override` attribute:
248
+
249
+ ```tsx
250
+ export function Welcome() {
251
+ return (
252
+ <div>
253
+ {/* Override translations for brand name */}
254
+ <h1 data-lingo-override={{ de: "Lingo.dev", fr: "Lingo.dev", es: "Lingo.dev" }}>
255
+ Lingo.dev
256
+ </h1>
257
+
258
+ {/* Override only specific locales */}
259
+ <p data-lingo-override={{ de: "Professionelle Übersetzung" }}>
260
+ Professional translation
261
+ </p>
262
+
263
+ {/* Works with rich text and interpolations */}
264
+ <p data-lingo-override={{
265
+ de: "Willkommen <strong0>{name}</strong0>",
266
+ fr: "Bienvenue <strong0>{name}</strong0>"
267
+ }}>
268
+ Welcome <strong>{name}</strong>
269
+ </p>
270
+ </div>
271
+ );
272
+ }
273
+ ```
274
+
275
+ The `data-lingo-override` attribute:
276
+ - Accepts an object with locale codes as keys
277
+ - Supports partial overrides (only specify locales you want to override)
278
+ - Is automatically removed from the final output
279
+ - Works with locale region codes (e.g., `en-US`, `en-GB`)
280
+ - Supports rich text with component placeholders (e.g., `<strong0>`)
281
+
282
+ **Use cases:**
283
+ - Brand names that shouldn't be translated
284
+ - Technical terms requiring specific translations
285
+ - Legal text requiring certified translations
286
+ - Marketing copy that needs human review
287
+
288
+ ## Build Modes
289
+
290
+ Control how translations are handled at build time:
291
+
292
+ ### `translate` (default)
293
+
294
+ Generate missing translations at build time using configured LLM:
295
+
296
+ ```ts
297
+ {
298
+ buildMode: "translate"
299
+ }
300
+ ```
301
+
302
+ - Generates translations for any missing entries
303
+ - Fails build if translation fails
304
+ - Best for: Development and CI pipelines with API keys
305
+
306
+ ### `cache-only`
307
+
308
+ Only use existing cached translations:
309
+
310
+ ```ts
311
+ {
312
+ buildMode: "cache-only"
313
+ }
314
+ ```
315
+
316
+ - No API calls made during build
317
+ - Fails build if translations are missing
318
+ - Best for: Production builds without API keys
319
+ - Requires translations to be pre-generated (during dev or in CI)
320
+
321
+ **Environment Variable Override:**
322
+
323
+ ```bash
324
+ LINGO_BUILD_MODE=cache-only npm run build
325
+ ```
326
+
327
+ **Recommended Workflow:**
328
+
329
+ 1. **Development**: Use `translate` mode with `usePseudotranslator: true`
330
+ 2. **CI**: Generate real translations with `buildMode: "translate"` and real API keys
331
+ 3. **Production Build**: Use `buildMode: "cache-only"` (no API keys needed)
332
+
333
+ ## Custom Locale Resolvers
334
+
335
+ Customize how locales are detected and persisted by providing custom resolver files:
336
+
337
+ ### Custom Server Locale Resolver
338
+
339
+ Create `.lingo/locale-resolver.server.ts` (Next.js):
340
+
341
+ ```ts
342
+ // Custom server-side locale detection
343
+ export async function getServerLocale(): Promise<string> {
344
+ // Your custom logic - e.g., from database, headers, etc.
345
+ const { headers } = await import('next/headers');
346
+ const headersList = await headers();
347
+ const acceptLanguage = headersList.get('accept-language');
348
+
349
+ // Parse and return locale
350
+ return acceptLanguage?.split(',')[0]?.split('-')[0] || 'en';
351
+ }
352
+ ```
353
+
354
+ ### Custom Client Locale Resolver
355
+
356
+ Create `.lingo/locale-resolver.client.ts`:
357
+
358
+ ```ts
359
+ // Custom client-side locale detection and persistence
360
+ export function getClientLocale(): string {
361
+ // Your custom logic - e.g., from localStorage, URL params, etc.
362
+ return localStorage.getItem('user-locale') || 'en';
363
+ }
364
+
365
+ export function persistLocale(locale: string): void {
366
+ localStorage.setItem('user-locale', locale);
367
+ // Optionally update URL, etc.
368
+ }
369
+ ```
370
+
371
+ If these files don't exist, the compiler uses the default cookie-based implementation.
372
+
102
373
  ## Development
103
374
 
104
375
  `pnpm install` from project root
@@ -1,9 +1,9 @@
1
1
  import { logger } from "../utils/logger.mjs";
2
2
  import { DEFAULT_TIMEOUTS, withTimeout } from "../utils/timeout.mjs";
3
3
  import { getLingoDir } from "../utils/path-helpers.mjs";
4
- import fsPromises from "fs/promises";
4
+ import fs from "fs/promises";
5
5
  import path from "path";
6
- import fs from "fs";
6
+ import fs$1 from "fs";
7
7
  import lockfile from "proper-lockfile";
8
8
 
9
9
  //#region src/metadata/manager.ts
@@ -22,7 +22,7 @@ function loadMetadata(path$1) {
22
22
  function cleanupExistingMetadata(metadataFilePath) {
23
23
  logger.debug(`Attempting to cleanup metadata file: ${metadataFilePath}`);
24
24
  try {
25
- fs.unlinkSync(metadataFilePath);
25
+ fs$1.unlinkSync(metadataFilePath);
26
26
  logger.info(`🧹 Cleaned up build metadata file: ${metadataFilePath}`);
27
27
  } catch (error) {
28
28
  if (error.code === "ENOENT") logger.debug(`Metadata file already deleted or doesn't exist: ${metadataFilePath}`);
@@ -50,7 +50,7 @@ var MetadataManager = class {
50
50
  */
51
51
  async loadMetadata() {
52
52
  try {
53
- const content = await withTimeout(fsPromises.readFile(this.filePath, "utf-8"), DEFAULT_TIMEOUTS.METADATA, "Load metadata");
53
+ const content = await withTimeout(fs.readFile(this.filePath, "utf-8"), DEFAULT_TIMEOUTS.METADATA, "Load metadata");
54
54
  return JSON.parse(content);
55
55
  } catch (error) {
56
56
  if (error.code === "ENOENT") return createEmptyMetadata();
@@ -62,7 +62,7 @@ var MetadataManager = class {
62
62
  * Times out after 15 seconds to prevent indefinite hangs
63
63
  */
64
64
  async saveMetadata(metadata) {
65
- await withTimeout(fsPromises.mkdir(path.dirname(this.filePath), { recursive: true }), DEFAULT_TIMEOUTS.FILE_IO, "Create metadata directory");
65
+ await withTimeout(fs.mkdir(path.dirname(this.filePath), { recursive: true }), DEFAULT_TIMEOUTS.FILE_IO, "Create metadata directory");
66
66
  metadata.stats = {
67
67
  totalEntries: Object.keys(metadata.entries).length,
68
68
  lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
@@ -71,17 +71,17 @@ var MetadataManager = class {
71
71
  const base = path.basename(this.filePath);
72
72
  const tmpPath = path.join(dir, `.${base}.tmp-${process.pid}-${Date.now()}`);
73
73
  const json = JSON.stringify(metadata, null, 2);
74
- await withTimeout(fsPromises.writeFile(tmpPath, json, "utf-8"), DEFAULT_TIMEOUTS.METADATA, "Save metadata (tmp write)");
74
+ await withTimeout(fs.writeFile(tmpPath, json, "utf-8"), DEFAULT_TIMEOUTS.METADATA, "Save metadata (tmp write)");
75
75
  try {
76
- await withTimeout(fsPromises.rename(tmpPath, this.filePath), DEFAULT_TIMEOUTS.METADATA, "Save metadata (atomic rename)");
76
+ await withTimeout(fs.rename(tmpPath, this.filePath), DEFAULT_TIMEOUTS.METADATA, "Save metadata (atomic rename)");
77
77
  } catch (error) {
78
78
  if (error && typeof error === "object" && "code" in error && error.code === "EPERM") {
79
- await withTimeout(fsPromises.writeFile(this.filePath, json, "utf-8"), DEFAULT_TIMEOUTS.METADATA, "Save metadata (EPERM fallback direct write)");
79
+ await withTimeout(fs.writeFile(this.filePath, json, "utf-8"), DEFAULT_TIMEOUTS.METADATA, "Save metadata (EPERM fallback direct write)");
80
80
  return;
81
81
  }
82
82
  throw error;
83
83
  } finally {
84
- await fsPromises.unlink(tmpPath).catch(() => {});
84
+ await fs.unlink(tmpPath).catch(() => {});
85
85
  }
86
86
  }
87
87
  /**
@@ -93,11 +93,11 @@ var MetadataManager = class {
93
93
  */
94
94
  async saveMetadataWithEntries(entries) {
95
95
  const lockDir = path.dirname(this.filePath);
96
- await fsPromises.mkdir(lockDir, { recursive: true });
96
+ await fs.mkdir(lockDir, { recursive: true });
97
97
  try {
98
- await fsPromises.access(this.filePath);
98
+ await fs.access(this.filePath);
99
99
  } catch {
100
- await fsPromises.writeFile(this.filePath, JSON.stringify(createEmptyMetadata(), null, 2), "utf-8");
100
+ await fs.writeFile(this.filePath, JSON.stringify(createEmptyMetadata(), null, 2), "utf-8");
101
101
  }
102
102
  const release = await lockfile.lock(this.filePath, {
103
103
  retries: {
@@ -1 +1 @@
1
- {"version":3,"file":"manager.mjs","names":["path","error: any","filePath: string"],"sources":["../../src/metadata/manager.ts"],"sourcesContent":["import fsPromises from \"fs/promises\";\nimport fs from \"fs\";\nimport path from \"path\";\nimport lockfile from \"proper-lockfile\";\nimport type { MetadataSchema, PathConfig, TranslationEntry } from \"../types\";\nimport { DEFAULT_TIMEOUTS, withTimeout } from \"../utils/timeout\";\nimport { getLingoDir } from \"../utils/path-helpers\";\nimport { logger } from \"../utils/logger\";\n\nexport function createEmptyMetadata(): MetadataSchema {\n return {\n entries: {},\n stats: {\n totalEntries: 0,\n lastUpdated: new Date().toISOString(),\n },\n };\n}\n\nexport function loadMetadata(path: string) {\n return new MetadataManager(path).loadMetadata();\n}\n\nexport function cleanupExistingMetadata(metadataFilePath: string) {\n // General cleanup. Delete metadata and stop the server if any was started.\n logger.debug(`Attempting to cleanup metadata file: ${metadataFilePath}`);\n\n try {\n fs.unlinkSync(metadataFilePath);\n logger.info(`🧹 Cleaned up build metadata file: ${metadataFilePath}`);\n } catch (error: any) {\n // Ignore if file doesn't exist\n if (error.code === \"ENOENT\") {\n logger.debug(\n `Metadata file already deleted or doesn't exist: ${metadataFilePath}`,\n );\n } else {\n logger.warn(`Failed to cleanup metadata file: ${error.message}`);\n }\n }\n}\n\n/**\n * Get the absolute path to the metadata file\n *\n * @param config - Config with sourceRoot, lingoDir, and environment\n * @returns Absolute path to metadata file\n */\nexport function getMetadataPath(config: PathConfig): string {\n const filename =\n // Similar to next keeping dev build separate, let's keep the build metadata clean of any dev mode additions\n config.environment === \"development\"\n ? \"metadata-dev.json\"\n : \"metadata-build.json\";\n return path.join(getLingoDir(config), filename);\n}\n\nexport class MetadataManager {\n constructor(private readonly filePath: string) {}\n\n /**\n * Load metadata from disk\n * Creates empty metadata if file doesn't exist\n * Times out after 15 seconds to prevent indefinite hangs\n */\n async loadMetadata(): Promise<MetadataSchema> {\n try {\n const content = await withTimeout(\n fsPromises.readFile(this.filePath, \"utf-8\"),\n DEFAULT_TIMEOUTS.METADATA,\n \"Load metadata\",\n );\n return JSON.parse(content) as MetadataSchema;\n } catch (error: any) {\n if (error.code === \"ENOENT\") {\n // File doesn't exist, create new metadata\n return createEmptyMetadata();\n }\n throw error;\n }\n }\n\n /**\n * Save metadata to disk\n * Times out after 15 seconds to prevent indefinite hangs\n */\n private async saveMetadata(metadata: MetadataSchema): Promise<void> {\n await withTimeout(\n fsPromises.mkdir(path.dirname(this.filePath), { recursive: true }),\n DEFAULT_TIMEOUTS.FILE_IO,\n \"Create metadata directory\",\n );\n\n metadata.stats = {\n totalEntries: Object.keys(metadata.entries).length,\n lastUpdated: new Date().toISOString(),\n };\n\n // Per LLM writing to a file is not an atomic operation while rename is, so nobody should get partial content.\n // Sounds reasonable.\n const dir = path.dirname(this.filePath);\n const base = path.basename(this.filePath);\n\n // Keep temp file in the same directory to maximize chance that rename is atomic\n const tmpPath = path.join(dir, `.${base}.tmp-${process.pid}-${Date.now()}`);\n\n const json = JSON.stringify(metadata, null, 2);\n\n await withTimeout(\n fsPromises.writeFile(tmpPath, json, \"utf-8\"),\n DEFAULT_TIMEOUTS.METADATA,\n \"Save metadata (tmp write)\",\n );\n\n try {\n // TODO (AleksandrSl 14/12/2025): LLM says that we may want to remove older file first for windows, but it seems lo work fine as is.\n await withTimeout(\n fsPromises.rename(tmpPath, this.filePath),\n DEFAULT_TIMEOUTS.METADATA,\n \"Save metadata (atomic rename)\",\n );\n } catch (error) {\n // On Windows, rename() can fail with EPERM if something briefly holds the file.\n // As a fallback, try writing directly to the destination (not atomic).\n if (\n error &&\n typeof error === \"object\" &&\n \"code\" in error &&\n error.code === \"EPERM\"\n ) {\n await withTimeout(\n fsPromises.writeFile(this.filePath, json, \"utf-8\"),\n DEFAULT_TIMEOUTS.METADATA,\n \"Save metadata (EPERM fallback direct write)\",\n );\n return;\n }\n throw error;\n } finally {\n // Best-effort cleanup if rename failed for some reason\n await fsPromises.unlink(tmpPath).catch(() => {});\n }\n }\n\n /**\n * Thread-safe save operation that atomically updates metadata with new entries\n * Uses file locking to prevent concurrent write corruption\n *\n * @param entries - Translation entries to add/update\n * @returns The updated metadata schema\n */\n async saveMetadataWithEntries(\n entries: TranslationEntry[],\n ): Promise<MetadataSchema> {\n const lockDir = path.dirname(this.filePath);\n\n await fsPromises.mkdir(lockDir, { recursive: true });\n\n try {\n await fsPromises.access(this.filePath);\n } catch {\n await fsPromises.writeFile(\n this.filePath,\n JSON.stringify(createEmptyMetadata(), null, 2),\n \"utf-8\",\n );\n }\n\n const release = await lockfile.lock(this.filePath, {\n retries: {\n retries: 10,\n minTimeout: 50,\n maxTimeout: 1000,\n },\n stale: 2000,\n });\n\n try {\n // Re-load metadata inside lock to get latest state\n const currentMetadata = await this.loadMetadata();\n for (const entry of entries) {\n currentMetadata.entries[entry.hash] = entry;\n }\n await this.saveMetadata(currentMetadata);\n return currentMetadata;\n } finally {\n await release();\n }\n }\n}\n"],"mappings":";;;;;;;;;AASA,SAAgB,sBAAsC;AACpD,QAAO;EACL,SAAS,EAAE;EACX,OAAO;GACL,cAAc;GACd,8BAAa,IAAI,MAAM,EAAC,aAAa;GACtC;EACF;;AAGH,SAAgB,aAAa,QAAc;AACzC,QAAO,IAAI,gBAAgBA,OAAK,CAAC,cAAc;;AAGjD,SAAgB,wBAAwB,kBAA0B;AAEhE,QAAO,MAAM,wCAAwC,mBAAmB;AAExE,KAAI;AACF,KAAG,WAAW,iBAAiB;AAC/B,SAAO,KAAK,sCAAsC,mBAAmB;UAC9DC,OAAY;AAEnB,MAAI,MAAM,SAAS,SACjB,QAAO,MACL,mDAAmD,mBACpD;MAED,QAAO,KAAK,oCAAoC,MAAM,UAAU;;;;;;;;;AAWtE,SAAgB,gBAAgB,QAA4B;CAC1D,MAAM,WAEJ,OAAO,gBAAgB,gBACnB,sBACA;AACN,QAAO,KAAK,KAAK,YAAY,OAAO,EAAE,SAAS;;AAGjD,IAAa,kBAAb,MAA6B;CAC3B,YAAY,AAAiBC,UAAkB;EAAlB;;;;;;;CAO7B,MAAM,eAAwC;AAC5C,MAAI;GACF,MAAM,UAAU,MAAM,YACpB,WAAW,SAAS,KAAK,UAAU,QAAQ,EAC3C,iBAAiB,UACjB,gBACD;AACD,UAAO,KAAK,MAAM,QAAQ;WACnBD,OAAY;AACnB,OAAI,MAAM,SAAS,SAEjB,QAAO,qBAAqB;AAE9B,SAAM;;;;;;;CAQV,MAAc,aAAa,UAAyC;AAClE,QAAM,YACJ,WAAW,MAAM,KAAK,QAAQ,KAAK,SAAS,EAAE,EAAE,WAAW,MAAM,CAAC,EAClE,iBAAiB,SACjB,4BACD;AAED,WAAS,QAAQ;GACf,cAAc,OAAO,KAAK,SAAS,QAAQ,CAAC;GAC5C,8BAAa,IAAI,MAAM,EAAC,aAAa;GACtC;EAID,MAAM,MAAM,KAAK,QAAQ,KAAK,SAAS;EACvC,MAAM,OAAO,KAAK,SAAS,KAAK,SAAS;EAGzC,MAAM,UAAU,KAAK,KAAK,KAAK,IAAI,KAAK,OAAO,QAAQ,IAAI,GAAG,KAAK,KAAK,GAAG;EAE3E,MAAM,OAAO,KAAK,UAAU,UAAU,MAAM,EAAE;AAE9C,QAAM,YACJ,WAAW,UAAU,SAAS,MAAM,QAAQ,EAC5C,iBAAiB,UACjB,4BACD;AAED,MAAI;AAEF,SAAM,YACJ,WAAW,OAAO,SAAS,KAAK,SAAS,EACzC,iBAAiB,UACjB,gCACD;WACM,OAAO;AAGd,OACE,SACA,OAAO,UAAU,YACjB,UAAU,SACV,MAAM,SAAS,SACf;AACA,UAAM,YACJ,WAAW,UAAU,KAAK,UAAU,MAAM,QAAQ,EAClD,iBAAiB,UACjB,8CACD;AACD;;AAEF,SAAM;YACE;AAER,SAAM,WAAW,OAAO,QAAQ,CAAC,YAAY,GAAG;;;;;;;;;;CAWpD,MAAM,wBACJ,SACyB;EACzB,MAAM,UAAU,KAAK,QAAQ,KAAK,SAAS;AAE3C,QAAM,WAAW,MAAM,SAAS,EAAE,WAAW,MAAM,CAAC;AAEpD,MAAI;AACF,SAAM,WAAW,OAAO,KAAK,SAAS;UAChC;AACN,SAAM,WAAW,UACf,KAAK,UACL,KAAK,UAAU,qBAAqB,EAAE,MAAM,EAAE,EAC9C,QACD;;EAGH,MAAM,UAAU,MAAM,SAAS,KAAK,KAAK,UAAU;GACjD,SAAS;IACP,SAAS;IACT,YAAY;IACZ,YAAY;IACb;GACD,OAAO;GACR,CAAC;AAEF,MAAI;GAEF,MAAM,kBAAkB,MAAM,KAAK,cAAc;AACjD,QAAK,MAAM,SAAS,QAClB,iBAAgB,QAAQ,MAAM,QAAQ;AAExC,SAAM,KAAK,aAAa,gBAAgB;AACxC,UAAO;YACC;AACR,SAAM,SAAS"}
1
+ {"version":3,"file":"manager.mjs","names":["path","error: any","filePath: string","fsPromises"],"sources":["../../src/metadata/manager.ts"],"sourcesContent":["import fsPromises from \"fs/promises\";\nimport fs from \"fs\";\nimport path from \"path\";\nimport lockfile from \"proper-lockfile\";\nimport type { MetadataSchema, PathConfig, TranslationEntry } from \"../types\";\nimport { DEFAULT_TIMEOUTS, withTimeout } from \"../utils/timeout\";\nimport { getLingoDir } from \"../utils/path-helpers\";\nimport { logger } from \"../utils/logger\";\n\nexport function createEmptyMetadata(): MetadataSchema {\n return {\n entries: {},\n stats: {\n totalEntries: 0,\n lastUpdated: new Date().toISOString(),\n },\n };\n}\n\nexport function loadMetadata(path: string) {\n return new MetadataManager(path).loadMetadata();\n}\n\nexport function cleanupExistingMetadata(metadataFilePath: string) {\n // General cleanup. Delete metadata and stop the server if any was started.\n logger.debug(`Attempting to cleanup metadata file: ${metadataFilePath}`);\n\n try {\n fs.unlinkSync(metadataFilePath);\n logger.info(`🧹 Cleaned up build metadata file: ${metadataFilePath}`);\n } catch (error: any) {\n // Ignore if file doesn't exist\n if (error.code === \"ENOENT\") {\n logger.debug(\n `Metadata file already deleted or doesn't exist: ${metadataFilePath}`,\n );\n } else {\n logger.warn(`Failed to cleanup metadata file: ${error.message}`);\n }\n }\n}\n\n/**\n * Get the absolute path to the metadata file\n *\n * @param config - Config with sourceRoot, lingoDir, and environment\n * @returns Absolute path to metadata file\n */\nexport function getMetadataPath(config: PathConfig): string {\n const filename =\n // Similar to next keeping dev build separate, let's keep the build metadata clean of any dev mode additions\n config.environment === \"development\"\n ? \"metadata-dev.json\"\n : \"metadata-build.json\";\n return path.join(getLingoDir(config), filename);\n}\n\nexport class MetadataManager {\n constructor(private readonly filePath: string) {}\n\n /**\n * Load metadata from disk\n * Creates empty metadata if file doesn't exist\n * Times out after 15 seconds to prevent indefinite hangs\n */\n async loadMetadata(): Promise<MetadataSchema> {\n try {\n const content = await withTimeout(\n fsPromises.readFile(this.filePath, \"utf-8\"),\n DEFAULT_TIMEOUTS.METADATA,\n \"Load metadata\",\n );\n return JSON.parse(content) as MetadataSchema;\n } catch (error: any) {\n if (error.code === \"ENOENT\") {\n // File doesn't exist, create new metadata\n return createEmptyMetadata();\n }\n throw error;\n }\n }\n\n /**\n * Save metadata to disk\n * Times out after 15 seconds to prevent indefinite hangs\n */\n private async saveMetadata(metadata: MetadataSchema): Promise<void> {\n await withTimeout(\n fsPromises.mkdir(path.dirname(this.filePath), { recursive: true }),\n DEFAULT_TIMEOUTS.FILE_IO,\n \"Create metadata directory\",\n );\n\n metadata.stats = {\n totalEntries: Object.keys(metadata.entries).length,\n lastUpdated: new Date().toISOString(),\n };\n\n // Per LLM writing to a file is not an atomic operation while rename is, so nobody should get partial content.\n // Sounds reasonable.\n const dir = path.dirname(this.filePath);\n const base = path.basename(this.filePath);\n\n // Keep temp file in the same directory to maximize chance that rename is atomic\n const tmpPath = path.join(dir, `.${base}.tmp-${process.pid}-${Date.now()}`);\n\n const json = JSON.stringify(metadata, null, 2);\n\n await withTimeout(\n fsPromises.writeFile(tmpPath, json, \"utf-8\"),\n DEFAULT_TIMEOUTS.METADATA,\n \"Save metadata (tmp write)\",\n );\n\n try {\n // TODO (AleksandrSl 14/12/2025): LLM says that we may want to remove older file first for windows, but it seems lo work fine as is.\n await withTimeout(\n fsPromises.rename(tmpPath, this.filePath),\n DEFAULT_TIMEOUTS.METADATA,\n \"Save metadata (atomic rename)\",\n );\n } catch (error) {\n // On Windows, rename() can fail with EPERM if something briefly holds the file.\n // As a fallback, try writing directly to the destination (not atomic).\n if (\n error &&\n typeof error === \"object\" &&\n \"code\" in error &&\n error.code === \"EPERM\"\n ) {\n await withTimeout(\n fsPromises.writeFile(this.filePath, json, \"utf-8\"),\n DEFAULT_TIMEOUTS.METADATA,\n \"Save metadata (EPERM fallback direct write)\",\n );\n return;\n }\n throw error;\n } finally {\n // Best-effort cleanup if rename failed for some reason\n await fsPromises.unlink(tmpPath).catch(() => {});\n }\n }\n\n /**\n * Thread-safe save operation that atomically updates metadata with new entries\n * Uses file locking to prevent concurrent write corruption\n *\n * @param entries - Translation entries to add/update\n * @returns The updated metadata schema\n */\n async saveMetadataWithEntries(\n entries: TranslationEntry[],\n ): Promise<MetadataSchema> {\n const lockDir = path.dirname(this.filePath);\n\n await fsPromises.mkdir(lockDir, { recursive: true });\n\n try {\n await fsPromises.access(this.filePath);\n } catch {\n await fsPromises.writeFile(\n this.filePath,\n JSON.stringify(createEmptyMetadata(), null, 2),\n \"utf-8\",\n );\n }\n\n const release = await lockfile.lock(this.filePath, {\n retries: {\n retries: 10,\n minTimeout: 50,\n maxTimeout: 1000,\n },\n stale: 2000,\n });\n\n try {\n // Re-load metadata inside lock to get latest state\n const currentMetadata = await this.loadMetadata();\n for (const entry of entries) {\n currentMetadata.entries[entry.hash] = entry;\n }\n await this.saveMetadata(currentMetadata);\n return currentMetadata;\n } finally {\n await release();\n }\n }\n}\n"],"mappings":";;;;;;;;;AASA,SAAgB,sBAAsC;AACpD,QAAO;EACL,SAAS,EAAE;EACX,OAAO;GACL,cAAc;GACd,8BAAa,IAAI,MAAM,EAAC,aAAa;GACtC;EACF;;AAGH,SAAgB,aAAa,QAAc;AACzC,QAAO,IAAI,gBAAgBA,OAAK,CAAC,cAAc;;AAGjD,SAAgB,wBAAwB,kBAA0B;AAEhE,QAAO,MAAM,wCAAwC,mBAAmB;AAExE,KAAI;AACF,OAAG,WAAW,iBAAiB;AAC/B,SAAO,KAAK,sCAAsC,mBAAmB;UAC9DC,OAAY;AAEnB,MAAI,MAAM,SAAS,SACjB,QAAO,MACL,mDAAmD,mBACpD;MAED,QAAO,KAAK,oCAAoC,MAAM,UAAU;;;;;;;;;AAWtE,SAAgB,gBAAgB,QAA4B;CAC1D,MAAM,WAEJ,OAAO,gBAAgB,gBACnB,sBACA;AACN,QAAO,KAAK,KAAK,YAAY,OAAO,EAAE,SAAS;;AAGjD,IAAa,kBAAb,MAA6B;CAC3B,YAAY,AAAiBC,UAAkB;EAAlB;;;;;;;CAO7B,MAAM,eAAwC;AAC5C,MAAI;GACF,MAAM,UAAU,MAAM,YACpBC,GAAW,SAAS,KAAK,UAAU,QAAQ,EAC3C,iBAAiB,UACjB,gBACD;AACD,UAAO,KAAK,MAAM,QAAQ;WACnBF,OAAY;AACnB,OAAI,MAAM,SAAS,SAEjB,QAAO,qBAAqB;AAE9B,SAAM;;;;;;;CAQV,MAAc,aAAa,UAAyC;AAClE,QAAM,YACJE,GAAW,MAAM,KAAK,QAAQ,KAAK,SAAS,EAAE,EAAE,WAAW,MAAM,CAAC,EAClE,iBAAiB,SACjB,4BACD;AAED,WAAS,QAAQ;GACf,cAAc,OAAO,KAAK,SAAS,QAAQ,CAAC;GAC5C,8BAAa,IAAI,MAAM,EAAC,aAAa;GACtC;EAID,MAAM,MAAM,KAAK,QAAQ,KAAK,SAAS;EACvC,MAAM,OAAO,KAAK,SAAS,KAAK,SAAS;EAGzC,MAAM,UAAU,KAAK,KAAK,KAAK,IAAI,KAAK,OAAO,QAAQ,IAAI,GAAG,KAAK,KAAK,GAAG;EAE3E,MAAM,OAAO,KAAK,UAAU,UAAU,MAAM,EAAE;AAE9C,QAAM,YACJA,GAAW,UAAU,SAAS,MAAM,QAAQ,EAC5C,iBAAiB,UACjB,4BACD;AAED,MAAI;AAEF,SAAM,YACJA,GAAW,OAAO,SAAS,KAAK,SAAS,EACzC,iBAAiB,UACjB,gCACD;WACM,OAAO;AAGd,OACE,SACA,OAAO,UAAU,YACjB,UAAU,SACV,MAAM,SAAS,SACf;AACA,UAAM,YACJA,GAAW,UAAU,KAAK,UAAU,MAAM,QAAQ,EAClD,iBAAiB,UACjB,8CACD;AACD;;AAEF,SAAM;YACE;AAER,SAAMA,GAAW,OAAO,QAAQ,CAAC,YAAY,GAAG;;;;;;;;;;CAWpD,MAAM,wBACJ,SACyB;EACzB,MAAM,UAAU,KAAK,QAAQ,KAAK,SAAS;AAE3C,QAAMA,GAAW,MAAM,SAAS,EAAE,WAAW,MAAM,CAAC;AAEpD,MAAI;AACF,SAAMA,GAAW,OAAO,KAAK,SAAS;UAChC;AACN,SAAMA,GAAW,UACf,KAAK,UACL,KAAK,UAAU,qBAAqB,EAAE,MAAM,EAAE,EAC9C,QACD;;EAGH,MAAM,UAAU,MAAM,SAAS,KAAK,KAAK,UAAU;GACjD,SAAS;IACP,SAAS;IACT,YAAY;IACZ,YAAY;IACb;GACD,OAAO;GACR,CAAC;AAEF,MAAI;GAEF,MAAM,kBAAkB,MAAM,KAAK,cAAc;AACjD,QAAK,MAAM,SAAS,QAClB,iBAAgB,QAAQ,MAAM,QAAQ;AAExC,SAAM,KAAK,aAAa,gBAAgB;AACxC,UAAO;YACC;AACR,SAAM,SAAS"}
@@ -2,6 +2,7 @@ const require_rolldown_runtime = require('../_virtual/rolldown_runtime.cjs');
2
2
  const require_logger = require('../utils/logger.cjs');
3
3
  const require_api = require('../translators/api.cjs');
4
4
  const require_cache_factory = require('../translators/cache-factory.cjs');
5
+ const require_translation_service = require('../translators/translation-service.cjs');
5
6
  const require_manager = require('../metadata/manager.cjs');
6
7
  const require_translation_server = require('../translation-server/translation-server.cjs');
7
8
  let fs_promises = require("fs/promises");
@@ -27,7 +28,6 @@ async function processBuildTranslations(options) {
27
28
  const { config, publicOutputPath, metadataFilePath } = options;
28
29
  const buildMode = process.env.LINGO_BUILD_MODE || config.buildMode;
29
30
  require_logger.logger.info(`🌍 Build mode: ${buildMode}`);
30
- if (metadataFilePath) require_logger.logger.info(`📋 Using build metadata file: ${metadataFilePath}`);
31
31
  const metadata = await require_manager.loadMetadata(metadataFilePath);
32
32
  if (!metadata || Object.keys(metadata.entries).length === 0) {
33
33
  require_logger.logger.info("No translations to process (metadata is empty)");
@@ -53,7 +53,7 @@ async function processBuildTranslations(options) {
53
53
  let translationServer;
54
54
  try {
55
55
  translationServer = await require_translation_server.startTranslationServer({
56
- startPort: config.dev.translationServerStartPort,
56
+ translationService: new require_translation_service.TranslationService(config, require_logger.logger),
57
57
  onError: (err) => {
58
58
  require_logger.logger.error("Translation server error:", err);
59
59
  },
@@ -93,7 +93,7 @@ async function processBuildTranslations(options) {
93
93
  stats
94
94
  };
95
95
  } catch (error) {
96
- require_logger.logger.error("❌ Translation generation failed:", error);
96
+ require_logger.logger.error("❌ Translation generation failed:\n", error instanceof Error ? error.message : error);
97
97
  process.exit(1);
98
98
  } finally {
99
99
  if (translationServer) {
@@ -1,9 +1,10 @@
1
1
  import { logger } from "../utils/logger.mjs";
2
2
  import { dictionaryFrom } from "../translators/api.mjs";
3
3
  import { createCache } from "../translators/cache-factory.mjs";
4
+ import { TranslationService } from "../translators/translation-service.mjs";
4
5
  import { loadMetadata } from "../metadata/manager.mjs";
5
6
  import { startTranslationServer } from "../translation-server/translation-server.mjs";
6
- import fsPromises from "fs/promises";
7
+ import fs from "fs/promises";
7
8
  import path from "path";
8
9
 
9
10
  //#region src/plugin/build-translator.ts
@@ -24,7 +25,6 @@ async function processBuildTranslations(options) {
24
25
  const { config, publicOutputPath, metadataFilePath } = options;
25
26
  const buildMode = process.env.LINGO_BUILD_MODE || config.buildMode;
26
27
  logger.info(`🌍 Build mode: ${buildMode}`);
27
- if (metadataFilePath) logger.info(`📋 Using build metadata file: ${metadataFilePath}`);
28
28
  const metadata = await loadMetadata(metadataFilePath);
29
29
  if (!metadata || Object.keys(metadata.entries).length === 0) {
30
30
  logger.info("No translations to process (metadata is empty)");
@@ -50,7 +50,7 @@ async function processBuildTranslations(options) {
50
50
  let translationServer;
51
51
  try {
52
52
  translationServer = await startTranslationServer({
53
- startPort: config.dev.translationServerStartPort,
53
+ translationService: new TranslationService(config, logger),
54
54
  onError: (err) => {
55
55
  logger.error("Translation server error:", err);
56
56
  },
@@ -90,7 +90,7 @@ async function processBuildTranslations(options) {
90
90
  stats
91
91
  };
92
92
  } catch (error) {
93
- logger.error("❌ Translation generation failed:", error);
93
+ logger.error("❌ Translation generation failed:\n", error instanceof Error ? error.message : error);
94
94
  process.exit(1);
95
95
  } finally {
96
96
  if (translationServer) {
@@ -147,7 +147,7 @@ function buildCacheStats(config, metadata) {
147
147
  }
148
148
  async function copyStaticFiles(config, publicOutputPath, metadata, cache) {
149
149
  logger.info(`📦 Generating static translation files in ${publicOutputPath}`);
150
- await fsPromises.mkdir(publicOutputPath, { recursive: true });
150
+ await fs.mkdir(publicOutputPath, { recursive: true });
151
151
  const usedHashes = new Set(Object.keys(metadata.entries));
152
152
  logger.info(`📊 Filtering translations to ${usedHashes.size} used hash(es)`);
153
153
  const allLocales = config.pluralization?.enabled !== false ? [config.sourceLocale, ...config.targetLocales] : config.targetLocales;
@@ -156,7 +156,7 @@ async function copyStaticFiles(config, publicOutputPath, metadata, cache) {
156
156
  try {
157
157
  const entries = await cache.get(locale, Array.from(usedHashes));
158
158
  const outputData = dictionaryFrom(locale, entries);
159
- await fsPromises.writeFile(publicFilePath, JSON.stringify(outputData, null, 2), "utf-8");
159
+ await fs.writeFile(publicFilePath, JSON.stringify(outputData, null, 2), "utf-8");
160
160
  logger.info(`✓ Generated ${locale}.json (${Object.keys(entries).length} translations)`);
161
161
  } catch (error) {
162
162
  logger.error(`❌ Failed to generate ${locale}.json:`, error);
@@ -1 +1 @@
1
- {"version":3,"file":"build-translator.mjs","names":["translationServer: TranslationServer | undefined","stats: BuildTranslationResult[\"stats\"]","errors: Array<{ locale: LocaleCode; error: string }>","missingLocales: string[]","incompleteLocales: Array<{\n locale: LocaleCode;\n missing: number;\n total: number;\n }>","fs"],"sources":["../../src/plugin/build-translator.ts"],"sourcesContent":["/**\n * Build-time translation processor\n *\n * Handles translation generation and validation at build time\n * Supports two modes:\n * - \"translate\": Generate all translations, fail if translation fails\n * - \"cache-only\": Validate cache completeness, fail if incomplete\n */\n// TODO (AleksandrSl 08/12/2025): Add ICU validation for messages? The problem is that we don't know which will be rendered as a simple text\nimport fs from \"fs/promises\";\nimport path from \"path\";\nimport type { LingoConfig, MetadataSchema } from \"../types\";\nimport { logger } from \"../utils/logger\";\nimport {\n startTranslationServer,\n type TranslationServer,\n} from \"../translation-server\";\nimport { loadMetadata } from \"../metadata/manager\";\nimport { createCache, type TranslationCache } from \"../translators\";\nimport { dictionaryFrom } from \"../translators/api\";\nimport type { LocaleCode } from \"lingo.dev/spec\";\n\nexport interface BuildTranslationOptions {\n config: LingoConfig;\n publicOutputPath: string;\n metadataFilePath: string;\n}\n\nexport interface BuildTranslationResult {\n /**\n * Whether the build succeeded\n */\n success: boolean;\n\n /**\n * Error message if build failed\n */\n error?: string;\n\n /**\n * Translation statistics per locale\n */\n stats: Record<\n string,\n {\n total: number;\n translated: number;\n failed: number;\n }\n >;\n}\n\n/**\n * Process translations at build time\n *\n * @throws Error if validation or translation fails (causes build to fail)\n */\nexport async function processBuildTranslations(\n options: BuildTranslationOptions,\n): Promise<BuildTranslationResult> {\n const { config, publicOutputPath, metadataFilePath } = options;\n\n // Determine build mode (env var > options > config)\n const buildMode =\n (process.env.LINGO_BUILD_MODE as \"translate\" | \"cache-only\") ||\n config.buildMode;\n\n logger.info(`🌍 Build mode: ${buildMode}`);\n\n if (metadataFilePath) {\n logger.info(`📋 Using build metadata file: ${metadataFilePath}`);\n }\n\n const metadata = await loadMetadata(metadataFilePath);\n\n if (!metadata || Object.keys(metadata.entries).length === 0) {\n logger.info(\"No translations to process (metadata is empty)\");\n return {\n success: true,\n stats: {},\n };\n }\n\n const totalEntries = Object.keys(metadata.entries).length;\n logger.info(`📊 Found ${totalEntries} translatable entries`);\n\n const cache = createCache(config);\n\n // Handle cache-only mode\n if (buildMode === \"cache-only\") {\n logger.info(\"🔍 Validating translation cache...\");\n await validateCache(config, metadata, cache);\n logger.info(\"✅ Cache validation passed\");\n\n if (publicOutputPath) {\n await copyStaticFiles(config, publicOutputPath, metadata, cache);\n }\n\n return {\n success: true,\n stats: buildCacheStats(config, metadata),\n };\n }\n\n // Handle translate mode\n logger.info(\"🔄 Generating translations...\");\n let translationServer: TranslationServer | undefined;\n\n try {\n translationServer = await startTranslationServer({\n startPort: config.dev.translationServerStartPort,\n onError: (err) => {\n logger.error(\"Translation server error:\", err);\n },\n config,\n });\n\n // When pluralization is enabled, we need to generate the source locale file too\n // because pluralization modifies the sourceText\n const needsSourceLocale = config.pluralization?.enabled !== false;\n const allLocales = needsSourceLocale\n ? [config.sourceLocale, ...config.targetLocales]\n : config.targetLocales;\n\n logger.info(\n `Processing translations for ${allLocales.length} locale(s)${needsSourceLocale ? \" (including source locale for pluralization)\" : \"\"}...`,\n );\n\n const stats: BuildTranslationResult[\"stats\"] = {};\n const errors: Array<{ locale: LocaleCode; error: string }> = [];\n\n // Translate all locales in parallel\n const localePromises = allLocales.map(async (locale) => {\n logger.info(`Translating to ${locale}...`);\n\n const result = await translationServer!.translateAll(locale);\n\n stats[locale] = {\n total: totalEntries,\n translated: Object.keys(result.translations).length,\n failed: result.errors.length,\n };\n\n if (result.errors.length > 0) {\n logger.warn(\n `⚠️ ${result.errors.length} translation error(s) for ${locale}`,\n );\n errors.push({\n locale,\n error: `${result.errors.length} translation(s) failed`,\n });\n } else {\n logger.info(`✅ ${locale} completed successfully`);\n }\n });\n\n await Promise.all(localePromises);\n\n // Fail build if any translations failed in translate mode\n if (errors.length > 0) {\n const errorMsg = formatTranslationErrors(errors);\n logger.error(errorMsg);\n process.exit(1);\n }\n\n // Copy cache to public directory if requested\n if (publicOutputPath) {\n await copyStaticFiles(config, publicOutputPath, metadata, cache);\n }\n\n logger.info(\"✅ Translation generation completed successfully\");\n\n return {\n success: true,\n stats,\n };\n } catch (error) {\n logger.error(\"❌ Translation generation failed:\", error);\n process.exit(1);\n } finally {\n if (translationServer) {\n await translationServer.stop();\n logger.info(\"✅ Translation server stopped\");\n }\n }\n}\n\n/**\n * Validate that all required translations exist in cache\n * @throws Error if cache is incomplete or missing\n */\nasync function validateCache(\n config: LingoConfig,\n metadata: MetadataSchema,\n cache: TranslationCache,\n): Promise<void> {\n const allHashes = Object.keys(metadata.entries);\n const missingLocales: string[] = [];\n const incompleteLocales: Array<{\n locale: LocaleCode;\n missing: number;\n total: number;\n }> = [];\n\n // Include source locale if pluralization is enabled\n const needsSourceLocale = config.pluralization?.enabled !== false;\n const allLocales = needsSourceLocale\n ? [config.sourceLocale, ...config.targetLocales]\n : config.targetLocales;\n\n for (const locale of allLocales) {\n try {\n const entries = await cache.get(locale);\n\n if (Object.keys(entries).length === 0) {\n missingLocales.push(locale);\n logger.debug(`Cache file not found or empty for ${locale}`);\n continue;\n }\n\n const missingHashes = allHashes.filter((hash) => !entries[hash]);\n\n if (missingHashes.length > 0) {\n incompleteLocales.push({\n locale,\n missing: missingHashes.length,\n total: allHashes.length,\n });\n\n // Log first few missing hashes for debugging\n logger.debug(\n `Missing hashes in ${locale}: ${missingHashes.slice(0, 5).join(\", \")}${\n missingHashes.length > 5 ? \"...\" : \"\"\n }`,\n );\n }\n } catch (error) {\n missingLocales.push(locale);\n logger.debug(`Failed to read cache for ${locale}:`, error);\n }\n }\n\n if (missingLocales.length > 0 || incompleteLocales.length > 0) {\n const errorMsg = formatCacheValidationError(\n missingLocales,\n incompleteLocales,\n );\n logger.error(errorMsg);\n process.exit(1);\n }\n}\n\nfunction buildCacheStats(\n config: LingoConfig,\n metadata: MetadataSchema,\n): BuildTranslationResult[\"stats\"] {\n const totalEntries = Object.keys(metadata.entries).length;\n const stats: BuildTranslationResult[\"stats\"] = {};\n\n // Include source locale if pluralization is enabled\n const needsSourceLocale = config.pluralization?.enabled !== false;\n const allLocales = needsSourceLocale\n ? [config.sourceLocale, ...config.targetLocales]\n : config.targetLocales;\n\n for (const locale of allLocales) {\n stats[locale] = {\n total: totalEntries,\n translated: totalEntries, // Assumed complete if validation passed\n failed: 0,\n };\n }\n\n return stats;\n}\n\nasync function copyStaticFiles(\n config: LingoConfig,\n publicOutputPath: string,\n metadata: MetadataSchema,\n cache: TranslationCache,\n): Promise<void> {\n logger.info(`📦 Generating static translation files in ${publicOutputPath}`);\n\n await fs.mkdir(publicOutputPath, { recursive: true });\n\n const usedHashes = new Set(Object.keys(metadata.entries));\n logger.info(`📊 Filtering translations to ${usedHashes.size} used hash(es)`);\n\n // Include source locale if pluralization is enabled\n const needsSourceLocale = config.pluralization?.enabled !== false;\n const allLocales = needsSourceLocale\n ? [config.sourceLocale, ...config.targetLocales]\n : config.targetLocales;\n\n for (const locale of allLocales) {\n const publicFilePath = path.join(publicOutputPath, `${locale}.json`);\n\n try {\n const entries = await cache.get(locale, Array.from(usedHashes));\n const outputData = dictionaryFrom(locale, entries);\n\n await fs.writeFile(\n publicFilePath,\n JSON.stringify(outputData, null, 2),\n \"utf-8\",\n );\n\n logger.info(\n `✓ Generated ${locale}.json (${Object.keys(entries).length} translations)`,\n );\n } catch (error) {\n logger.error(`❌ Failed to generate ${locale}.json:`, error);\n process.exit(1);\n }\n }\n}\n\nfunction formatCacheValidationError(\n missingLocales: string[],\n incompleteLocales: Array<{\n locale: LocaleCode;\n missing: number;\n total: number;\n }>,\n): string {\n let msg = \"❌ Cache validation failed in cache-only mode:\\n\\n\";\n\n if (missingLocales.length > 0) {\n msg += ` 📁 Missing cache files:\\n`;\n msg += missingLocales.map((locale) => ` - ${locale}.json`).join(\"\\n\");\n msg += \"\\n\\n\";\n }\n\n if (incompleteLocales.length > 0) {\n msg += ` 📊 Incomplete cache:\\n`;\n msg += incompleteLocales\n .map(\n (item) =>\n ` - ${item.locale}: ${item.missing}/${item.total} translations missing`,\n )\n .join(\"\\n\");\n msg += \"\\n\\n\";\n }\n\n msg += ` 💡 To fix:\\n`;\n msg += ` 1. Set LINGO_BUILD_MODE=translate to generate translations\\n`;\n msg += ` 2. Commit the generated .lingo/cache/*.json files\\n`;\n msg += ` 3. Ensure translation API keys are available if generating translations`;\n\n return msg;\n}\n\nfunction formatTranslationErrors(\n errors: Array<{ locale: LocaleCode; error: string }>,\n): string {\n let msg = \"❌ Translation generation failed:\\n\\n\";\n\n msg += errors.map((err) => ` - ${err.locale}: ${err.error}`).join(\"\\n\");\n\n msg += \"\\n\\n\";\n msg += ` 💡 Translation errors must be resolved in \"translate\" mode.\\n`;\n msg += ` Check translation server logs for details.`;\n\n return msg;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"build-translator.mjs","names":["translationServer: TranslationServer | undefined","stats: BuildTranslationResult[\"stats\"]","errors: Array<{ locale: LocaleCode; error: string }>","missingLocales: string[]","incompleteLocales: Array<{\n locale: LocaleCode;\n missing: number;\n total: number;\n }>"],"sources":["../../src/plugin/build-translator.ts"],"sourcesContent":["/**\n * Build-time translation processor\n *\n * Handles translation generation and validation at build time\n * Supports two modes:\n * - \"translate\": Generate all translations, fail if translation fails\n * - \"cache-only\": Validate cache completeness, fail if incomplete\n */\n// TODO (AleksandrSl 08/12/2025): Add ICU validation for messages? The problem is that we don't know which will be rendered as a simple text\nimport fs from \"fs/promises\";\nimport path from \"path\";\nimport type { LingoConfig, MetadataSchema } from \"../types\";\nimport { logger } from \"../utils/logger\";\nimport { startTranslationServer, type TranslationServer, } from \"../translation-server\";\nimport { loadMetadata } from \"../metadata/manager\";\nimport { createCache, type TranslationCache, TranslationService, } from \"../translators\";\nimport { dictionaryFrom } from \"../translators/api\";\nimport type { LocaleCode } from \"lingo.dev/spec\";\n\nexport interface BuildTranslationOptions {\n config: LingoConfig;\n publicOutputPath: string;\n metadataFilePath: string;\n}\n\nexport interface BuildTranslationResult {\n /**\n * Whether the build succeeded\n */\n success: boolean;\n\n /**\n * Error message if build failed\n */\n error?: string;\n\n /**\n * Translation statistics per locale\n */\n stats: Record<\n string,\n {\n total: number;\n translated: number;\n failed: number;\n }\n >;\n}\n\n/**\n * Process translations at build time\n *\n * @throws Error if validation or translation fails (causes build to fail)\n */\nexport async function processBuildTranslations(\n options: BuildTranslationOptions,\n): Promise<BuildTranslationResult> {\n const { config, publicOutputPath, metadataFilePath } = options;\n\n // Determine build mode (env var > options > config)\n const buildMode =\n (process.env.LINGO_BUILD_MODE as \"translate\" | \"cache-only\") ||\n config.buildMode;\n\n logger.info(`🌍 Build mode: ${buildMode}`);\n\n const metadata = await loadMetadata(metadataFilePath);\n\n if (!metadata || Object.keys(metadata.entries).length === 0) {\n logger.info(\"No translations to process (metadata is empty)\");\n return {\n success: true,\n stats: {},\n };\n }\n\n const totalEntries = Object.keys(metadata.entries).length;\n logger.info(`📊 Found ${totalEntries} translatable entries`);\n\n const cache = createCache(config);\n\n // Handle cache-only mode\n if (buildMode === \"cache-only\") {\n logger.info(\"🔍 Validating translation cache...\");\n await validateCache(config, metadata, cache);\n logger.info(\"✅ Cache validation passed\");\n\n if (publicOutputPath) {\n await copyStaticFiles(config, publicOutputPath, metadata, cache);\n }\n\n return {\n success: true,\n stats: buildCacheStats(config, metadata),\n };\n }\n\n // Handle translate mode\n logger.info(\"🔄 Generating translations...\");\n let translationServer: TranslationServer | undefined;\n\n try {\n translationServer = await startTranslationServer({\n translationService: new TranslationService(config, logger),\n onError: (err) => {\n logger.error(\"Translation server error:\", err);\n },\n config,\n });\n\n // When pluralization is enabled, we need to generate the source locale file too\n // because pluralization modifies the sourceText\n const needsSourceLocale = config.pluralization?.enabled !== false;\n const allLocales = needsSourceLocale\n ? [config.sourceLocale, ...config.targetLocales]\n : config.targetLocales;\n\n logger.info(\n `Processing translations for ${allLocales.length} locale(s)${needsSourceLocale ? \" (including source locale for pluralization)\" : \"\"}...`,\n );\n\n const stats: BuildTranslationResult[\"stats\"] = {};\n const errors: Array<{ locale: LocaleCode; error: string }> = [];\n\n // Translate all locales in parallel\n const localePromises = allLocales.map(async (locale) => {\n logger.info(`Translating to ${locale}...`);\n\n const result = await translationServer!.translateAll(locale);\n\n stats[locale] = {\n total: totalEntries,\n translated: Object.keys(result.translations).length,\n failed: result.errors.length,\n };\n\n if (result.errors.length > 0) {\n logger.warn(\n `⚠️ ${result.errors.length} translation error(s) for ${locale}`,\n );\n errors.push({\n locale,\n error: `${result.errors.length} translation(s) failed`,\n });\n } else {\n logger.info(`✅ ${locale} completed successfully`);\n }\n });\n\n await Promise.all(localePromises);\n\n // Fail build if any translations failed in translate mode\n if (errors.length > 0) {\n const errorMsg = formatTranslationErrors(errors);\n logger.error(errorMsg);\n process.exit(1);\n }\n\n // Copy cache to public directory if requested\n if (publicOutputPath) {\n await copyStaticFiles(config, publicOutputPath, metadata, cache);\n }\n\n logger.info(\"✅ Translation generation completed successfully\");\n\n return {\n success: true,\n stats,\n };\n } catch (error) {\n logger.error(\n \"❌ Translation generation failed:\\n\",\n error instanceof Error ? error.message : error,\n );\n process.exit(1);\n } finally {\n if (translationServer) {\n await translationServer.stop();\n logger.info(\"✅ Translation server stopped\");\n }\n }\n}\n\n/**\n * Validate that all required translations exist in cache\n * @throws Error if cache is incomplete or missing\n */\nasync function validateCache(\n config: LingoConfig,\n metadata: MetadataSchema,\n cache: TranslationCache,\n): Promise<void> {\n const allHashes = Object.keys(metadata.entries);\n const missingLocales: string[] = [];\n const incompleteLocales: Array<{\n locale: LocaleCode;\n missing: number;\n total: number;\n }> = [];\n\n // Include source locale if pluralization is enabled\n const needsSourceLocale = config.pluralization?.enabled !== false;\n const allLocales = needsSourceLocale\n ? [config.sourceLocale, ...config.targetLocales]\n : config.targetLocales;\n\n for (const locale of allLocales) {\n try {\n const entries = await cache.get(locale);\n\n if (Object.keys(entries).length === 0) {\n missingLocales.push(locale);\n logger.debug(`Cache file not found or empty for ${locale}`);\n continue;\n }\n\n const missingHashes = allHashes.filter((hash) => !entries[hash]);\n\n if (missingHashes.length > 0) {\n incompleteLocales.push({\n locale,\n missing: missingHashes.length,\n total: allHashes.length,\n });\n\n // Log first few missing hashes for debugging\n logger.debug(\n `Missing hashes in ${locale}: ${missingHashes.slice(0, 5).join(\", \")}${\n missingHashes.length > 5 ? \"...\" : \"\"\n }`,\n );\n }\n } catch (error) {\n missingLocales.push(locale);\n logger.debug(`Failed to read cache for ${locale}:`, error);\n }\n }\n\n if (missingLocales.length > 0 || incompleteLocales.length > 0) {\n const errorMsg = formatCacheValidationError(\n missingLocales,\n incompleteLocales,\n );\n logger.error(errorMsg);\n process.exit(1);\n }\n}\n\nfunction buildCacheStats(\n config: LingoConfig,\n metadata: MetadataSchema,\n): BuildTranslationResult[\"stats\"] {\n const totalEntries = Object.keys(metadata.entries).length;\n const stats: BuildTranslationResult[\"stats\"] = {};\n\n // Include source locale if pluralization is enabled\n const needsSourceLocale = config.pluralization?.enabled !== false;\n const allLocales = needsSourceLocale\n ? [config.sourceLocale, ...config.targetLocales]\n : config.targetLocales;\n\n for (const locale of allLocales) {\n stats[locale] = {\n total: totalEntries,\n translated: totalEntries, // Assumed complete if validation passed\n failed: 0,\n };\n }\n\n return stats;\n}\n\nasync function copyStaticFiles(\n config: LingoConfig,\n publicOutputPath: string,\n metadata: MetadataSchema,\n cache: TranslationCache,\n): Promise<void> {\n logger.info(`📦 Generating static translation files in ${publicOutputPath}`);\n\n await fs.mkdir(publicOutputPath, { recursive: true });\n\n const usedHashes = new Set(Object.keys(metadata.entries));\n logger.info(`📊 Filtering translations to ${usedHashes.size} used hash(es)`);\n\n // Include source locale if pluralization is enabled\n const needsSourceLocale = config.pluralization?.enabled !== false;\n const allLocales = needsSourceLocale\n ? [config.sourceLocale, ...config.targetLocales]\n : config.targetLocales;\n\n for (const locale of allLocales) {\n const publicFilePath = path.join(publicOutputPath, `${locale}.json`);\n\n try {\n const entries = await cache.get(locale, Array.from(usedHashes));\n const outputData = dictionaryFrom(locale, entries);\n\n await fs.writeFile(\n publicFilePath,\n JSON.stringify(outputData, null, 2),\n \"utf-8\",\n );\n\n logger.info(\n `✓ Generated ${locale}.json (${Object.keys(entries).length} translations)`,\n );\n } catch (error) {\n logger.error(`❌ Failed to generate ${locale}.json:`, error);\n process.exit(1);\n }\n }\n}\n\nfunction formatCacheValidationError(\n missingLocales: string[],\n incompleteLocales: Array<{\n locale: LocaleCode;\n missing: number;\n total: number;\n }>,\n): string {\n let msg = \"❌ Cache validation failed in cache-only mode:\\n\\n\";\n\n if (missingLocales.length > 0) {\n msg += ` 📁 Missing cache files:\\n`;\n msg += missingLocales.map((locale) => ` - ${locale}.json`).join(\"\\n\");\n msg += \"\\n\\n\";\n }\n\n if (incompleteLocales.length > 0) {\n msg += ` 📊 Incomplete cache:\\n`;\n msg += incompleteLocales\n .map(\n (item) =>\n ` - ${item.locale}: ${item.missing}/${item.total} translations missing`,\n )\n .join(\"\\n\");\n msg += \"\\n\\n\";\n }\n\n msg += ` 💡 To fix:\\n`;\n msg += ` 1. Set LINGO_BUILD_MODE=translate to generate translations\\n`;\n msg += ` 2. Commit the generated .lingo/cache/*.json files\\n`;\n msg += ` 3. Ensure translation API keys are available if generating translations`;\n\n return msg;\n}\n\nfunction formatTranslationErrors(\n errors: Array<{ locale: LocaleCode; error: string }>,\n): string {\n let msg = \"❌ Translation generation failed:\\n\\n\";\n\n msg += errors.map((err) => ` - ${err.locale}: ${err.error}`).join(\"\\n\");\n\n msg += \"\\n\\n\";\n msg += ` 💡 Translation errors must be resolved in \"translate\" mode.\\n`;\n msg += ` Check translation server logs for details.`;\n\n return msg;\n}\n"],"mappings":"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"}
@@ -1,5 +1,6 @@
1
1
  const require_logger = require('../utils/logger.cjs');
2
2
  const require_config_factory = require('../utils/config-factory.cjs');
3
+ const require_translation_service = require('../translators/translation-service.cjs');
3
4
  const require_manager = require('../metadata/manager.cjs');
4
5
  const require_translation_server = require('../translation-server/translation-server.cjs');
5
6
  const require_build_translator = require('./build-translator.cjs');
@@ -134,7 +135,7 @@ async function withLingo(nextConfig = {}, lingoOptions) {
134
135
  require_logger.logger.debug(`Initializing Lingo.dev compiler. Is dev mode: ${isDev}. Is main runner: ${isMainRunner()}`);
135
136
  if (isDev && !process.env.LINGO_TRANSLATION_SERVER_URL) {
136
137
  const translationServer = await require_translation_server.startOrGetTranslationServer({
137
- startPort: lingoConfig.dev.translationServerStartPort,
138
+ translationService: new require_translation_service.TranslationService(lingoConfig, require_logger.logger),
138
139
  onError: (err) => {
139
140
  require_logger.logger.error("Translation server error:", err);
140
141
  },
@@ -182,7 +183,6 @@ async function withLingo(nextConfig = {}, lingoOptions) {
182
183
  projectDir
183
184
  });
184
185
  require_logger.logger.info("Running post-build translation generation...");
185
- require_logger.logger.info(`Build mode: Using metadata file: ${metadataFilePath}`);
186
186
  try {
187
187
  await require_build_translator.processBuildTranslations({
188
188
  config: lingoConfig,
@@ -190,7 +190,7 @@ async function withLingo(nextConfig = {}, lingoOptions) {
190
190
  metadataFilePath
191
191
  });
192
192
  } catch (error) {
193
- require_logger.logger.error("Translation generation failed:", error);
193
+ require_logger.logger.error("Translation generation failed:", error instanceof Error ? error.message : error);
194
194
  throw error;
195
195
  }
196
196
  };
@@ -1 +1 @@
1
- {"version":3,"file":"next.d.cts","names":[],"sources":["../../src/plugin/next.ts"],"sourcesContent":[],"mappings":";;;;KAeY,sBAAA,GAAyB;iBAoLf,SAAA,aACR,sCACE,yBACb,QAAQ"}
1
+ {"version":3,"file":"next.d.cts","names":[],"sources":["../../src/plugin/next.ts"],"sourcesContent":[],"mappings":";;;;KAgBY,sBAAA,GAAyB;iBAoLf,SAAA,aACR,sCACE,yBACb,QAAQ"}
@@ -1 +1 @@
1
- {"version":3,"file":"next.d.mts","names":[],"sources":["../../src/plugin/next.ts"],"sourcesContent":[],"mappings":";;;;KAeY,sBAAA,GAAyB;iBAoLf,SAAA,aACR,sCACE,yBACb,QAAQ"}
1
+ {"version":3,"file":"next.d.mts","names":[],"sources":["../../src/plugin/next.ts"],"sourcesContent":[],"mappings":";;;;KAgBY,sBAAA,GAAyB;iBAoLf,SAAA,aACR,sCACE,yBACb,QAAQ"}
@@ -1,6 +1,7 @@
1
1
  import { __require } from "../_virtual/rolldown_runtime.mjs";
2
2
  import { logger } from "../utils/logger.mjs";
3
3
  import { createLingoConfig } from "../utils/config-factory.mjs";
4
+ import { TranslationService } from "../translators/translation-service.mjs";
4
5
  import { cleanupExistingMetadata, getMetadataPath } from "../metadata/manager.mjs";
5
6
  import { startOrGetTranslationServer } from "../translation-server/translation-server.mjs";
6
7
  import { processBuildTranslations } from "./build-translator.mjs";
@@ -135,7 +136,7 @@ async function withLingo(nextConfig = {}, lingoOptions) {
135
136
  logger.debug(`Initializing Lingo.dev compiler. Is dev mode: ${isDev}. Is main runner: ${isMainRunner()}`);
136
137
  if (isDev && !process.env.LINGO_TRANSLATION_SERVER_URL) {
137
138
  const translationServer = await startOrGetTranslationServer({
138
- startPort: lingoConfig.dev.translationServerStartPort,
139
+ translationService: new TranslationService(lingoConfig, logger),
139
140
  onError: (err) => {
140
141
  logger.error("Translation server error:", err);
141
142
  },
@@ -183,7 +184,6 @@ async function withLingo(nextConfig = {}, lingoOptions) {
183
184
  projectDir
184
185
  });
185
186
  logger.info("Running post-build translation generation...");
186
- logger.info(`Build mode: Using metadata file: ${metadataFilePath}`);
187
187
  try {
188
188
  await processBuildTranslations({
189
189
  config: lingoConfig,
@@ -191,7 +191,7 @@ async function withLingo(nextConfig = {}, lingoOptions) {
191
191
  metadataFilePath
192
192
  });
193
193
  } catch (error) {
194
- logger.error("Translation generation failed:", error);
194
+ logger.error("Translation generation failed:", error instanceof Error ? error.message : error);
195
195
  throw error;
196
196
  }
197
197
  };