@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/.turbo/turbo-lint.log +3 -0
- package/.turbo/turbo-test.log +57 -0
- package/CHANGELOG.md +7 -0
- package/LICENSE +9 -0
- package/README.md +370 -0
- package/eslint.config.js +1 -0
- package/package.json +46 -0
- package/src/__snapshots__/index.test.js.snap +177 -0
- package/src/index.js +476 -0
- package/src/index.test.js +303 -0
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
|
+
}
|