@inglorious/vite-plugin-jsx 1.0.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/src/index.js ADDED
@@ -0,0 +1,476 @@
1
+ import { transformAsync, types as t } from "@babel/core"
2
+ import syntaxJsx from "@babel/plugin-syntax-jsx"
3
+ import syntaxTs from "@babel/plugin-syntax-typescript"
4
+
5
+ const NOT_FOUND = -1
6
+ const LAST = 1
7
+ const AFTER_ON = 2
8
+
9
+ const VOID_TAGS = [
10
+ "area",
11
+ "base",
12
+ "br",
13
+ "col",
14
+ "embed",
15
+ "hr",
16
+ "img",
17
+ "input",
18
+ "link",
19
+ "meta",
20
+ "param",
21
+ "source",
22
+ "track",
23
+ "wbr",
24
+ ]
25
+
26
+ /**
27
+ * Vite plugin to transform JSX into lit-html templates for @inglorious/web.
28
+ *
29
+ * @returns {import('vite').Plugin} The Vite plugin instance.
30
+ */
31
+ export function jsx() {
32
+ return {
33
+ name: "@inglorious/vite-plugin-jsx",
34
+
35
+ async transform(code, id) {
36
+ if (!/\.[jt]sx$/.test(id)) return null
37
+
38
+ const result = await transformAsync(code, {
39
+ filename: id,
40
+ babelrc: false,
41
+ configFile: false,
42
+ plugins: [syntaxJsx, [syntaxTs, { isTSX: true }], jsxToLit()],
43
+ sourceMaps: true,
44
+ })
45
+
46
+ return result && { code: result.code, map: result.map }
47
+ },
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Babel plugin factory that traverses the AST to transform JSX elements.
53
+ *
54
+ * @returns {import('@babel/core').PluginObj} The Babel visitor object.
55
+ */
56
+ function jsxToLit() {
57
+ return {
58
+ visitor: {
59
+ Program: {
60
+ enter(path) {
61
+ path.__needsHtml = false
62
+ path.__needsWhen = false
63
+ path.__needsRepeat = false
64
+ },
65
+ exit(path) {
66
+ const importSource = "@inglorious/web"
67
+ const needed = new Set()
68
+ if (path.__needsHtml) needed.add("html")
69
+ if (path.__needsWhen) needed.add("when")
70
+ if (path.__needsRepeat) needed.add("repeat")
71
+
72
+ if (!needed.size) return
73
+
74
+ let importDecl = null
75
+
76
+ // Find existing import from '@inglorious/web' and remove already imported names from `needed`
77
+ path.get("body").forEach((nodePath) => {
78
+ if (
79
+ nodePath.isImportDeclaration() &&
80
+ nodePath.node.source.value === importSource
81
+ ) {
82
+ importDecl = nodePath
83
+ for (const specifier of nodePath.get("specifiers")) {
84
+ if (specifier.isImportSpecifier()) {
85
+ needed.delete(specifier.node.imported.name)
86
+ }
87
+ }
88
+ }
89
+ })
90
+
91
+ const specifiersToAdd = [...needed].map(createImportSpecifier)
92
+
93
+ if (specifiersToAdd.length) {
94
+ if (importDecl) {
95
+ // Add missing specifiers to the existing import declaration
96
+ importDecl.pushContainer("specifiers", specifiersToAdd)
97
+ } else {
98
+ // Or, create a new import declaration
99
+ path.unshiftContainer(
100
+ "body",
101
+ t.importDeclaration(
102
+ specifiersToAdd,
103
+ t.stringLiteral(importSource),
104
+ ),
105
+ )
106
+ }
107
+ }
108
+ },
109
+ },
110
+
111
+ JSXElement(path) {
112
+ path.findParent((p) => p.isProgram()).__needsHtml = true
113
+ path.replaceWith(buildTemplate(path.node, path))
114
+ },
115
+
116
+ JSXFragment(path) {
117
+ path.findParent((p) => p.isProgram()).__needsHtml = true
118
+ path.replaceWith(buildFragment(path.node, path))
119
+ },
120
+
121
+ // Transform {cond ? <A/> : <B/>} -> ${when(cond, () => html`<A/>`, () => html`<B/>`)}
122
+ ConditionalExpression(path) {
123
+ const { test, consequent, alternate } = path.node
124
+ if (isJsx(consequent) || isJsx(alternate)) {
125
+ path.findParent((p) => p.isProgram()).__needsWhen = true
126
+ path.replaceWith(
127
+ t.callExpression(t.identifier("when"), [
128
+ test,
129
+ t.arrowFunctionExpression([], consequent),
130
+ t.arrowFunctionExpression([], alternate),
131
+ ]),
132
+ )
133
+ }
134
+ },
135
+
136
+ // Transform {cond && <A/>} -> ${when(cond, () => html`<A/>`)}
137
+ LogicalExpression(path) {
138
+ if (path.node.operator === "&&" && isJsx(path.node.right)) {
139
+ path.findParent((p) => p.isProgram()).__needsWhen = true
140
+ path.replaceWith(
141
+ t.callExpression(t.identifier("when"), [
142
+ path.node.left,
143
+ t.arrowFunctionExpression([], path.node.right),
144
+ ]),
145
+ )
146
+ }
147
+ },
148
+
149
+ // Transform items.map(i => <A key={i.id}/>) -> ${repeat(items, i => i.id, i => html`<A.../>`)}
150
+ CallExpression(path) {
151
+ const { callee, arguments: args } = path.node
152
+ const [arrow] = args
153
+
154
+ if (
155
+ t.isMemberExpression(callee) &&
156
+ callee.property.name === "map" &&
157
+ t.isArrowFunctionExpression(arrow)
158
+ ) {
159
+ if (isJsx(arrow.body)) {
160
+ if (
161
+ !path.findParent(
162
+ (p) => p.isJSXExpressionContainer() || p.isReturnStatement(),
163
+ )
164
+ ) {
165
+ return
166
+ }
167
+
168
+ path.findParent((p) => p.isProgram()).__needsRepeat = true
169
+
170
+ const items = callee.object
171
+ const templateFn = arrow
172
+ const repeatArgs = [items]
173
+
174
+ // Try to extract key from the returned JSX element
175
+ // Look for key={...} in the opening element attributes
176
+ let keyExpr = null
177
+ if (t.isJSXElement(arrow.body)) {
178
+ const keyAttr = arrow.body.openingElement.attributes.find(
179
+ (attr) => attr.name && attr.name.name === "key",
180
+ )
181
+ if (keyAttr && keyAttr.value.type === "JSXExpressionContainer") {
182
+ keyExpr = keyAttr.value.expression
183
+ }
184
+ }
185
+
186
+ // If key found, inject key function as 2nd argument: (item) => item.id
187
+ if (keyExpr) {
188
+ repeatArgs.push(t.arrowFunctionExpression(arrow.params, keyExpr))
189
+ } else {
190
+ // If no key, repeat acts like map, but we can still use it
191
+ // Or we could skip repeat and just let map run (lit handles arrays)
192
+ // But user asked for repeat.
193
+ }
194
+
195
+ repeatArgs.push(templateFn)
196
+
197
+ path.replaceWith(
198
+ t.callExpression(t.identifier("repeat"), repeatArgs),
199
+ )
200
+ }
201
+ }
202
+ },
203
+ },
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Converts a JSXElement node into a lit-html TaggedTemplateExpression.
209
+ *
210
+ * @param {import('@babel/types').JSXElement} node - The JSX element node.
211
+ * @param {import('@babel/core').NodePath} [path] - The path to the node.
212
+ * @param {boolean} [isSvg=false] - Whether we are inside an SVG context.
213
+ * @returns {import('@babel/types').TaggedTemplateExpression} The transformed node.
214
+ */
215
+ function buildTemplate(node, path, isSvg = false) {
216
+ const tag = node.openingElement.name.name
217
+
218
+ // If capitalized → engine component
219
+ if (/^[A-Z]/.test(tag)) {
220
+ if (path) {
221
+ const fn = path.getFunctionParent()
222
+ if (fn) {
223
+ let isRender = false
224
+ // ObjectMethod: { render() {} }
225
+ if (fn.isObjectMethod() && fn.node.key.name === "render") {
226
+ isRender = true
227
+ }
228
+ // ObjectProperty: { render: () => {} } or { render: function() {} }
229
+ else if (
230
+ fn.parentPath.isObjectProperty() &&
231
+ fn.parentPath.node.key.name === "render"
232
+ ) {
233
+ isRender = true
234
+ }
235
+
236
+ if (isRender) {
237
+ const params = fn.node.params
238
+ const apiIndex = params.findIndex(
239
+ (p) => t.isIdentifier(p) && p.name === "api",
240
+ )
241
+
242
+ if (apiIndex !== NOT_FOUND) {
243
+ if (apiIndex !== params.length - LAST) {
244
+ const lastParam = params[params.length - LAST]
245
+ if (t.isRestElement(lastParam)) {
246
+ throw fn.buildCodeFrameError(
247
+ "Cannot move 'api' parameter to the end because of a rest element.",
248
+ )
249
+ }
250
+ const apiPath = fn.get("params")[apiIndex]
251
+ const apiNode = apiPath.node
252
+ apiPath.remove()
253
+ fn.pushContainer("params", apiNode)
254
+ }
255
+ } else {
256
+ const lastParam = params[params.length - LAST]
257
+ if (lastParam && t.isRestElement(lastParam)) {
258
+ throw fn.buildCodeFrameError(
259
+ "Cannot inject 'api' parameter because of a rest element.",
260
+ )
261
+ }
262
+ fn.pushContainer("params", t.identifier("api"))
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ const name = tag.toLowerCase()
269
+ const props = []
270
+
271
+ for (const attr of node.openingElement.attributes) {
272
+ if (t.isJSXSpreadAttribute(attr)) {
273
+ props.push(t.spreadElement(attr.argument))
274
+ continue
275
+ }
276
+
277
+ const key = attr.name.name
278
+ let value
279
+
280
+ if (!attr.value) {
281
+ value = t.booleanLiteral(true)
282
+ } else if (t.isJSXExpressionContainer(attr.value)) {
283
+ value = attr.value.expression
284
+ } else {
285
+ value = attr.value
286
+ }
287
+
288
+ props.push(t.objectProperty(t.identifier(key), value))
289
+ }
290
+
291
+ return t.callExpression(
292
+ t.memberExpression(t.identifier("api"), t.identifier("render")),
293
+ props.length
294
+ ? [t.stringLiteral(name), t.objectExpression(props)]
295
+ : [t.stringLiteral(name)],
296
+ )
297
+ }
298
+
299
+ const isCurrentSvg = isSvg || tag === "svg"
300
+ let text = `<${tag}`
301
+ const quasis = []
302
+ const exprs = []
303
+
304
+ for (const attr of node.openingElement.attributes) {
305
+ let name = attr.name.name
306
+ const value = attr.value
307
+
308
+ if (name === "className") name = "class"
309
+
310
+ if (name.startsWith("on")) {
311
+ if (!value || value.type !== "JSXExpressionContainer") {
312
+ if (path) {
313
+ throw path.buildCodeFrameError(
314
+ `Event handler ${name} must be an expression`,
315
+ )
316
+ }
317
+ throw new Error(`Event handler ${name} must be an expression`)
318
+ }
319
+ quasis.push(tpl(`${text} @${name.slice(AFTER_ON).toLowerCase()}=`))
320
+ exprs.push(value.expression)
321
+ text = ""
322
+ continue
323
+ }
324
+
325
+ if (!value) {
326
+ text += ` ${name}`
327
+ continue
328
+ }
329
+
330
+ if (value.type === "JSXExpressionContainer") {
331
+ // Use property binding (.) only if it's not a standard attribute or kebab-case
332
+ const prefix =
333
+ name.includes("-") || name === "class" || name === "id" ? "" : "."
334
+ quasis.push(tpl(`${text} ${prefix}${name}=`))
335
+ exprs.push(value.expression)
336
+ text = ""
337
+ continue
338
+ }
339
+
340
+ text += ` ${name}="${value.value}"`
341
+ }
342
+
343
+ // Handle Void tags (always self-closing) and SVG self-closing tags
344
+ if (
345
+ VOID_TAGS.includes(tag) ||
346
+ (node.openingElement.selfClosing && isCurrentSvg)
347
+ ) {
348
+ text += " />"
349
+ quasis.push(tpl(text, true))
350
+ return {
351
+ type: "TaggedTemplateExpression",
352
+ tag: { type: "Identifier", name: "html" },
353
+ quasi: { type: "TemplateLiteral", quasis, expressions: exprs },
354
+ }
355
+ }
356
+
357
+ // Handle non-void HTML tags that are self-closing in JSX (e.g. <div />)
358
+ // These must be expanded to <div></div> for the browser parser.
359
+ if (node.openingElement.selfClosing) {
360
+ text += `></${tag}>`
361
+ quasis.push(tpl(text, true))
362
+ return {
363
+ type: "TaggedTemplateExpression",
364
+ tag: { type: "Identifier", name: "html" },
365
+ quasi: { type: "TemplateLiteral", quasis, expressions: exprs },
366
+ }
367
+ }
368
+
369
+ text += ">"
370
+
371
+ const nextIsSvg = isCurrentSvg && tag !== "foreignObject"
372
+ const childrenPaths = path ? path.get("children") : []
373
+
374
+ for (let i = 0; i < node.children.length; i++) {
375
+ const child = node.children[i]
376
+ const childPath = childrenPaths[i]
377
+
378
+ if (child.type === "JSXText") {
379
+ text += child.value.replace(/\s+/g, " ")
380
+ } else if (child.type === "JSXExpressionContainer") {
381
+ if (child.expression.type === "JSXEmptyExpression") continue
382
+ quasis.push(tpl(text))
383
+ exprs.push(child.expression)
384
+ text = ""
385
+ } else {
386
+ quasis.push(tpl(text))
387
+ exprs.push(
388
+ child.type === "JSXFragment"
389
+ ? buildFragment(child, childPath, nextIsSvg)
390
+ : buildTemplate(child, childPath, nextIsSvg),
391
+ )
392
+ text = ""
393
+ }
394
+ }
395
+
396
+ text += `</${tag}>`
397
+ quasis.push(tpl(text, true))
398
+
399
+ return {
400
+ type: "TaggedTemplateExpression",
401
+ tag: { type: "Identifier", name: "html" },
402
+ quasi: { type: "TemplateLiteral", quasis, expressions: exprs },
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Converts a JSXFragment node into a lit-html TaggedTemplateExpression.
408
+ *
409
+ * @param {import('@babel/types').JSXFragment} node - The JSX fragment node.
410
+ * @param {import('@babel/core').NodePath} [path] - The path to the node.
411
+ * @param {boolean} [isSvg=false] - Whether we are inside an SVG context.
412
+ * @returns {import('@babel/types').TaggedTemplateExpression} The transformed node.
413
+ */
414
+ function buildFragment(node, path, isSvg = false) {
415
+ let text = ""
416
+ const quasis = []
417
+ const exprs = []
418
+
419
+ const childrenPaths = path ? path.get("children") : []
420
+ for (let i = 0; i < node.children.length; i++) {
421
+ const child = node.children[i]
422
+ const childPath = childrenPaths[i]
423
+
424
+ if (child.type === "JSXText") {
425
+ text += child.value.replace(/\s+/g, " ")
426
+ } else if (child.type === "JSXExpressionContainer") {
427
+ if (child.expression.type === "JSXEmptyExpression") continue
428
+ quasis.push(tpl(text))
429
+ exprs.push(child.expression)
430
+ text = ""
431
+ } else {
432
+ quasis.push(tpl(text))
433
+ exprs.push(
434
+ child.type === "JSXFragment"
435
+ ? buildFragment(child, childPath, isSvg)
436
+ : buildTemplate(child, childPath, isSvg),
437
+ )
438
+ text = ""
439
+ }
440
+ }
441
+
442
+ quasis.push(tpl(text, true))
443
+
444
+ return {
445
+ type: "TaggedTemplateExpression",
446
+ tag: { type: "Identifier", name: "html" },
447
+ quasi: { type: "TemplateLiteral", quasis, expressions: exprs },
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Helper to create a Babel TemplateElement node.
453
+ *
454
+ * @param {string} raw - The raw string content of the template part.
455
+ * @param {boolean} [tail=false] - Whether this is the last part of the template.
456
+ * @returns {import('@babel/types').TemplateElement} The template element node.
457
+ */
458
+ function tpl(raw, tail = false) {
459
+ return { type: "TemplateElement", value: { raw, cooked: raw }, tail }
460
+ }
461
+
462
+ function createImportSpecifier(name) {
463
+ return {
464
+ type: "ImportSpecifier",
465
+ imported: { type: "Identifier", name },
466
+ local: { type: "Identifier", name },
467
+ }
468
+ }
469
+
470
+ function isJsx(node) {
471
+ return (
472
+ node.type === "JSXElement" ||
473
+ node.type === "JSXFragment" ||
474
+ (node.type === "CallExpression" && node.callee.name === "html") // Already transformed
475
+ )
476
+ }