@linguanahq/compiler 0.1.0 → 0.1.1

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.
package/dist/index.js ADDED
@@ -0,0 +1,760 @@
1
+ // src/index.ts
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
4
+ import generateImport from "@babel/generator";
5
+ import { parse } from "@babel/parser";
6
+ import traverseImport from "@babel/traverse";
7
+ import * as t from "@babel/types";
8
+ import {
9
+ contentHash,
10
+ manifestHash,
11
+ manifestSchema,
12
+ normalizeSource,
13
+ parseMessagePlaceholders,
14
+ SCHEMA_VERSION,
15
+ stableMessageId
16
+ } from "@linguanahq/catalog";
17
+ import { createFilter } from "vite";
18
+ var COMPILER_VERSION = "0.1.1";
19
+ var generate = generateImport.default ?? generateImport;
20
+ var traverse = traverseImport.default ?? traverseImport;
21
+ var EXCLUDED_ELEMENTS = /* @__PURE__ */ new Set(["script", "style", "code", "pre"]);
22
+ var ALLOWED_ATTRIBUTES = /* @__PURE__ */ new Set([
23
+ "title",
24
+ "placeholder",
25
+ "aria-label",
26
+ "alt",
27
+ "label",
28
+ "value"
29
+ ]);
30
+ var MACHINE_ATTRIBUTES = /* @__PURE__ */ new Set([
31
+ "id",
32
+ "class",
33
+ "className",
34
+ "name",
35
+ "role",
36
+ "href",
37
+ "src",
38
+ "action",
39
+ "method",
40
+ "data-testid",
41
+ "data-test-id",
42
+ "data-analytics",
43
+ "data-event"
44
+ ]);
45
+ function jsxName(node) {
46
+ if (t.isJSXIdentifier(node)) return node.name;
47
+ if (t.isJSXMemberExpression(node)) return jsxName(node.property);
48
+ return `${node.namespace.name}:${node.name.name}`;
49
+ }
50
+ function renderedJsxText(value) {
51
+ const lines = value.replace(/\r\n?/g, "\n").split("\n");
52
+ let result = "";
53
+ for (let index = 0; index < lines.length; index += 1) {
54
+ let line = lines[index]?.replace(/\t/g, " ") ?? "";
55
+ if (index !== 0) line = line.replace(/^\s+/, "");
56
+ if (index !== lines.length - 1) line = line.replace(/\s+$/, "");
57
+ if (!line) continue;
58
+ if (result && !result.endsWith(" ")) result += " ";
59
+ result += line;
60
+ }
61
+ return result;
62
+ }
63
+ function location(path, root, filename) {
64
+ const start = path.node.loc?.start;
65
+ return {
66
+ file: relative(root, filename).replaceAll("\\", "/"),
67
+ line: start?.line ?? 1,
68
+ column: start?.column ?? 0
69
+ };
70
+ }
71
+ function getLiteralAttribute(opening, name) {
72
+ for (const attribute of opening.attributes) {
73
+ if (t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name) && attribute.name.name === name && t.isStringLiteral(attribute.value)) {
74
+ return attribute.value.value;
75
+ }
76
+ }
77
+ return void 0;
78
+ }
79
+ function hasAttribute(opening, name) {
80
+ return opening.attributes.some(
81
+ (attribute) => t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name) && attribute.name.name === name
82
+ );
83
+ }
84
+ function attributeValue(opening, name) {
85
+ for (const attribute of opening.attributes) {
86
+ if (!t.isJSXAttribute(attribute) || !t.isJSXIdentifier(attribute.name, { name }))
87
+ continue;
88
+ if (attribute.value === null) return true;
89
+ if (t.isStringLiteral(attribute.value)) return attribute.value.value;
90
+ }
91
+ }
92
+ function isUnsafeStaticCopy(source) {
93
+ const normalized = normalizeSource(source);
94
+ return /^(?:https?:\/\/|mailto:|tel:|www\.)/i.test(normalized) || /^\/?[\w.-]+(?:\/[\w./-]+)+(?:\?\S*)?$/.test(normalized) || /^[a-z0-9]+(?:[_:.][a-z0-9-]+)+$/i.test(normalized);
95
+ }
96
+ function isExcluded(path) {
97
+ return Boolean(
98
+ path.findParent((parent) => {
99
+ if (!parent.isJSXElement()) return false;
100
+ const opening = parent.node.openingElement;
101
+ const tag = jsxName(opening.name);
102
+ return tag === "T" || EXCLUDED_ELEMENTS.has(tag.toLowerCase()) || hasAttribute(opening, "data-no-translate") || hasAttribute(opening, "hidden") || attributeValue(opening, "aria-hidden") === "true" || tag.toLowerCase() === "input" && attributeValue(opening, "type") === "hidden";
103
+ })
104
+ );
105
+ }
106
+ function isExcludedElement(opening) {
107
+ const tag = jsxName(opening.name);
108
+ return EXCLUDED_ELEMENTS.has(tag.toLowerCase()) || hasAttribute(opening, "data-no-translate") || hasAttribute(opening, "hidden") || attributeValue(opening, "aria-hidden") === "true" || tag.toLowerCase() === "input" && attributeValue(opening, "type") === "hidden";
109
+ }
110
+ function hasMixedRichChildren(path) {
111
+ return Boolean(
112
+ path.findParent((parent) => {
113
+ if (!parent.isJSXElement()) return false;
114
+ const children = parent.node.children;
115
+ const hasElement = children.some(
116
+ (child) => t.isJSXElement(child) || t.isJSXExpressionContainer(child) && !t.isJSXEmptyExpression(child.expression)
117
+ );
118
+ const hasText = children.some(
119
+ (child) => t.isJSXText(child) && Boolean(renderedJsxText(child.value))
120
+ );
121
+ return hasElement && hasText;
122
+ })
123
+ );
124
+ }
125
+ function placeholders(source) {
126
+ try {
127
+ return parseMessagePlaceholders(source);
128
+ } catch {
129
+ return [];
130
+ }
131
+ }
132
+ function stringProperty(object, name) {
133
+ for (const property of object.properties) {
134
+ if (!t.isObjectProperty(property)) continue;
135
+ const key = t.isIdentifier(property.key) ? property.key.name : t.isStringLiteral(property.key) ? property.key.value : void 0;
136
+ if (key === name && t.isStringLiteral(property.value))
137
+ return property.value.value;
138
+ }
139
+ return void 0;
140
+ }
141
+ function isTranslationCall(path, runtimePackage = "@linguanahq/react") {
142
+ if (!t.isIdentifier(path.node.callee)) return false;
143
+ const name = path.node.callee.name;
144
+ const binding = path.scope.getBinding(name);
145
+ if (!binding) return false;
146
+ if (binding.path.isImportSpecifier()) {
147
+ const declaration = binding.path.parentPath;
148
+ return declaration.isImportDeclaration() && declaration.node.source.value === runtimePackage && t.isIdentifier(binding.path.node.imported, { name: "t" });
149
+ }
150
+ const declarator = binding.path.isVariableDeclarator() ? binding.path : binding.path.findParent((candidate) => candidate.isVariableDeclarator());
151
+ if (!declarator?.isVariableDeclarator()) return false;
152
+ if (!t.isCallExpression(declarator.node.init) || !t.isIdentifier(declarator.node.init.callee, { name: "useTranslation" }) || !t.isObjectPattern(declarator.node.id))
153
+ return false;
154
+ return declarator.node.id.properties.some(
155
+ (property) => t.isObjectProperty(property) && t.isIdentifier(property.key, { name: "t" }) && t.isIdentifier(property.value, { name })
156
+ );
157
+ }
158
+ function transformSource(code, filename, options) {
159
+ const root = options.root ?? process.cwd();
160
+ const ast = parse(code, {
161
+ sourceType: "module",
162
+ sourceFilename: filename,
163
+ plugins: ["jsx", "typescript"]
164
+ });
165
+ const messages = /* @__PURE__ */ new Map();
166
+ const diagnostics = [];
167
+ let needsRuntime = false;
168
+ const richDiagnosticLocations = /* @__PURE__ */ new Set();
169
+ const addMessage = (message) => {
170
+ const existing = messages.get(message.id);
171
+ if (existing && existing.contentHash !== message.contentHash) {
172
+ diagnostics.push({
173
+ code: "duplicate-id",
174
+ severity: "error",
175
+ message: `Message ID ${message.id} refers to incompatible source content`,
176
+ location: message.locations[0],
177
+ remediation: "Choose a unique explicit ID or make the source messages identical."
178
+ });
179
+ return;
180
+ }
181
+ if (existing) {
182
+ existing.locations.push(...message.locations);
183
+ return;
184
+ }
185
+ messages.set(message.id, message);
186
+ };
187
+ traverse(ast, {
188
+ JSXText(path) {
189
+ const source = renderedJsxText(path.node.value);
190
+ if (!source || isExcluded(path)) return;
191
+ if (isUnsafeStaticCopy(source)) {
192
+ diagnostics.push({
193
+ code: "unsafe-static-copy",
194
+ severity: "info",
195
+ message: "URL-like or machine-readable text was excluded from extraction.",
196
+ location: location(path, root, filename)
197
+ });
198
+ return;
199
+ }
200
+ if (hasMixedRichChildren(path)) {
201
+ const itemLocation = location(path, root, filename);
202
+ const key = `${itemLocation.file}:${itemLocation.line}:${itemLocation.column}`;
203
+ if (!richDiagnosticLocations.has(key)) {
204
+ richDiagnosticLocations.add(key);
205
+ diagnostics.push({
206
+ code: "ambiguous-rich-text",
207
+ severity: "warning",
208
+ message: "Mixed text and JSX elements require an explicit <T> message.",
209
+ location: itemLocation,
210
+ remediation: "Use <T defaultMessage components> or data-no-translate."
211
+ });
212
+ }
213
+ return;
214
+ }
215
+ const parent = path.findParent((candidate) => candidate.isJSXElement());
216
+ if (!parent?.isJSXElement()) return;
217
+ const element = jsxName(parent.node.openingElement.name);
218
+ const context = { kind: "text", element };
219
+ const id = stableMessageId({
220
+ sourceLocale: options.sourceLocale,
221
+ source,
222
+ context
223
+ });
224
+ addMessage({
225
+ id,
226
+ source,
227
+ normalized: normalizeSource(source),
228
+ contentHash: contentHash(source),
229
+ context,
230
+ placeholders: placeholders(source),
231
+ confidence: "high",
232
+ locations: [location(path, root, filename)]
233
+ });
234
+ needsRuntime = true;
235
+ path.replaceWith(
236
+ t.jsxExpressionContainer(
237
+ t.jsxElement(
238
+ t.jsxOpeningElement(
239
+ t.jsxIdentifier("__LinguanaMessage"),
240
+ [
241
+ t.jsxAttribute(t.jsxIdentifier("id"), t.stringLiteral(id)),
242
+ t.jsxAttribute(
243
+ t.jsxIdentifier("defaultMessage"),
244
+ t.stringLiteral(source)
245
+ )
246
+ ],
247
+ true
248
+ ),
249
+ null,
250
+ [],
251
+ true
252
+ )
253
+ )
254
+ );
255
+ },
256
+ JSXExpressionContainer(path) {
257
+ if (isExcluded(path) || t.isJSXEmptyExpression(path.node.expression) || !path.parentPath.isJSXElement())
258
+ return;
259
+ if (t.isConditionalExpression(path.node.expression) || t.isBinaryExpression(path.node.expression) || t.isTemplateLiteral(path.node.expression)) {
260
+ diagnostics.push({
261
+ code: "dynamic-candidate",
262
+ severity: "warning",
263
+ message: "Dynamic JSX copy was not automatically extracted.",
264
+ location: location(path, root, filename),
265
+ remediation: "Use t(), <T>, data-no-translate, or an explicit annotation."
266
+ });
267
+ }
268
+ },
269
+ CallExpression(path) {
270
+ if (!isTranslationCall(path, options.runtimePackage)) return;
271
+ const [sourceNode, , optionsNode] = path.node.arguments;
272
+ if (!t.isStringLiteral(sourceNode)) return;
273
+ const explicitId = t.isObjectExpression(optionsNode) ? stringProperty(optionsNode, "id") : void 0;
274
+ const developer = t.isObjectExpression(optionsNode) ? stringProperty(optionsNode, "context") : void 0;
275
+ const context = { kind: "explicit", developer };
276
+ const id = stableMessageId({
277
+ sourceLocale: options.sourceLocale,
278
+ source: sourceNode.value,
279
+ context,
280
+ explicitId
281
+ });
282
+ addMessage({
283
+ id,
284
+ source: sourceNode.value,
285
+ normalized: normalizeSource(sourceNode.value),
286
+ contentHash: contentHash(sourceNode.value),
287
+ context,
288
+ placeholders: placeholders(sourceNode.value),
289
+ confidence: "explicit",
290
+ locations: [location(path, root, filename)]
291
+ });
292
+ },
293
+ JSXElement: {
294
+ exit(path) {
295
+ if (isExcluded(path) || isExcludedElement(path.node.openingElement))
296
+ return;
297
+ const opening = path.node.openingElement;
298
+ const tag = jsxName(opening.name);
299
+ if (tag === "T") {
300
+ const source = getLiteralAttribute(opening, "defaultMessage");
301
+ if (!source) return;
302
+ const explicitId = getLiteralAttribute(opening, "id");
303
+ const developer = getLiteralAttribute(opening, "context");
304
+ const context = { kind: "explicit", developer };
305
+ const id = stableMessageId({
306
+ sourceLocale: options.sourceLocale,
307
+ source,
308
+ context,
309
+ explicitId
310
+ });
311
+ addMessage({
312
+ id,
313
+ source,
314
+ normalized: normalizeSource(source),
315
+ contentHash: contentHash(source),
316
+ context,
317
+ placeholders: placeholders(source),
318
+ confidence: "explicit",
319
+ locations: [location(path, root, filename)]
320
+ });
321
+ return;
322
+ }
323
+ const descriptors = [];
324
+ const localizedNames = [];
325
+ for (const attribute of opening.attributes) {
326
+ if (!t.isJSXAttribute(attribute) || !t.isJSXIdentifier(attribute.name) || !t.isStringLiteral(attribute.value))
327
+ continue;
328
+ const name = attribute.name.name;
329
+ if (MACHINE_ATTRIBUTES.has(name) || name.startsWith("on") || name === "data-translate")
330
+ continue;
331
+ const optInValue = attributeValue(opening, "data-translate");
332
+ const optedIn = typeof optInValue === "string" && optInValue.split(/[\s,]+/).includes(name);
333
+ if (!ALLOWED_ATTRIBUTES.has(name) && !optedIn) continue;
334
+ if (name === "value" && tag.toLowerCase() !== "button") continue;
335
+ const source = attribute.value.value;
336
+ if (!source || isUnsafeStaticCopy(source)) continue;
337
+ const context = {
338
+ kind: "attribute",
339
+ element: tag,
340
+ attribute: name
341
+ };
342
+ const id = stableMessageId({
343
+ sourceLocale: options.sourceLocale,
344
+ source,
345
+ context
346
+ });
347
+ addMessage({
348
+ id,
349
+ source,
350
+ normalized: normalizeSource(source),
351
+ contentHash: contentHash(source),
352
+ context,
353
+ placeholders: placeholders(source),
354
+ confidence: "high",
355
+ locations: [location(path, root, filename)]
356
+ });
357
+ localizedNames.push(name);
358
+ descriptors.push(
359
+ t.objectExpression([
360
+ t.objectProperty(t.identifier("name"), t.stringLiteral(name)),
361
+ t.objectProperty(t.identifier("id"), t.stringLiteral(id)),
362
+ t.objectProperty(
363
+ t.identifier("defaultMessage"),
364
+ t.stringLiteral(source)
365
+ )
366
+ ])
367
+ );
368
+ }
369
+ if (!descriptors.length) return;
370
+ needsRuntime = true;
371
+ const original = t.cloneNode(path.node, true);
372
+ for (const attribute of original.openingElement.attributes) {
373
+ if (t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name) && localizedNames.includes(attribute.name.name)) {
374
+ attribute.value = t.jsxExpressionContainer(
375
+ t.memberExpression(
376
+ t.identifier("__localized"),
377
+ t.stringLiteral(attribute.name.name),
378
+ true
379
+ )
380
+ );
381
+ }
382
+ }
383
+ const wrapper = t.jsxElement(
384
+ t.jsxOpeningElement(
385
+ t.jsxIdentifier("__LinguanaAttributes"),
386
+ [
387
+ t.jsxAttribute(
388
+ t.jsxIdentifier("descriptors"),
389
+ t.jsxExpressionContainer(t.arrayExpression(descriptors))
390
+ )
391
+ ],
392
+ false
393
+ ),
394
+ t.jsxClosingElement(t.jsxIdentifier("__LinguanaAttributes")),
395
+ [
396
+ t.jsxExpressionContainer(
397
+ t.arrowFunctionExpression(
398
+ [t.identifier("__localized")],
399
+ original
400
+ )
401
+ )
402
+ ]
403
+ );
404
+ path.replaceWith(wrapper);
405
+ path.skip();
406
+ }
407
+ },
408
+ Program: {
409
+ exit(path) {
410
+ if (!needsRuntime) return;
411
+ path.unshiftContainer(
412
+ "body",
413
+ t.importDeclaration(
414
+ [
415
+ t.importSpecifier(
416
+ t.identifier("__LinguanaMessage"),
417
+ t.identifier("LinguanaMessage")
418
+ ),
419
+ t.importSpecifier(
420
+ t.identifier("__LinguanaAttributes"),
421
+ t.identifier("LinguanaAttributes")
422
+ )
423
+ ],
424
+ t.stringLiteral(
425
+ options.compilerRuntime ?? "@linguanahq/react/compiler-runtime"
426
+ )
427
+ )
428
+ );
429
+ }
430
+ }
431
+ });
432
+ const output = generate(
433
+ ast,
434
+ { sourceMaps: true, sourceFileName: filename },
435
+ code
436
+ );
437
+ return {
438
+ code: output.code,
439
+ map: output.map,
440
+ messages: [...messages.values()].sort(
441
+ (left, right) => left.id.localeCompare(right.id)
442
+ ),
443
+ diagnostics
444
+ };
445
+ }
446
+ function shouldFail(strict, diagnostics) {
447
+ if (!strict) return false;
448
+ if (strict === true)
449
+ return diagnostics.some((item) => item.severity !== "info");
450
+ return diagnostics.some((item) => {
451
+ if (item.code === "duplicate-id") return strict.duplicate ?? true;
452
+ if (item.code.startsWith("unsafe")) return strict.unsafe ?? false;
453
+ return strict.ambiguous ?? false;
454
+ });
455
+ }
456
+ async function uploadManifest(options, manifest, hash) {
457
+ if (!options.apiUrl || !options.token) {
458
+ throw new Error("Manifest upload requires apiUrl and token");
459
+ }
460
+ const body = JSON.stringify(manifest);
461
+ const response = await fetch(
462
+ `${options.apiUrl.replace(/\/$/, "")}/api/v1/manifests`,
463
+ {
464
+ method: "POST",
465
+ headers: {
466
+ "content-type": "application/json",
467
+ authorization: `Bearer ${options.token}`,
468
+ "idempotency-key": hash
469
+ },
470
+ body: JSON.stringify({
471
+ schemaVersion: SCHEMA_VERSION,
472
+ contentHash: hash,
473
+ byteLength: new TextEncoder().encode(body).byteLength,
474
+ messageCount: manifest.messages.length,
475
+ sourceRevision: manifest.sourceRevision
476
+ })
477
+ }
478
+ );
479
+ if (!response.ok)
480
+ throw new Error(`Manifest registration failed (${response.status})`);
481
+ const registration = await response.json();
482
+ if (registration.upload) {
483
+ const upload = await fetch(registration.upload.url, {
484
+ method: "PUT",
485
+ headers: registration.upload.headers,
486
+ body
487
+ });
488
+ if (!upload.ok)
489
+ throw new Error(`Manifest artifact upload failed (${upload.status})`);
490
+ const completion = await fetch(
491
+ `${options.apiUrl.replace(/\/$/, "")}/api/v1/manifests/${registration.manifestId}/complete`,
492
+ {
493
+ method: "POST",
494
+ headers: {
495
+ authorization: `Bearer ${options.token}`,
496
+ "idempotency-key": `${hash}:complete`
497
+ }
498
+ }
499
+ );
500
+ if (!completion.ok)
501
+ throw new Error(`Manifest completion failed (${completion.status})`);
502
+ }
503
+ return registration;
504
+ }
505
+ async function apiJson(url, init) {
506
+ const response = await fetch(url, init);
507
+ const body = await response.json().catch(() => ({}));
508
+ if (!response.ok) {
509
+ const details = [
510
+ body.code ?? `HTTP_${response.status}`,
511
+ body.message,
512
+ body.requestId ? `request ${body.requestId}` : void 0
513
+ ].filter(Boolean);
514
+ throw new Error(details.join(": "));
515
+ }
516
+ return body;
517
+ }
518
+ async function translateManifest(options, manifestId, hash, onProgress) {
519
+ const translation = options.translation;
520
+ if (!translation || !options.apiUrl || !options.token) return;
521
+ const endpoint = options.apiUrl.replace(/\/$/, "");
522
+ const buildKey = contentHash({
523
+ manifestHash: hash,
524
+ targetLocales: [...translation.targetLocales].sort(),
525
+ publish: translation.publish
526
+ });
527
+ onProgress(
528
+ `Requesting ${translation.targetLocales.join(", ")} translations for manifest ${hash.slice(0, 12)}.`
529
+ );
530
+ const created = await apiJson(`${endpoint}/api/v1/manifests/${manifestId}/translations`, {
531
+ method: "POST",
532
+ headers: {
533
+ authorization: `Bearer ${options.token}`,
534
+ "content-type": "application/json",
535
+ "idempotency-key": buildKey
536
+ },
537
+ body: JSON.stringify({
538
+ targetLocales: translation.targetLocales,
539
+ maxChargeMicros: translation.maxChargeMicros
540
+ })
541
+ });
542
+ onProgress(
543
+ `Translation jobs accepted (maximum charge ${created.maximumChargeMicros} micros).`
544
+ );
545
+ const deadline = Date.now() + (translation.waitTimeoutMs ?? 3e5);
546
+ const completed = /* @__PURE__ */ new Map();
547
+ while (completed.size < created.jobs.length && Date.now() < deadline) {
548
+ for (const initial of created.jobs) {
549
+ if (completed.has(initial.id)) continue;
550
+ const job = await apiJson(
551
+ `${endpoint}/api/v1/jobs/${initial.id}`,
552
+ {
553
+ method: "GET",
554
+ headers: { authorization: `Bearer ${options.token}` }
555
+ }
556
+ );
557
+ if (job.dispatchStatus === "FAILED" && !["SUCCEEDED", "REVIEW_REQUIRED", "FAILED", "CANCELED"].includes(
558
+ job.status
559
+ )) {
560
+ throw new Error(
561
+ `${job.targetLocale}: ${job.errorCode ?? "INNGEST_DISPATCH_FAILED"}: job ${job.id}. Start or reconnect the Inngest worker.`
562
+ );
563
+ }
564
+ if (job.status === "SUCCEEDED") {
565
+ if (!job.catalogVersionId)
566
+ throw new Error(
567
+ `${job.targetLocale}: CATALOG_VERSION_MISSING: job ${job.id}.`
568
+ );
569
+ completed.set(job.id, job);
570
+ onProgress(`${job.targetLocale} translation completed.`);
571
+ } else if (["REVIEW_REQUIRED", "FAILED", "CANCELED"].includes(job.status)) {
572
+ throw new Error(
573
+ `${job.targetLocale}: ${job.errorCode ?? job.status}: job ${job.id}. Inspect the translation job in the dashboard.`
574
+ );
575
+ }
576
+ }
577
+ const remainingMs = deadline - Date.now();
578
+ if (completed.size < created.jobs.length && remainingMs > 0)
579
+ await new Promise(
580
+ (resolve2) => setTimeout(resolve2, Math.min(1e3, remainingMs))
581
+ );
582
+ }
583
+ if (completed.size !== created.jobs.length) {
584
+ const pending = created.jobs.filter((job) => !completed.has(job.id)).map((job) => `${job.targetLocale} (${job.id})`).join(", ");
585
+ throw new Error(
586
+ `TRANSLATION_BUILD_TIMEOUT: ${pending} did not finish within ${translation.waitTimeoutMs ?? 3e5}ms.`
587
+ );
588
+ }
589
+ await apiJson(
590
+ `${endpoint}/api/v1/manifests/${manifestId}/development-release`,
591
+ {
592
+ method: "POST",
593
+ headers: {
594
+ authorization: `Bearer ${options.token}`,
595
+ "content-type": "application/json",
596
+ "idempotency-key": `${buildKey}:release:${contentHash(
597
+ created.jobs.map(({ id }) => id).sort()
598
+ ).slice(0, 16)}`
599
+ },
600
+ body: JSON.stringify({
601
+ catalogVersionIds: [...completed.values()].map(
602
+ (job) => job.catalogVersionId
603
+ )
604
+ })
605
+ }
606
+ );
607
+ onProgress("Published the translated development catalog.");
608
+ }
609
+ function validatePluginOptions(options) {
610
+ const translation = options.translation;
611
+ if (!translation) return;
612
+ if (!options.apiUrl || !options.token)
613
+ throw new Error(
614
+ "Linguana translation requires both apiUrl and a project token."
615
+ );
616
+ if (options.environment !== "development")
617
+ throw new Error(
618
+ "Linguana build-driven publication is restricted to the development environment."
619
+ );
620
+ if (!Number.isSafeInteger(translation.maxChargeMicros) || translation.maxChargeMicros <= 0)
621
+ throw new Error(
622
+ "Linguana translation maxChargeMicros must be a positive safe integer."
623
+ );
624
+ if (translation.waitTimeoutMs !== void 0 && (!Number.isSafeInteger(translation.waitTimeoutMs) || translation.waitTimeoutMs <= 0))
625
+ throw new Error(
626
+ "Linguana translation waitTimeoutMs must be a positive safe integer."
627
+ );
628
+ if (translation.targetLocales.length === 0)
629
+ throw new Error(
630
+ "Linguana translation requires at least one target locale."
631
+ );
632
+ if (new Set(translation.targetLocales).size !== translation.targetLocales.length)
633
+ throw new Error("Linguana translation target locales must be unique.");
634
+ if (translation.targetLocales.includes(options.sourceLocale))
635
+ throw new Error(
636
+ "Linguana translation target locales must differ from the source locale."
637
+ );
638
+ }
639
+ function linguana(options) {
640
+ validatePluginOptions(options);
641
+ const filter = createFilter(
642
+ options.include ?? ["**/*.jsx", "**/*.tsx"],
643
+ options.exclude ?? ["**/node_modules/**", "**/*.test.*", "**/*.spec.*"]
644
+ );
645
+ const collected = /* @__PURE__ */ new Map();
646
+ const diagnostics = [];
647
+ const diagnosticKeys = /* @__PURE__ */ new Set();
648
+ const buildRuns = /* @__PURE__ */ new Map();
649
+ const restrictToRoot = options.include === void 0;
650
+ let root = process.cwd();
651
+ return {
652
+ name: "linguana",
653
+ enforce: "pre",
654
+ buildStart() {
655
+ this.warn(
656
+ "@linguanahq/compiler is deprecated; migrate to @linguanahq/vite with @linguanahq/vite/react."
657
+ );
658
+ },
659
+ configResolved(config) {
660
+ root = config.root;
661
+ },
662
+ transform(code, id) {
663
+ const cleanId = id.split("?", 1)[0] ?? id;
664
+ const projectRelativeId = relative(root, cleanId);
665
+ if (restrictToRoot && (projectRelativeId === ".." || projectRelativeId.startsWith("../") || projectRelativeId.startsWith("..\\") || isAbsolute(projectRelativeId)))
666
+ return null;
667
+ if (!filter(cleanId)) return null;
668
+ const result = transformSource(code, cleanId, {
669
+ sourceLocale: options.sourceLocale,
670
+ root
671
+ });
672
+ for (const message of result.messages) {
673
+ const existing = collected.get(message.id);
674
+ if (existing && existing.contentHash === message.contentHash) {
675
+ for (const item of message.locations) {
676
+ const key = `${item.file}:${item.line}:${item.column}`;
677
+ if (!existing.locations.some(
678
+ (candidate) => `${candidate.file}:${candidate.line}:${candidate.column}` === key
679
+ )) {
680
+ existing.locations.push(item);
681
+ }
682
+ }
683
+ } else if (existing) {
684
+ const item = message.locations[0];
685
+ const key = `duplicate-id:${item?.file ?? ""}:${item?.line ?? 0}:${item?.column ?? 0}`;
686
+ if (!diagnosticKeys.has(key)) {
687
+ diagnosticKeys.add(key);
688
+ diagnostics.push({
689
+ code: "duplicate-id",
690
+ severity: "error",
691
+ message: `Message ID ${message.id} refers to incompatible source content across modules.`,
692
+ location: item,
693
+ remediation: "Choose a unique explicit ID or make the source messages identical."
694
+ });
695
+ }
696
+ } else {
697
+ collected.set(message.id, message);
698
+ }
699
+ }
700
+ for (const diagnostic of result.diagnostics) {
701
+ const item = diagnostic.location;
702
+ const key = `${diagnostic.code}:${item?.file ?? ""}:${item?.line ?? 0}:${item?.column ?? 0}`;
703
+ if (!diagnosticKeys.has(key)) {
704
+ diagnosticKeys.add(key);
705
+ diagnostics.push(diagnostic);
706
+ }
707
+ }
708
+ return { code: result.code, map: result.map };
709
+ },
710
+ async writeBundle() {
711
+ const manifest = manifestSchema.parse({
712
+ schemaVersion: SCHEMA_VERSION,
713
+ compilerVersion: COMPILER_VERSION,
714
+ projectId: options.projectId,
715
+ environment: options.environment,
716
+ sourceLocale: options.sourceLocale,
717
+ sourceRevision: process.env.GIT_COMMIT_SHA,
718
+ messages: [...collected.values()].sort(
719
+ (left, right) => left.id.localeCompare(right.id)
720
+ ),
721
+ diagnostics
722
+ });
723
+ const output = resolve(root, options.output ?? ".linguana/manifest.json");
724
+ await mkdir(dirname(output), { recursive: true });
725
+ await writeFile(output, `${JSON.stringify(manifest, null, 2)}
726
+ `, "utf8");
727
+ for (const item of diagnostics) {
728
+ const method = item.severity === "error" ? "error" : "warn";
729
+ this[method](`${item.code}: ${item.message}`);
730
+ }
731
+ if (shouldFail(options.strict, diagnostics)) {
732
+ this.error("Linguana strict policy rejected compiler diagnostics.");
733
+ }
734
+ if (options.upload || options.translation) {
735
+ const hash = manifestHash(manifest);
736
+ let buildRun = buildRuns.get(hash);
737
+ if (!buildRun) {
738
+ buildRun = (async () => {
739
+ const registration = await uploadManifest(options, manifest, hash);
740
+ if (options.translation)
741
+ await translateManifest(
742
+ options,
743
+ registration.manifestId,
744
+ hash,
745
+ (message) => this.info(`[linguana] ${message}`)
746
+ );
747
+ })();
748
+ buildRuns.set(hash, buildRun);
749
+ }
750
+ await buildRun;
751
+ }
752
+ }
753
+ };
754
+ }
755
+ var index_default = linguana;
756
+ export {
757
+ index_default as default,
758
+ linguana,
759
+ transformSource
760
+ };