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