@linguanahq/compiler 0.1.0 → 0.1.2

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,17 @@
1
+ import generateImport from "@babel/generator";
2
+ import { type Diagnostic, type ManifestMessage } from "@linguanahq/catalog";
3
+ export type TransformOptions = {
4
+ sourceLocale: string;
5
+ root?: string;
6
+ runtimePackage?: string;
7
+ compilerRuntime?: string;
8
+ };
9
+ declare const generate: typeof generateImport;
10
+ export type TransformResult = {
11
+ code: string;
12
+ map: ReturnType<typeof generate>["map"];
13
+ messages: ManifestMessage[];
14
+ diagnostics: Diagnostic[];
15
+ };
16
+ export declare function transformSource(code: string, filename: string, options: TransformOptions): TransformResult;
17
+ export {};
@@ -0,0 +1,468 @@
1
+ // src/transform.ts
2
+ import { relative } from "node:path";
3
+ import generateImport from "@babel/generator";
4
+ import { parse } from "@babel/parser";
5
+ import traverseImport from "@babel/traverse";
6
+ import * as t from "@babel/types";
7
+ import {
8
+ contentHash,
9
+ normalizeSource,
10
+ parseMessagePlaceholders,
11
+ stableMessageId
12
+ } from "@linguanahq/catalog";
13
+ var generate = generateImport.default ?? generateImport;
14
+ var traverse = traverseImport.default ?? traverseImport;
15
+ var EXCLUDED_ELEMENTS = /* @__PURE__ */ new Set(["script", "style", "code", "pre"]);
16
+ var ALLOWED_ATTRIBUTES = /* @__PURE__ */ new Set([
17
+ "title",
18
+ "placeholder",
19
+ "aria-label",
20
+ "alt",
21
+ "label",
22
+ "value"
23
+ ]);
24
+ var MACHINE_ATTRIBUTES = /* @__PURE__ */ new Set([
25
+ "id",
26
+ "class",
27
+ "className",
28
+ "name",
29
+ "role",
30
+ "href",
31
+ "src",
32
+ "action",
33
+ "method",
34
+ "data-testid",
35
+ "data-test-id",
36
+ "data-analytics",
37
+ "data-event"
38
+ ]);
39
+ function jsxName(node) {
40
+ if (t.isJSXIdentifier(node)) return node.name;
41
+ if (t.isJSXMemberExpression(node)) return jsxName(node.property);
42
+ return `${node.namespace.name}:${node.name.name}`;
43
+ }
44
+ function renderedJsxText(value) {
45
+ const lines = value.replace(/\r\n?/g, "\n").split("\n");
46
+ let result = "";
47
+ for (let index = 0; index < lines.length; index += 1) {
48
+ let line = lines[index]?.replace(/\t/g, " ") ?? "";
49
+ if (index !== 0) line = line.replace(/^\s+/, "");
50
+ if (index !== lines.length - 1) line = line.replace(/\s+$/, "");
51
+ if (!line) continue;
52
+ if (result && !result.endsWith(" ")) result += " ";
53
+ result += line;
54
+ }
55
+ return result;
56
+ }
57
+ function location(path, root, filename) {
58
+ const start = path.node.loc?.start;
59
+ return {
60
+ file: relative(root, filename).replaceAll("\\", "/"),
61
+ line: start?.line ?? 1,
62
+ column: start?.column ?? 0
63
+ };
64
+ }
65
+ function getLiteralAttribute(opening, name) {
66
+ for (const attribute of opening.attributes) {
67
+ if (t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name) && attribute.name.name === name && t.isStringLiteral(attribute.value)) {
68
+ return attribute.value.value;
69
+ }
70
+ }
71
+ return void 0;
72
+ }
73
+ function hasAttribute(opening, name) {
74
+ return opening.attributes.some(
75
+ (attribute) => t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name) && attribute.name.name === name
76
+ );
77
+ }
78
+ function attributeValue(opening, name) {
79
+ for (const attribute of opening.attributes) {
80
+ if (!t.isJSXAttribute(attribute) || !t.isJSXIdentifier(attribute.name, { name }))
81
+ continue;
82
+ if (attribute.value === null) return true;
83
+ if (t.isStringLiteral(attribute.value)) return attribute.value.value;
84
+ }
85
+ }
86
+ function isUnsafeStaticCopy(source) {
87
+ const normalized = normalizeSource(source);
88
+ return /^(?:https?:\/\/|mailto:|tel:|www\.)/i.test(normalized) || /^\/?[\w.-]+(?:\/[\w./-]+)+(?:\?\S*)?$/.test(normalized) || /^[a-z0-9]+(?:[_:.][a-z0-9-]+)+$/i.test(normalized);
89
+ }
90
+ function isExcluded(path) {
91
+ return Boolean(
92
+ path.findParent((parent) => {
93
+ if (!parent.isJSXElement()) return false;
94
+ const opening = parent.node.openingElement;
95
+ const tag = jsxName(opening.name);
96
+ 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";
97
+ })
98
+ );
99
+ }
100
+ function isExcludedElement(opening) {
101
+ const tag = jsxName(opening.name);
102
+ 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";
103
+ }
104
+ function hasMixedRichChildren(path) {
105
+ return Boolean(
106
+ path.findParent((parent) => {
107
+ if (!parent.isJSXElement()) return false;
108
+ const children = parent.node.children;
109
+ const hasElement = children.some(
110
+ (child) => t.isJSXElement(child) || t.isJSXExpressionContainer(child) && !t.isJSXEmptyExpression(child.expression)
111
+ );
112
+ const hasText = children.some(
113
+ (child) => t.isJSXText(child) && Boolean(renderedJsxText(child.value))
114
+ );
115
+ return hasElement && hasText;
116
+ })
117
+ );
118
+ }
119
+ function placeholders(source) {
120
+ try {
121
+ return parseMessagePlaceholders(source);
122
+ } catch {
123
+ return [];
124
+ }
125
+ }
126
+ function stringProperty(object, name) {
127
+ for (const property of object.properties) {
128
+ if (!t.isObjectProperty(property)) continue;
129
+ const key = t.isIdentifier(property.key) ? property.key.name : t.isStringLiteral(property.key) ? property.key.value : void 0;
130
+ if (key === name && t.isStringLiteral(property.value))
131
+ return property.value.value;
132
+ }
133
+ return void 0;
134
+ }
135
+ function isTranslationCall(path, runtimePackage = "@linguanahq/react") {
136
+ if (!t.isIdentifier(path.node.callee)) return false;
137
+ const name = path.node.callee.name;
138
+ const binding = path.scope.getBinding(name);
139
+ if (!binding) return false;
140
+ if (binding.path.isImportSpecifier()) {
141
+ const declaration = binding.path.parentPath;
142
+ return declaration.isImportDeclaration() && declaration.node.source.value === runtimePackage && t.isIdentifier(binding.path.node.imported, { name: "t" });
143
+ }
144
+ const declarator = binding.path.isVariableDeclarator() ? binding.path : binding.path.findParent((candidate) => candidate.isVariableDeclarator());
145
+ if (!declarator?.isVariableDeclarator()) return false;
146
+ const init = t.isAwaitExpression(declarator.node.init) ? declarator.node.init.argument : declarator.node.init;
147
+ if (!t.isCallExpression(init) || !t.isIdentifier(init.callee) || !t.isObjectPattern(declarator.node.id))
148
+ return false;
149
+ const factory = path.scope.getBinding(init.callee.name);
150
+ const imported = factory?.path;
151
+ const nextFactory = imported?.isImportSpecifier() && imported.parentPath.isImportDeclaration() && ["@linguanahq/next/client", "@linguanahq/next/server"].includes(
152
+ imported.parentPath.node.source.value
153
+ ) && t.isIdentifier(imported.node.imported) && ["useTranslation", "getTranslations"].includes(imported.node.imported.name);
154
+ if (!nextFactory && init.callee.name !== "useTranslation") return false;
155
+ return declarator.node.id.properties.some(
156
+ (property) => t.isObjectProperty(property) && t.isIdentifier(property.key, { name: "t" }) && t.isIdentifier(property.value, { name })
157
+ );
158
+ }
159
+ function transformSource(code, filename, options) {
160
+ const root = options.root ?? process.cwd();
161
+ const ast = parse(code, {
162
+ sourceType: "module",
163
+ sourceFilename: filename,
164
+ plugins: ["jsx", "typescript"]
165
+ });
166
+ const names = /* @__PURE__ */ new Set();
167
+ traverse(ast, {
168
+ Identifier(path) {
169
+ names.add(path.node.name);
170
+ },
171
+ JSXIdentifier(path) {
172
+ names.add(path.node.name);
173
+ }
174
+ });
175
+ const unique = (base) => {
176
+ let name = base;
177
+ let suffix = 0;
178
+ while (names.has(name)) name = `${base}${++suffix}`;
179
+ names.add(name);
180
+ return name;
181
+ };
182
+ const messageName = unique("__LinguanaMessage");
183
+ const attributesName = unique("__LinguanaAttributes");
184
+ const localizedName = unique("__localized");
185
+ const messages = /* @__PURE__ */ new Map();
186
+ const diagnostics = [];
187
+ let needsRuntime = false;
188
+ const richDiagnosticLocations = /* @__PURE__ */ new Set();
189
+ const addMessage = (message) => {
190
+ const existing = messages.get(message.id);
191
+ if (existing && existing.contentHash !== message.contentHash) {
192
+ diagnostics.push({
193
+ code: "duplicate-id",
194
+ severity: "error",
195
+ message: `Message ID ${message.id} refers to incompatible source content`,
196
+ location: message.locations[0],
197
+ remediation: "Choose a unique explicit ID or make the source messages identical."
198
+ });
199
+ return;
200
+ }
201
+ if (existing) {
202
+ existing.locations.push(...message.locations);
203
+ return;
204
+ }
205
+ messages.set(message.id, message);
206
+ };
207
+ traverse(ast, {
208
+ JSXText(path) {
209
+ const source = renderedJsxText(path.node.value);
210
+ if (!source || isExcluded(path)) return;
211
+ if (isUnsafeStaticCopy(source)) {
212
+ diagnostics.push({
213
+ code: "unsafe-static-copy",
214
+ severity: "info",
215
+ message: "URL-like or machine-readable text was excluded from extraction.",
216
+ location: location(path, root, filename)
217
+ });
218
+ return;
219
+ }
220
+ if (hasMixedRichChildren(path)) {
221
+ const itemLocation = location(path, root, filename);
222
+ const key = `${itemLocation.file}:${itemLocation.line}:${itemLocation.column}`;
223
+ if (!richDiagnosticLocations.has(key)) {
224
+ richDiagnosticLocations.add(key);
225
+ diagnostics.push({
226
+ code: "ambiguous-rich-text",
227
+ severity: "warning",
228
+ message: "Mixed text and JSX elements require an explicit <T> message.",
229
+ location: itemLocation,
230
+ remediation: "Use <T defaultMessage components> or data-no-translate."
231
+ });
232
+ }
233
+ return;
234
+ }
235
+ const parent = path.findParent((candidate) => candidate.isJSXElement());
236
+ if (!parent?.isJSXElement()) return;
237
+ const element = jsxName(parent.node.openingElement.name);
238
+ const context = { kind: "text", element };
239
+ const id = stableMessageId({
240
+ sourceLocale: options.sourceLocale,
241
+ source,
242
+ context
243
+ });
244
+ addMessage({
245
+ id,
246
+ source,
247
+ normalized: normalizeSource(source),
248
+ contentHash: contentHash(source),
249
+ context,
250
+ placeholders: placeholders(source),
251
+ confidence: "high",
252
+ locations: [location(path, root, filename)]
253
+ });
254
+ needsRuntime = true;
255
+ path.replaceWith(
256
+ t.jsxExpressionContainer(
257
+ t.jsxElement(
258
+ t.jsxOpeningElement(
259
+ t.jsxIdentifier(messageName),
260
+ [
261
+ t.jsxAttribute(t.jsxIdentifier("id"), t.stringLiteral(id)),
262
+ t.jsxAttribute(
263
+ t.jsxIdentifier("defaultMessage"),
264
+ t.stringLiteral(source)
265
+ )
266
+ ],
267
+ true
268
+ ),
269
+ null,
270
+ [],
271
+ true
272
+ )
273
+ )
274
+ );
275
+ },
276
+ JSXExpressionContainer(path) {
277
+ if (isExcluded(path) || t.isJSXEmptyExpression(path.node.expression) || !path.parentPath.isJSXElement())
278
+ return;
279
+ if (t.isConditionalExpression(path.node.expression) || t.isBinaryExpression(path.node.expression) || t.isTemplateLiteral(path.node.expression)) {
280
+ diagnostics.push({
281
+ code: "dynamic-candidate",
282
+ severity: "warning",
283
+ message: "Dynamic JSX copy was not automatically extracted.",
284
+ location: location(path, root, filename),
285
+ remediation: "Use t(), <T>, data-no-translate, or an explicit annotation."
286
+ });
287
+ }
288
+ },
289
+ CallExpression(path) {
290
+ if (!isTranslationCall(path, options.runtimePackage)) return;
291
+ const [sourceNode, , optionsNode] = path.node.arguments;
292
+ if (!t.isStringLiteral(sourceNode)) return;
293
+ const explicitId = t.isObjectExpression(optionsNode) ? stringProperty(optionsNode, "id") : void 0;
294
+ const developer = t.isObjectExpression(optionsNode) ? stringProperty(optionsNode, "context") : void 0;
295
+ const context = { kind: "explicit", developer };
296
+ const id = stableMessageId({
297
+ sourceLocale: options.sourceLocale,
298
+ source: sourceNode.value,
299
+ context,
300
+ explicitId
301
+ });
302
+ addMessage({
303
+ id,
304
+ source: sourceNode.value,
305
+ normalized: normalizeSource(sourceNode.value),
306
+ contentHash: contentHash(sourceNode.value),
307
+ context,
308
+ placeholders: placeholders(sourceNode.value),
309
+ confidence: "explicit",
310
+ locations: [location(path, root, filename)]
311
+ });
312
+ },
313
+ JSXElement: {
314
+ exit(path) {
315
+ if (isExcluded(path) || isExcludedElement(path.node.openingElement))
316
+ return;
317
+ const opening = path.node.openingElement;
318
+ const tag = jsxName(opening.name);
319
+ if (tag === "T") {
320
+ const source = getLiteralAttribute(opening, "defaultMessage");
321
+ if (!source) return;
322
+ const explicitId = getLiteralAttribute(opening, "id");
323
+ const developer = getLiteralAttribute(opening, "context");
324
+ const context = { kind: "explicit", developer };
325
+ const id = stableMessageId({
326
+ sourceLocale: options.sourceLocale,
327
+ source,
328
+ context,
329
+ explicitId
330
+ });
331
+ addMessage({
332
+ id,
333
+ source,
334
+ normalized: normalizeSource(source),
335
+ contentHash: contentHash(source),
336
+ context,
337
+ placeholders: placeholders(source),
338
+ confidence: "explicit",
339
+ locations: [location(path, root, filename)]
340
+ });
341
+ return;
342
+ }
343
+ const descriptors = [];
344
+ const localizedNames = [];
345
+ for (const attribute of opening.attributes) {
346
+ if (!t.isJSXAttribute(attribute) || !t.isJSXIdentifier(attribute.name) || !t.isStringLiteral(attribute.value))
347
+ continue;
348
+ const name = attribute.name.name;
349
+ if (MACHINE_ATTRIBUTES.has(name) || name.startsWith("on") || name === "data-translate")
350
+ continue;
351
+ const optInValue = attributeValue(opening, "data-translate");
352
+ const optedIn = typeof optInValue === "string" && optInValue.split(/[\s,]+/).includes(name);
353
+ if (!ALLOWED_ATTRIBUTES.has(name) && !optedIn) continue;
354
+ if (name === "value" && tag.toLowerCase() !== "button") continue;
355
+ const source = attribute.value.value;
356
+ if (!source || isUnsafeStaticCopy(source)) continue;
357
+ const context = {
358
+ kind: "attribute",
359
+ element: tag,
360
+ attribute: name
361
+ };
362
+ const id = stableMessageId({
363
+ sourceLocale: options.sourceLocale,
364
+ source,
365
+ context
366
+ });
367
+ addMessage({
368
+ id,
369
+ source,
370
+ normalized: normalizeSource(source),
371
+ contentHash: contentHash(source),
372
+ context,
373
+ placeholders: placeholders(source),
374
+ confidence: "high",
375
+ locations: [location(path, root, filename)]
376
+ });
377
+ localizedNames.push(name);
378
+ descriptors.push(
379
+ t.objectExpression([
380
+ t.objectProperty(t.identifier("name"), t.stringLiteral(name)),
381
+ t.objectProperty(t.identifier("id"), t.stringLiteral(id)),
382
+ t.objectProperty(
383
+ t.identifier("defaultMessage"),
384
+ t.stringLiteral(source)
385
+ )
386
+ ])
387
+ );
388
+ }
389
+ if (!descriptors.length) return;
390
+ needsRuntime = true;
391
+ const original = t.cloneNode(path.node, true);
392
+ for (const attribute of original.openingElement.attributes) {
393
+ if (t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name) && localizedNames.includes(attribute.name.name)) {
394
+ attribute.value = t.jsxExpressionContainer(
395
+ t.memberExpression(
396
+ t.identifier(localizedName),
397
+ t.stringLiteral(attribute.name.name),
398
+ true
399
+ )
400
+ );
401
+ }
402
+ }
403
+ const wrapper = t.jsxElement(
404
+ t.jsxOpeningElement(
405
+ t.jsxIdentifier(attributesName),
406
+ [
407
+ t.jsxAttribute(
408
+ t.jsxIdentifier("descriptors"),
409
+ t.jsxExpressionContainer(t.arrayExpression(descriptors))
410
+ )
411
+ ],
412
+ false
413
+ ),
414
+ t.jsxClosingElement(t.jsxIdentifier(attributesName)),
415
+ [
416
+ t.jsxExpressionContainer(
417
+ t.arrowFunctionExpression(
418
+ [t.identifier(localizedName)],
419
+ original
420
+ )
421
+ )
422
+ ]
423
+ );
424
+ path.replaceWith(wrapper);
425
+ path.skip();
426
+ }
427
+ },
428
+ Program: {
429
+ exit(path) {
430
+ if (!needsRuntime) return;
431
+ path.unshiftContainer(
432
+ "body",
433
+ t.importDeclaration(
434
+ [
435
+ t.importSpecifier(
436
+ t.identifier(messageName),
437
+ t.identifier("LinguanaMessage")
438
+ ),
439
+ t.importSpecifier(
440
+ t.identifier(attributesName),
441
+ t.identifier("LinguanaAttributes")
442
+ )
443
+ ],
444
+ t.stringLiteral(
445
+ options.compilerRuntime ?? "@linguanahq/react/compiler-runtime"
446
+ )
447
+ )
448
+ );
449
+ }
450
+ }
451
+ });
452
+ const output = generate(
453
+ ast,
454
+ { sourceMaps: true, sourceFileName: filename },
455
+ code
456
+ );
457
+ return {
458
+ code: output.code,
459
+ map: output.map,
460
+ messages: [...messages.values()].sort(
461
+ (left, right) => left.id.localeCompare(right.id)
462
+ ),
463
+ diagnostics
464
+ };
465
+ }
466
+ export {
467
+ transformSource
468
+ };
package/package.json CHANGED
@@ -1,33 +1,42 @@
1
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
- }
2
+ "name": "@linguanahq/compiler",
3
+ "version": "0.1.2",
4
+ "type": "module",
5
+ "description": "Linguana compiler integration",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.js",
10
+ "default": "./dist/index.js"
11
+ },
12
+ "./transform": {
13
+ "types": "./dist/transform.d.ts",
14
+ "import": "./dist/transform.js",
15
+ "default": "./dist/transform.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "engines": {
22
+ "node": ">=22.12.0"
23
+ },
24
+ "dependencies": {
25
+ "@babel/generator": "^7.28.5",
26
+ "@babel/parser": "^7.28.5",
27
+ "@babel/traverse": "^7.28.5",
28
+ "@babel/types": "^7.29.8",
29
+ "@linguanahq/catalog": "0.1.2"
30
+ },
31
+ "peerDependencies": {
32
+ "vite": ">=7 <9"
33
+ },
34
+ "peerDependenciesMeta": {
35
+ "vite": {
36
+ "optional": true
37
+ }
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ }
33
42
  }