@sprlab/wccompiler 0.13.0 → 0.15.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/README.md +998 -998
- package/adapters/angular-compiled/angular.d.ts +197 -197
- package/adapters/angular-compiled/angular.mjs +488 -488
- package/adapters/angular.js +54 -54
- package/adapters/angular.ts +630 -630
- package/adapters/react.js +114 -114
- package/adapters/vue.js +103 -103
- package/bin/wcc.js +412 -412
- package/bin/wcc.test.js +126 -126
- package/integrations/angular.js +73 -73
- package/integrations/react.js +859 -859
- package/integrations/vue.js +253 -253
- package/lib/codegen.js +2078 -2074
- package/lib/compiler-browser.js +545 -545
- package/lib/compiler.js +483 -479
- package/lib/config.js +71 -71
- package/lib/css-scoper.js +180 -180
- package/lib/dev-server.js +193 -193
- package/lib/import-resolver.js +160 -160
- package/lib/parser-extractors.js +1240 -1169
- package/lib/parser.js +273 -269
- package/lib/reactive-runtime.js +143 -143
- package/lib/sfc-parser.js +333 -333
- package/lib/template-normalizer.js +114 -114
- package/lib/tree-walker.js +1013 -1013
- package/lib/types.js +262 -262
- package/lib/wcc-runtime.js +68 -68
- package/package.json +85 -85
- package/types/wcc.d.ts +28 -28
- package/types/wcc.test.js +46 -46
package/integrations/react.js
CHANGED
|
@@ -1,859 +1,859 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* React Vite plugin for WCC custom elements.
|
|
3
|
-
* Transforms idiomatic React JSX slot patterns into WCC-compatible slot markup.
|
|
4
|
-
*
|
|
5
|
-
* @module @sprlab/wccompiler/integrations/react
|
|
6
|
-
*
|
|
7
|
-
* IMPORTANT: This file is for vite.config.js (Node.js context) ONLY.
|
|
8
|
-
* For browser-side hooks, import from '@sprlab/wccompiler/adapters/react'.
|
|
9
|
-
*
|
|
10
|
-
* @example vite.config.js
|
|
11
|
-
* ```js
|
|
12
|
-
* import { wccReactPlugin } from '@sprlab/wccompiler/integrations/react'
|
|
13
|
-
* export default { plugins: [wccReactPlugin()] }
|
|
14
|
-
* ```
|
|
15
|
-
*
|
|
16
|
-
* @example Component (browser — import hooks from adapters)
|
|
17
|
-
* ```jsx
|
|
18
|
-
* import { useWccEvent, useWccModel } from '@sprlab/wccompiler/adapters/react'
|
|
19
|
-
* ```
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { parse } from '@babel/parser'
|
|
23
|
-
import _traverse from '@babel/traverse'
|
|
24
|
-
import _generate from '@babel/generator'
|
|
25
|
-
const traverse = _traverse.default || _traverse
|
|
26
|
-
const generate = _generate.default || _generate
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* JSX attribute name to HTML attribute name mapping.
|
|
30
|
-
* React uses camelCase for some attributes that HTML uses lowercase.
|
|
31
|
-
* @type {Record<string, string>}
|
|
32
|
-
*/
|
|
33
|
-
const JSX_TO_HTML_ATTRS = {
|
|
34
|
-
className: 'class',
|
|
35
|
-
htmlFor: 'for',
|
|
36
|
-
tabIndex: 'tabindex',
|
|
37
|
-
readOnly: 'readonly',
|
|
38
|
-
maxLength: 'maxlength',
|
|
39
|
-
autoFocus: 'autofocus',
|
|
40
|
-
autoComplete: 'autocomplete'
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* HTML void elements that should not have a closing tag.
|
|
45
|
-
* @type {Set<string>}
|
|
46
|
-
*/
|
|
47
|
-
const VOID_ELEMENTS = new Set([
|
|
48
|
-
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
49
|
-
'link', 'meta', 'param', 'source', 'track', 'wbr'
|
|
50
|
-
])
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Serializes a Babel JSX AST node into an HTML string.
|
|
54
|
-
*
|
|
55
|
-
* Converts JSX attribute names to HTML equivalents, handles void elements,
|
|
56
|
-
* recursively serializes nested elements, and replaces parameter references
|
|
57
|
-
* with {%paramName%} tokens for scoped slot templates.
|
|
58
|
-
*
|
|
59
|
-
* @param {object} node - A Babel AST node (JSXElement, JSXFragment, JSXText, JSXExpressionContainer, etc.)
|
|
60
|
-
* @param {string[]} [paramNames] - Parameter names to replace with {%param%} tokens (for scoped slots)
|
|
61
|
-
* @param {Array<string>} [warnings] - Array to collect warning messages about unsupported expressions
|
|
62
|
-
* @returns {string} The serialized HTML string
|
|
63
|
-
*/
|
|
64
|
-
export function serializeJsxToHtml(node, paramNames, warnings) {
|
|
65
|
-
if (!node) return ''
|
|
66
|
-
|
|
67
|
-
switch (node.type) {
|
|
68
|
-
case 'JSXElement':
|
|
69
|
-
return serializeJsxElement(node, paramNames, warnings)
|
|
70
|
-
|
|
71
|
-
case 'JSXFragment':
|
|
72
|
-
return serializeJsxChildren(node.children, paramNames, warnings)
|
|
73
|
-
|
|
74
|
-
case 'JSXText':
|
|
75
|
-
return serializeJsxText(node)
|
|
76
|
-
|
|
77
|
-
case 'JSXExpressionContainer':
|
|
78
|
-
return serializeJsxExpression(node.expression, paramNames, warnings)
|
|
79
|
-
|
|
80
|
-
case 'StringLiteral':
|
|
81
|
-
return node.value
|
|
82
|
-
|
|
83
|
-
default:
|
|
84
|
-
return ''
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Serializes a JSXElement node to HTML.
|
|
90
|
-
* @param {object} node - JSXElement Babel AST node
|
|
91
|
-
* @param {string[]} [paramNames]
|
|
92
|
-
* @param {Array<string>} [warnings]
|
|
93
|
-
* @returns {string}
|
|
94
|
-
*/
|
|
95
|
-
function serializeJsxElement(node, paramNames, warnings) {
|
|
96
|
-
const opening = node.openingElement
|
|
97
|
-
const tagName = getJsxElementName(opening.name)
|
|
98
|
-
const attrs = serializeAttributes(opening.attributes, paramNames, warnings)
|
|
99
|
-
const isVoid = VOID_ELEMENTS.has(tagName)
|
|
100
|
-
|
|
101
|
-
if (isVoid) {
|
|
102
|
-
return `<${tagName}${attrs}>`
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const children = serializeJsxChildren(node.children, paramNames, warnings)
|
|
106
|
-
return `<${tagName}${attrs}>${children}</${tagName}>`
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Gets the tag name string from a JSX element name node.
|
|
111
|
-
* @param {object} nameNode - JSXIdentifier or JSXMemberExpression
|
|
112
|
-
* @returns {string}
|
|
113
|
-
*/
|
|
114
|
-
function getJsxElementName(nameNode) {
|
|
115
|
-
if (nameNode.type === 'JSXIdentifier') {
|
|
116
|
-
return nameNode.name
|
|
117
|
-
}
|
|
118
|
-
if (nameNode.type === 'JSXMemberExpression') {
|
|
119
|
-
return `${getJsxElementName(nameNode.object)}.${nameNode.property.name}`
|
|
120
|
-
}
|
|
121
|
-
if (nameNode.type === 'JSXNamespacedName') {
|
|
122
|
-
return `${nameNode.namespace.name}:${nameNode.name.name}`
|
|
123
|
-
}
|
|
124
|
-
return ''
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Serializes JSX attributes to an HTML attribute string.
|
|
129
|
-
* @param {Array<object>} attributes - Array of JSXAttribute or JSXSpreadAttribute nodes
|
|
130
|
-
* @param {string[]} [paramNames]
|
|
131
|
-
* @param {Array<string>} [warnings]
|
|
132
|
-
* @returns {string} Attribute string with leading space, or empty string
|
|
133
|
-
*/
|
|
134
|
-
function serializeAttributes(attributes, paramNames, warnings) {
|
|
135
|
-
if (!attributes || attributes.length === 0) return ''
|
|
136
|
-
|
|
137
|
-
const parts = []
|
|
138
|
-
for (const attr of attributes) {
|
|
139
|
-
if (attr.type === 'JSXSpreadAttribute') {
|
|
140
|
-
// Spread attributes can't be statically serialized
|
|
141
|
-
if (warnings) {
|
|
142
|
-
warnings.push(`Spread attribute cannot be statically serialized`)
|
|
143
|
-
}
|
|
144
|
-
continue
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
if (attr.type === 'JSXAttribute') {
|
|
148
|
-
const rawName = attr.name.type === 'JSXNamespacedName'
|
|
149
|
-
? `${attr.name.namespace.name}:${attr.name.name.name}`
|
|
150
|
-
: attr.name.name
|
|
151
|
-
const htmlName = JSX_TO_HTML_ATTRS[rawName] || rawName
|
|
152
|
-
|
|
153
|
-
if (attr.value === null || attr.value === undefined) {
|
|
154
|
-
// Boolean attribute (e.g., `disabled`)
|
|
155
|
-
parts.push(htmlName)
|
|
156
|
-
} else if (attr.value.type === 'StringLiteral') {
|
|
157
|
-
parts.push(`${htmlName}="${attr.value.value}"`)
|
|
158
|
-
} else if (attr.value.type === 'JSXExpressionContainer') {
|
|
159
|
-
const exprValue = serializeAttributeExpression(attr.value.expression, paramNames, warnings)
|
|
160
|
-
if (exprValue !== null) {
|
|
161
|
-
parts.push(`${htmlName}="${exprValue}"`)
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return parts.length > 0 ? ' ' + parts.join(' ') : ''
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Serializes an expression used as an attribute value.
|
|
172
|
-
* @param {object} expression - Babel AST expression node
|
|
173
|
-
* @param {string[]} [paramNames]
|
|
174
|
-
* @param {Array<string>} [warnings]
|
|
175
|
-
* @returns {string|null} The serialized value, or null if it can't be serialized
|
|
176
|
-
*/
|
|
177
|
-
function serializeAttributeExpression(expression, paramNames, warnings) {
|
|
178
|
-
if (!expression) return null
|
|
179
|
-
|
|
180
|
-
if (expression.type === 'StringLiteral') {
|
|
181
|
-
return expression.value
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (expression.type === 'NumericLiteral') {
|
|
185
|
-
return String(expression.value)
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (expression.type === 'BooleanLiteral') {
|
|
189
|
-
return String(expression.value)
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
if (expression.type === 'Identifier') {
|
|
193
|
-
if (paramNames && paramNames.includes(expression.name)) {
|
|
194
|
-
return `{%${expression.name}%}`
|
|
195
|
-
}
|
|
196
|
-
// Dynamic expression that can't be statically serialized
|
|
197
|
-
if (warnings) {
|
|
198
|
-
warnings.push(`Dynamic expression "{${expression.name}}" cannot be statically serialized`)
|
|
199
|
-
}
|
|
200
|
-
return null
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
if (expression.type === 'TemplateLiteral') {
|
|
204
|
-
return serializeTemplateLiteral(expression, paramNames, warnings)
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Other expressions can't be statically serialized
|
|
208
|
-
if (warnings) {
|
|
209
|
-
warnings.push(`Expression of type "${expression.type}" cannot be statically serialized`)
|
|
210
|
-
}
|
|
211
|
-
return null
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Serializes a template literal expression.
|
|
216
|
-
* @param {object} node - TemplateLiteral AST node
|
|
217
|
-
* @param {string[]} [paramNames]
|
|
218
|
-
* @param {Array<string>} [warnings]
|
|
219
|
-
* @returns {string|null}
|
|
220
|
-
*/
|
|
221
|
-
function serializeTemplateLiteral(node, paramNames, warnings) {
|
|
222
|
-
let result = ''
|
|
223
|
-
for (let i = 0; i < node.quasis.length; i++) {
|
|
224
|
-
result += node.quasis[i].value.raw
|
|
225
|
-
if (i < node.expressions.length) {
|
|
226
|
-
const expr = node.expressions[i]
|
|
227
|
-
if (expr.type === 'Identifier' && paramNames && paramNames.includes(expr.name)) {
|
|
228
|
-
result += `{%${expr.name}%}`
|
|
229
|
-
} else if (expr.type === 'StringLiteral') {
|
|
230
|
-
result += expr.value
|
|
231
|
-
} else if (expr.type === 'NumericLiteral') {
|
|
232
|
-
result += String(expr.value)
|
|
233
|
-
} else {
|
|
234
|
-
if (warnings) {
|
|
235
|
-
warnings.push(`Dynamic expression in template literal cannot be statically serialized`)
|
|
236
|
-
}
|
|
237
|
-
return null
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
return result
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Serializes an array of JSX children nodes.
|
|
246
|
-
* @param {Array<object>} children
|
|
247
|
-
* @param {string[]} [paramNames]
|
|
248
|
-
* @param {Array<string>} [warnings]
|
|
249
|
-
* @returns {string}
|
|
250
|
-
*/
|
|
251
|
-
function serializeJsxChildren(children, paramNames, warnings) {
|
|
252
|
-
if (!children || children.length === 0) return ''
|
|
253
|
-
return children.map(child => serializeJsxToHtml(child, paramNames, warnings)).join('')
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Serializes a JSXText node, handling whitespace similar to how React does.
|
|
258
|
-
* - Whitespace-only text nodes (just newlines/spaces between elements) → empty
|
|
259
|
-
* - Newlines with surrounding whitespace are collapsed to a single space
|
|
260
|
-
* - Inline spaces are preserved (they are significant content)
|
|
261
|
-
* @param {object} node - JSXText AST node
|
|
262
|
-
* @returns {string}
|
|
263
|
-
*/
|
|
264
|
-
function serializeJsxText(node) {
|
|
265
|
-
const text = node.value
|
|
266
|
-
// If the text is only whitespace (newlines, spaces, tabs), return empty
|
|
267
|
-
if (/^\s+$/.test(text)) return ''
|
|
268
|
-
// Collapse newlines and their surrounding whitespace to a single space
|
|
269
|
-
let result = text.replace(/[ \t]*\n[ \t]*/g, '\n')
|
|
270
|
-
// Split by newlines to handle multi-line text
|
|
271
|
-
const lines = result.split('\n')
|
|
272
|
-
// Trim empty leading/trailing lines but preserve inline content
|
|
273
|
-
let start = 0
|
|
274
|
-
while (start < lines.length && lines[start].trim() === '') start++
|
|
275
|
-
let end = lines.length - 1
|
|
276
|
-
while (end >= start && lines[end].trim() === '') end--
|
|
277
|
-
if (start > end) return ''
|
|
278
|
-
// Join remaining lines with a space (newlines become spaces in JSX)
|
|
279
|
-
return lines.slice(start, end + 1).map((line, i) => {
|
|
280
|
-
if (i === 0 && start > 0) return line.trimStart()
|
|
281
|
-
if (i === end - start && end < lines.length - 1) return line.trimEnd()
|
|
282
|
-
return line
|
|
283
|
-
}).join(' ')
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* Serializes a JSX expression container's expression.
|
|
288
|
-
* @param {object} expression - The expression inside { }
|
|
289
|
-
* @param {string[]} [paramNames]
|
|
290
|
-
* @param {Array<string>} [warnings]
|
|
291
|
-
* @returns {string}
|
|
292
|
-
*/
|
|
293
|
-
function serializeJsxExpression(expression, paramNames, warnings) {
|
|
294
|
-
if (!expression) return ''
|
|
295
|
-
|
|
296
|
-
// JSXEmptyExpression (e.g., {/* comment */})
|
|
297
|
-
if (expression.type === 'JSXEmptyExpression') return ''
|
|
298
|
-
|
|
299
|
-
// Identifier — check if it's a param reference
|
|
300
|
-
if (expression.type === 'Identifier') {
|
|
301
|
-
if (paramNames && paramNames.includes(expression.name)) {
|
|
302
|
-
return `{%${expression.name}%}`
|
|
303
|
-
}
|
|
304
|
-
// Dynamic expression
|
|
305
|
-
if (warnings) {
|
|
306
|
-
warnings.push(`Dynamic expression "{${expression.name}}" cannot be statically serialized`)
|
|
307
|
-
}
|
|
308
|
-
return ''
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// String literal — inline the value
|
|
312
|
-
if (expression.type === 'StringLiteral') {
|
|
313
|
-
return expression.value
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// Numeric literal — inline the value
|
|
317
|
-
if (expression.type === 'NumericLiteral') {
|
|
318
|
-
return String(expression.value)
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// Template literal
|
|
322
|
-
if (expression.type === 'TemplateLiteral') {
|
|
323
|
-
const result = serializeTemplateLiteral(expression, paramNames, warnings)
|
|
324
|
-
return result !== null ? result : ''
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// Other expressions can't be statically serialized
|
|
328
|
-
if (warnings) {
|
|
329
|
-
warnings.push(`Expression of type "${expression.type}" cannot be statically serialized`)
|
|
330
|
-
}
|
|
331
|
-
return ''
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* Reserved prop names that should always pass through without slot transformation.
|
|
337
|
-
* @type {Set<string>}
|
|
338
|
-
*/
|
|
339
|
-
const RESERVED_PROPS = new Set([
|
|
340
|
-
'children', 'key', 'ref', 'className', 'id', 'style', 'slot', 'is', 'dangerouslySetInnerHTML'
|
|
341
|
-
])
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* Classifies a prop on a custom element to determine how it should be handled.
|
|
345
|
-
*
|
|
346
|
-
* Classification rules are applied in priority order:
|
|
347
|
-
* 1. Reserved props → passthrough
|
|
348
|
-
* 2. Event handlers (on + uppercase) → passthrough
|
|
349
|
-
* 3. data-/aria- prefixed props → passthrough
|
|
350
|
-
* 4. Props in user's exclude list → passthrough
|
|
351
|
-
* 5. Render props (render + uppercase + ArrowFunctionExpression) → renderProp
|
|
352
|
-
* 6. Non-JSX/non-string values → passthrough
|
|
353
|
-
* 7. Named slot prop (respecting slotProps option) → slot
|
|
354
|
-
*
|
|
355
|
-
* @param {string} propName - The prop name
|
|
356
|
-
* @param {object} propValue - The Babel AST node for the prop value
|
|
357
|
-
* @param {object} options - Plugin options
|
|
358
|
-
* @param {string[]} [options.exclude] - Prop names to never treat as slots
|
|
359
|
-
* @param {string[]} [options.slotProps] - Explicit list of prop names to treat as named slots
|
|
360
|
-
* @returns {{ type: 'slot', name: string, value: object } | { type: 'renderProp', slotName: string, params: string[], body: object } | { type: 'passthrough' }}
|
|
361
|
-
*/
|
|
362
|
-
export function classifyProp(propName, propValue, options = {}) {
|
|
363
|
-
const { exclude = [], slotProps } = options
|
|
364
|
-
|
|
365
|
-
// Rule 1: Reserved props always pass through
|
|
366
|
-
if (RESERVED_PROPS.has(propName)) {
|
|
367
|
-
return { type: 'passthrough' }
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// Rule 2: Event handlers (on + uppercase letter) always pass through
|
|
371
|
-
if (propName.length > 2 && propName[0] === 'o' && propName[1] === 'n' && propName[2] >= 'A' && propName[2] <= 'Z') {
|
|
372
|
-
return { type: 'passthrough' }
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// Rule 3: data- and aria- prefixed props always pass through
|
|
376
|
-
if (propName.startsWith('data-') || propName.startsWith('aria-')) {
|
|
377
|
-
return { type: 'passthrough' }
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// Rule 4: Props in the user's exclude list always pass through
|
|
381
|
-
if (exclude.includes(propName)) {
|
|
382
|
-
return { type: 'passthrough' }
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// Rule 5: Render props (render + uppercase AND ArrowFunctionExpression value)
|
|
386
|
-
if (/^render[A-Z]/.test(propName) && propValue && propValue.type === 'ArrowFunctionExpression') {
|
|
387
|
-
const slotName = propName.slice(6)
|
|
388
|
-
const derivedSlotName = slotName[0].toLowerCase() + slotName.slice(1)
|
|
389
|
-
const params = (propValue.params || []).map(p => p.name || (p.type === 'Identifier' ? p.name : ''))
|
|
390
|
-
return {
|
|
391
|
-
type: 'renderProp',
|
|
392
|
-
slotName: derivedSlotName,
|
|
393
|
-
params,
|
|
394
|
-
body: propValue.body
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// Rule 6: Non-JSX/non-string values always pass through
|
|
399
|
-
// We need to check the actual value type. In JSX, prop values come wrapped in
|
|
400
|
-
// JSXExpressionContainer. The propValue here is the raw value node.
|
|
401
|
-
if (propValue) {
|
|
402
|
-
const valueType = propValue.type
|
|
403
|
-
// String literals are slot-eligible
|
|
404
|
-
if (valueType === 'StringLiteral') {
|
|
405
|
-
// Fall through to Rule 7
|
|
406
|
-
}
|
|
407
|
-
// JSX expressions are slot-eligible
|
|
408
|
-
else if (valueType === 'JSXElement' || valueType === 'JSXFragment') {
|
|
409
|
-
// Fall through to Rule 7
|
|
410
|
-
}
|
|
411
|
-
// Everything else (NumericLiteral, BooleanLiteral, Identifier, ArrayExpression,
|
|
412
|
-
// ObjectExpression, CallExpression, etc.) passes through
|
|
413
|
-
else {
|
|
414
|
-
return { type: 'passthrough' }
|
|
415
|
-
}
|
|
416
|
-
} else {
|
|
417
|
-
// No value (boolean shorthand like `disabled`) passes through
|
|
418
|
-
return { type: 'passthrough' }
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// Rule 7: Named slot prop (respecting slotProps option if set)
|
|
422
|
-
if (slotProps) {
|
|
423
|
-
// When slotProps is set, only props in that list become slots
|
|
424
|
-
if (slotProps.includes(propName)) {
|
|
425
|
-
return { type: 'slot', name: propName, value: propValue }
|
|
426
|
-
}
|
|
427
|
-
// Not in the explicit list → passthrough
|
|
428
|
-
return { type: 'passthrough' }
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
// Default heuristic: any remaining prop with JSX or string value is a slot
|
|
432
|
-
return { type: 'slot', name: propName, value: propValue }
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
/**
|
|
437
|
-
* Derives a slot name from a render prop name by stripping the `render` prefix
|
|
438
|
-
* and lowercasing the first character of the remaining name.
|
|
439
|
-
*
|
|
440
|
-
* @param {string} renderPropName - The render prop name (e.g., 'renderStats', 'renderItemRow')
|
|
441
|
-
* @returns {string} The derived slot name (e.g., 'stats', 'itemRow')
|
|
442
|
-
*
|
|
443
|
-
* @example
|
|
444
|
-
* deriveSlotName('renderStats') // → 'stats'
|
|
445
|
-
* deriveSlotName('renderItemRow') // → 'itemRow'
|
|
446
|
-
*/
|
|
447
|
-
export function deriveSlotName(renderPropName) {
|
|
448
|
-
const withoutPrefix = renderPropName.slice(6) // strip "render"
|
|
449
|
-
return withoutPrefix[0].toLowerCase() + withoutPrefix.slice(1)
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
/**
|
|
454
|
-
* Creates a Babel AST JSXElement node representing `<div slot="name">content</div>`.
|
|
455
|
-
* Used for named slot props whose value is a JSX expression.
|
|
456
|
-
*
|
|
457
|
-
* @param {string} slotName - The slot name to use in the `slot` attribute
|
|
458
|
-
* @param {object} content - A Babel JSX AST node (JSXElement, JSXFragment, etc.) to wrap as children
|
|
459
|
-
* @returns {object} A Babel JSXElement AST node
|
|
460
|
-
*/
|
|
461
|
-
export function generateNamedSlotElement(slotName, content) {
|
|
462
|
-
const slotAttr = {
|
|
463
|
-
type: 'JSXAttribute',
|
|
464
|
-
name: { type: 'JSXIdentifier', name: 'slot' },
|
|
465
|
-
value: { type: 'StringLiteral', value: slotName }
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
const openingElement = {
|
|
469
|
-
type: 'JSXOpeningElement',
|
|
470
|
-
name: { type: 'JSXIdentifier', name: 'div' },
|
|
471
|
-
attributes: [slotAttr],
|
|
472
|
-
selfClosing: false
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
const closingElement = {
|
|
476
|
-
type: 'JSXClosingElement',
|
|
477
|
-
name: { type: 'JSXIdentifier', name: 'div' }
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
// Wrap content in a JSXExpressionContainer if it's not already a JSX child type
|
|
481
|
-
let children
|
|
482
|
-
if (content.type === 'JSXElement' || content.type === 'JSXFragment' || content.type === 'JSXText' || content.type === 'JSXExpressionContainer') {
|
|
483
|
-
children = [content]
|
|
484
|
-
} else {
|
|
485
|
-
children = [{ type: 'JSXExpressionContainer', expression: content }]
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
return {
|
|
489
|
-
type: 'JSXElement',
|
|
490
|
-
openingElement,
|
|
491
|
-
closingElement,
|
|
492
|
-
children
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
/**
|
|
498
|
-
* Creates a Babel AST JSXElement node representing `<span slot="name">text</span>`.
|
|
499
|
-
* Used for named slot props whose value is a string literal.
|
|
500
|
-
*
|
|
501
|
-
* @param {string} slotName - The slot name to use in the `slot` attribute
|
|
502
|
-
* @param {string} text - The string text content
|
|
503
|
-
* @returns {object} A Babel JSXElement AST node
|
|
504
|
-
*/
|
|
505
|
-
export function generateStringSlotElement(slotName, text) {
|
|
506
|
-
const slotAttr = {
|
|
507
|
-
type: 'JSXAttribute',
|
|
508
|
-
name: { type: 'JSXIdentifier', name: 'slot' },
|
|
509
|
-
value: { type: 'StringLiteral', value: slotName }
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
const openingElement = {
|
|
513
|
-
type: 'JSXOpeningElement',
|
|
514
|
-
name: { type: 'JSXIdentifier', name: 'span' },
|
|
515
|
-
attributes: [slotAttr],
|
|
516
|
-
selfClosing: false
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
const closingElement = {
|
|
520
|
-
type: 'JSXClosingElement',
|
|
521
|
-
name: { type: 'JSXIdentifier', name: 'span' }
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
return {
|
|
525
|
-
type: 'JSXElement',
|
|
526
|
-
openingElement,
|
|
527
|
-
closingElement,
|
|
528
|
-
children: [{ type: 'JSXText', value: text }]
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
/**
|
|
534
|
-
* Creates a Babel AST JSXElement node representing:
|
|
535
|
-
* `<div slot="name" slot-props="param1,param2" dangerouslySetInnerHTML={{__html: `...`}}></div>`
|
|
536
|
-
*
|
|
537
|
-
* Used for render prop (scoped slot) transformation. The body JSX is serialized to an HTML
|
|
538
|
-
* template string with {%param%} tokens using `serializeJsxToHtml`.
|
|
539
|
-
*
|
|
540
|
-
* @param {string} slotName - The derived slot name
|
|
541
|
-
* @param {string[]} params - The arrow function parameter names
|
|
542
|
-
* @param {object} body - The Babel JSX AST node for the arrow function body
|
|
543
|
-
* @returns {object} A Babel JSXElement AST node
|
|
544
|
-
*/
|
|
545
|
-
export function generateScopedSlotElement(slotName, params, body) {
|
|
546
|
-
// Serialize the body to an HTML template string with {%param%} tokens
|
|
547
|
-
const htmlTemplate = serializeJsxToHtml(body, params)
|
|
548
|
-
|
|
549
|
-
// slot="name" attribute
|
|
550
|
-
const slotAttr = {
|
|
551
|
-
type: 'JSXAttribute',
|
|
552
|
-
name: { type: 'JSXIdentifier', name: 'slot' },
|
|
553
|
-
value: { type: 'StringLiteral', value: slotName }
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// slot-props="param1,param2" attribute
|
|
557
|
-
const slotPropsAttr = {
|
|
558
|
-
type: 'JSXAttribute',
|
|
559
|
-
name: { type: 'JSXIdentifier', name: 'slot-props' },
|
|
560
|
-
value: { type: 'StringLiteral', value: params.join(',') }
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
// dangerouslySetInnerHTML={{__html: `...`}} attribute
|
|
564
|
-
const dangerouslyAttr = {
|
|
565
|
-
type: 'JSXAttribute',
|
|
566
|
-
name: { type: 'JSXIdentifier', name: 'dangerouslySetInnerHTML' },
|
|
567
|
-
value: {
|
|
568
|
-
type: 'JSXExpressionContainer',
|
|
569
|
-
expression: {
|
|
570
|
-
type: 'ObjectExpression',
|
|
571
|
-
properties: [{
|
|
572
|
-
type: 'ObjectProperty',
|
|
573
|
-
key: { type: 'Identifier', name: '__html' },
|
|
574
|
-
value: { type: 'TemplateLiteral', quasis: [{ type: 'TemplateElement', value: { raw: htmlTemplate, cooked: htmlTemplate } }], expressions: [] },
|
|
575
|
-
computed: false,
|
|
576
|
-
shorthand: false
|
|
577
|
-
}]
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
const openingElement = {
|
|
583
|
-
type: 'JSXOpeningElement',
|
|
584
|
-
name: { type: 'JSXIdentifier', name: 'div' },
|
|
585
|
-
attributes: [slotAttr, slotPropsAttr, dangerouslyAttr],
|
|
586
|
-
selfClosing: false
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
const closingElement = {
|
|
590
|
-
type: 'JSXClosingElement',
|
|
591
|
-
name: { type: 'JSXIdentifier', name: 'div' }
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
return {
|
|
595
|
-
type: 'JSXElement',
|
|
596
|
-
openingElement,
|
|
597
|
-
closingElement,
|
|
598
|
-
children: []
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
/**
|
|
604
|
-
* Vite plugin that transforms idiomatic React JSX slot patterns into
|
|
605
|
-
* WCC-compatible slot markup at build time.
|
|
606
|
-
*
|
|
607
|
-
* Runs with `enforce: 'pre'` so it processes JSX before @vitejs/plugin-react.
|
|
608
|
-
*
|
|
609
|
-
* @param {Object} [options]
|
|
610
|
-
* @param {string} [options.prefix] - Tag prefix filter (e.g., 'wcc-'). If set, only elements starting with this prefix are processed.
|
|
611
|
-
* @param {string[]} [options.exclude] - Prop names to never treat as slots.
|
|
612
|
-
* @param {string[]} [options.slotProps] - Explicit list of prop names to treat as named slots (overrides default heuristic).
|
|
613
|
-
* @returns {import('vite').Plugin}
|
|
614
|
-
*/
|
|
615
|
-
export function wccReactPlugin(options = {}) {
|
|
616
|
-
const { prefix, exclude = [], slotProps } = options
|
|
617
|
-
|
|
618
|
-
return {
|
|
619
|
-
name: 'vite-plugin-wcc-react-slots',
|
|
620
|
-
enforce: 'pre',
|
|
621
|
-
transform(code, id) {
|
|
622
|
-
// Only process .jsx and .tsx files
|
|
623
|
-
if (!/\.[jt]sx$/.test(id)) {
|
|
624
|
-
return null
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
let ast
|
|
628
|
-
try {
|
|
629
|
-
ast = parse(code, {
|
|
630
|
-
sourceType: 'module',
|
|
631
|
-
plugins: ['jsx', 'typescript']
|
|
632
|
-
})
|
|
633
|
-
} catch (e) {
|
|
634
|
-
this.warn(`[wcc-react] ${id} — failed to parse: ${e.message}`)
|
|
635
|
-
return null
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
let transformed = false
|
|
639
|
-
const pluginCtx = this
|
|
640
|
-
|
|
641
|
-
traverse(ast, {
|
|
642
|
-
JSXElement(path) {
|
|
643
|
-
const openingElement = path.node.openingElement
|
|
644
|
-
const nameNode = openingElement.name
|
|
645
|
-
|
|
646
|
-
// ── Compound component transform ──
|
|
647
|
-
// <WccCard.Header>children</WccCard.Header> → <div slot="header" style={{display:'contents'}}>children</div>
|
|
648
|
-
// <WccCard.Stats>{(likes) => <span>{likes}</span>}</WccCard.Stats> → scoped slot div
|
|
649
|
-
if (nameNode.type === 'JSXMemberExpression') {
|
|
650
|
-
const objectName = nameNode.object?.name // e.g., 'WccCard'
|
|
651
|
-
const propName = nameNode.property?.name // e.g., 'Header'
|
|
652
|
-
if (!objectName || !propName) return
|
|
653
|
-
|
|
654
|
-
// Convert PascalCase object to kebab-case and check if it's a custom element
|
|
655
|
-
const kebab = objectName.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')
|
|
656
|
-
if (!kebab.includes('-')) return
|
|
657
|
-
if (prefix && !kebab.startsWith(prefix)) return
|
|
658
|
-
|
|
659
|
-
// Derive slot name: Header → header, FooterNav → footerNav (lcfirst)
|
|
660
|
-
const slotName = propName[0].toLowerCase() + propName.slice(1)
|
|
661
|
-
|
|
662
|
-
// Check if children is a function (scoped slot / render prop pattern)
|
|
663
|
-
const children = path.node.children
|
|
664
|
-
const isScopedSlot = children.length === 1
|
|
665
|
-
&& children[0].type === 'JSXExpressionContainer'
|
|
666
|
-
&& children[0].expression.type === 'ArrowFunctionExpression'
|
|
667
|
-
|
|
668
|
-
if (isScopedSlot) {
|
|
669
|
-
// Scoped slot: <WccCard.Stats>{(likes) => <span>{likes}</span>}</WccCard.Stats>
|
|
670
|
-
const arrowFn = children[0].expression
|
|
671
|
-
const params = (arrowFn.params || []).map(p => p.name || '')
|
|
672
|
-
const body = arrowFn.body
|
|
673
|
-
|
|
674
|
-
// Warn on unsupported expressions
|
|
675
|
-
const renderWarnings = []
|
|
676
|
-
serializeJsxToHtml(body, params, renderWarnings)
|
|
677
|
-
if (renderWarnings.length > 0) {
|
|
678
|
-
pluginCtx.warn(`[wcc-react] ${id} — ${objectName}.${propName}: ${renderWarnings[0]}`)
|
|
679
|
-
return
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
// Replace with scoped slot element
|
|
683
|
-
const scopedEl = generateScopedSlotElement(slotName, params, body)
|
|
684
|
-
path.replaceWith(scopedEl)
|
|
685
|
-
transformed = true
|
|
686
|
-
} else {
|
|
687
|
-
// Named slot: <WccCard.Header><strong>Title</strong></WccCard.Header>
|
|
688
|
-
// → <div slot="header" style={{display:'contents'}}>children</div>
|
|
689
|
-
const slotAttr = {
|
|
690
|
-
type: 'JSXAttribute',
|
|
691
|
-
name: { type: 'JSXIdentifier', name: 'slot' },
|
|
692
|
-
value: { type: 'StringLiteral', value: slotName }
|
|
693
|
-
}
|
|
694
|
-
const styleAttr = {
|
|
695
|
-
type: 'JSXAttribute',
|
|
696
|
-
name: { type: 'JSXIdentifier', name: 'style' },
|
|
697
|
-
value: {
|
|
698
|
-
type: 'JSXExpressionContainer',
|
|
699
|
-
expression: {
|
|
700
|
-
type: 'ObjectExpression',
|
|
701
|
-
properties: [{
|
|
702
|
-
type: 'ObjectProperty',
|
|
703
|
-
key: { type: 'Identifier', name: 'display' },
|
|
704
|
-
value: { type: 'StringLiteral', value: 'contents' },
|
|
705
|
-
computed: false,
|
|
706
|
-
shorthand: false
|
|
707
|
-
}]
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
openingElement.name = { type: 'JSXIdentifier', name: 'div' }
|
|
713
|
-
openingElement.attributes = [...openingElement.attributes, slotAttr, styleAttr]
|
|
714
|
-
if (path.node.closingElement) {
|
|
715
|
-
path.node.closingElement.name = { type: 'JSXIdentifier', name: 'div' }
|
|
716
|
-
}
|
|
717
|
-
transformed = true
|
|
718
|
-
}
|
|
719
|
-
return
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
// ── PascalCase custom element transform ──
|
|
723
|
-
// <WccCard> → <wcc-card> (only if it maps to a hyphenated tag)
|
|
724
|
-
if (nameNode.type === 'JSXIdentifier' && /^[A-Z]/.test(nameNode.name)) {
|
|
725
|
-
const kebab = nameNode.name.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')
|
|
726
|
-
if (!kebab.includes('-')) return
|
|
727
|
-
if (prefix && !kebab.startsWith(prefix)) return
|
|
728
|
-
|
|
729
|
-
// Rewrite tag name to kebab-case
|
|
730
|
-
openingElement.name = { type: 'JSXIdentifier', name: kebab }
|
|
731
|
-
if (path.node.closingElement) {
|
|
732
|
-
path.node.closingElement.name = { type: 'JSXIdentifier', name: kebab }
|
|
733
|
-
}
|
|
734
|
-
transformed = true
|
|
735
|
-
// Fall through to process props on this element
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
// Only process elements with hyphenated tag names (custom elements)
|
|
739
|
-
const currentName = openingElement.name
|
|
740
|
-
if (currentName.type !== 'JSXIdentifier') return
|
|
741
|
-
const tagName = currentName.name
|
|
742
|
-
if (!tagName.includes('-')) return
|
|
743
|
-
|
|
744
|
-
// Apply prefix filtering if set
|
|
745
|
-
if (prefix && !tagName.startsWith(prefix)) return
|
|
746
|
-
|
|
747
|
-
const slotChildren = []
|
|
748
|
-
const remainingAttributes = []
|
|
749
|
-
|
|
750
|
-
for (const attr of openingElement.attributes) {
|
|
751
|
-
// Skip spread attributes — leave them unchanged
|
|
752
|
-
if (attr.type !== 'JSXAttribute') {
|
|
753
|
-
remainingAttributes.push(attr)
|
|
754
|
-
continue
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
const propName = attr.name.type === 'JSXNamespacedName'
|
|
758
|
-
? `${attr.name.namespace.name}:${attr.name.name.name}`
|
|
759
|
-
: attr.name.name
|
|
760
|
-
|
|
761
|
-
// Get the prop value — unwrap JSXExpressionContainer
|
|
762
|
-
let propValue = attr.value
|
|
763
|
-
if (propValue && propValue.type === 'JSXExpressionContainer') {
|
|
764
|
-
propValue = propValue.expression
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
// Task 7.2: Warn on invalid render prop values (non-arrow-function)
|
|
768
|
-
if (/^render[A-Z]/.test(propName) && propValue && propValue.type !== 'ArrowFunctionExpression' && propValue.type !== 'StringLiteral') {
|
|
769
|
-
pluginCtx.warn(`[wcc-react] ${id} — ${propName}: expected ArrowFunctionExpression, got ${propValue.type}`)
|
|
770
|
-
remainingAttributes.push(attr)
|
|
771
|
-
continue
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
const classification = classifyProp(propName, propValue, { exclude, slotProps })
|
|
775
|
-
|
|
776
|
-
if (classification.type === 'slot') {
|
|
777
|
-
// Task 7.4: Warn on dynamic expressions in named slot props — leave prop unchanged
|
|
778
|
-
if (classification.value.type === 'JSXElement' || classification.value.type === 'JSXFragment') {
|
|
779
|
-
const slotWarnings = []
|
|
780
|
-
serializeJsxToHtml(classification.value, [], slotWarnings)
|
|
781
|
-
if (slotWarnings.length > 0) {
|
|
782
|
-
pluginCtx.warn(`[wcc-react] ${id} — ${propName}: ${slotWarnings[0]}`)
|
|
783
|
-
remainingAttributes.push(attr)
|
|
784
|
-
continue
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
// Generate slot child element
|
|
788
|
-
if (classification.value.type === 'StringLiteral') {
|
|
789
|
-
slotChildren.push(generateStringSlotElement(classification.name, classification.value.value))
|
|
790
|
-
} else {
|
|
791
|
-
slotChildren.push(generateNamedSlotElement(classification.name, classification.value))
|
|
792
|
-
}
|
|
793
|
-
transformed = true
|
|
794
|
-
} else if (classification.type === 'renderProp') {
|
|
795
|
-
// Task 7.3: Warn on unsupported expressions in render prop bodies — leave prop unchanged
|
|
796
|
-
const renderWarnings = []
|
|
797
|
-
serializeJsxToHtml(classification.body, classification.params, renderWarnings)
|
|
798
|
-
if (renderWarnings.length > 0) {
|
|
799
|
-
pluginCtx.warn(`[wcc-react] ${id} — ${propName}: ${renderWarnings[0]}`)
|
|
800
|
-
remainingAttributes.push(attr)
|
|
801
|
-
continue
|
|
802
|
-
}
|
|
803
|
-
// Generate scoped slot element
|
|
804
|
-
slotChildren.push(generateScopedSlotElement(
|
|
805
|
-
classification.slotName,
|
|
806
|
-
classification.params,
|
|
807
|
-
classification.body
|
|
808
|
-
))
|
|
809
|
-
transformed = true
|
|
810
|
-
} else {
|
|
811
|
-
// passthrough — keep the attribute
|
|
812
|
-
remainingAttributes.push(attr)
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
if (slotChildren.length > 0) {
|
|
817
|
-
// Remove transformed slot props from the element's attributes
|
|
818
|
-
openingElement.attributes = remainingAttributes
|
|
819
|
-
|
|
820
|
-
// If element was self-closing, convert to open/close pair
|
|
821
|
-
if (openingElement.selfClosing) {
|
|
822
|
-
openingElement.selfClosing = false
|
|
823
|
-
path.node.closingElement = { type: 'JSXClosingElement', name: { ...nameNode } }
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
// Append generated slot elements after existing children
|
|
827
|
-
path.node.children = [...path.node.children, ...slotChildren]
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
})
|
|
831
|
-
|
|
832
|
-
if (!transformed) {
|
|
833
|
-
return null
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
const result = generate(ast, { sourceMaps: true, sourceFileName: id }, code)
|
|
837
|
-
return { code: result.code, map: result.map }
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
/**
|
|
844
|
-
* @deprecated Use the CLI-generated stubs instead (dist/wcc-react.js).
|
|
845
|
-
* The `wcc build` command now auto-generates importable stubs with types.
|
|
846
|
-
* This virtual module plugin is kept for backward compatibility but will be removed.
|
|
847
|
-
*
|
|
848
|
-
* Migration:
|
|
849
|
-
* Before: import { WccCard } from '@wcc/react' (virtual module)
|
|
850
|
-
* After: import { WccCard } from './dist/wcc-react' (real file, tree-shakeable)
|
|
851
|
-
*/
|
|
852
|
-
export function wccReactComponents(options = {}) {
|
|
853
|
-
return {
|
|
854
|
-
name: 'vite-plugin-wcc-react-components-deprecated',
|
|
855
|
-
buildStart() {
|
|
856
|
-
this.warn('[wcc] wccReactComponents() is deprecated. Use the CLI-generated stubs from dist/wcc-react.js instead.')
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* React Vite plugin for WCC custom elements.
|
|
3
|
+
* Transforms idiomatic React JSX slot patterns into WCC-compatible slot markup.
|
|
4
|
+
*
|
|
5
|
+
* @module @sprlab/wccompiler/integrations/react
|
|
6
|
+
*
|
|
7
|
+
* IMPORTANT: This file is for vite.config.js (Node.js context) ONLY.
|
|
8
|
+
* For browser-side hooks, import from '@sprlab/wccompiler/adapters/react'.
|
|
9
|
+
*
|
|
10
|
+
* @example vite.config.js
|
|
11
|
+
* ```js
|
|
12
|
+
* import { wccReactPlugin } from '@sprlab/wccompiler/integrations/react'
|
|
13
|
+
* export default { plugins: [wccReactPlugin()] }
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* @example Component (browser — import hooks from adapters)
|
|
17
|
+
* ```jsx
|
|
18
|
+
* import { useWccEvent, useWccModel } from '@sprlab/wccompiler/adapters/react'
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { parse } from '@babel/parser'
|
|
23
|
+
import _traverse from '@babel/traverse'
|
|
24
|
+
import _generate from '@babel/generator'
|
|
25
|
+
const traverse = _traverse.default || _traverse
|
|
26
|
+
const generate = _generate.default || _generate
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* JSX attribute name to HTML attribute name mapping.
|
|
30
|
+
* React uses camelCase for some attributes that HTML uses lowercase.
|
|
31
|
+
* @type {Record<string, string>}
|
|
32
|
+
*/
|
|
33
|
+
const JSX_TO_HTML_ATTRS = {
|
|
34
|
+
className: 'class',
|
|
35
|
+
htmlFor: 'for',
|
|
36
|
+
tabIndex: 'tabindex',
|
|
37
|
+
readOnly: 'readonly',
|
|
38
|
+
maxLength: 'maxlength',
|
|
39
|
+
autoFocus: 'autofocus',
|
|
40
|
+
autoComplete: 'autocomplete'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* HTML void elements that should not have a closing tag.
|
|
45
|
+
* @type {Set<string>}
|
|
46
|
+
*/
|
|
47
|
+
const VOID_ELEMENTS = new Set([
|
|
48
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
49
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr'
|
|
50
|
+
])
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Serializes a Babel JSX AST node into an HTML string.
|
|
54
|
+
*
|
|
55
|
+
* Converts JSX attribute names to HTML equivalents, handles void elements,
|
|
56
|
+
* recursively serializes nested elements, and replaces parameter references
|
|
57
|
+
* with {%paramName%} tokens for scoped slot templates.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} node - A Babel AST node (JSXElement, JSXFragment, JSXText, JSXExpressionContainer, etc.)
|
|
60
|
+
* @param {string[]} [paramNames] - Parameter names to replace with {%param%} tokens (for scoped slots)
|
|
61
|
+
* @param {Array<string>} [warnings] - Array to collect warning messages about unsupported expressions
|
|
62
|
+
* @returns {string} The serialized HTML string
|
|
63
|
+
*/
|
|
64
|
+
export function serializeJsxToHtml(node, paramNames, warnings) {
|
|
65
|
+
if (!node) return ''
|
|
66
|
+
|
|
67
|
+
switch (node.type) {
|
|
68
|
+
case 'JSXElement':
|
|
69
|
+
return serializeJsxElement(node, paramNames, warnings)
|
|
70
|
+
|
|
71
|
+
case 'JSXFragment':
|
|
72
|
+
return serializeJsxChildren(node.children, paramNames, warnings)
|
|
73
|
+
|
|
74
|
+
case 'JSXText':
|
|
75
|
+
return serializeJsxText(node)
|
|
76
|
+
|
|
77
|
+
case 'JSXExpressionContainer':
|
|
78
|
+
return serializeJsxExpression(node.expression, paramNames, warnings)
|
|
79
|
+
|
|
80
|
+
case 'StringLiteral':
|
|
81
|
+
return node.value
|
|
82
|
+
|
|
83
|
+
default:
|
|
84
|
+
return ''
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Serializes a JSXElement node to HTML.
|
|
90
|
+
* @param {object} node - JSXElement Babel AST node
|
|
91
|
+
* @param {string[]} [paramNames]
|
|
92
|
+
* @param {Array<string>} [warnings]
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
function serializeJsxElement(node, paramNames, warnings) {
|
|
96
|
+
const opening = node.openingElement
|
|
97
|
+
const tagName = getJsxElementName(opening.name)
|
|
98
|
+
const attrs = serializeAttributes(opening.attributes, paramNames, warnings)
|
|
99
|
+
const isVoid = VOID_ELEMENTS.has(tagName)
|
|
100
|
+
|
|
101
|
+
if (isVoid) {
|
|
102
|
+
return `<${tagName}${attrs}>`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const children = serializeJsxChildren(node.children, paramNames, warnings)
|
|
106
|
+
return `<${tagName}${attrs}>${children}</${tagName}>`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Gets the tag name string from a JSX element name node.
|
|
111
|
+
* @param {object} nameNode - JSXIdentifier or JSXMemberExpression
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
function getJsxElementName(nameNode) {
|
|
115
|
+
if (nameNode.type === 'JSXIdentifier') {
|
|
116
|
+
return nameNode.name
|
|
117
|
+
}
|
|
118
|
+
if (nameNode.type === 'JSXMemberExpression') {
|
|
119
|
+
return `${getJsxElementName(nameNode.object)}.${nameNode.property.name}`
|
|
120
|
+
}
|
|
121
|
+
if (nameNode.type === 'JSXNamespacedName') {
|
|
122
|
+
return `${nameNode.namespace.name}:${nameNode.name.name}`
|
|
123
|
+
}
|
|
124
|
+
return ''
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Serializes JSX attributes to an HTML attribute string.
|
|
129
|
+
* @param {Array<object>} attributes - Array of JSXAttribute or JSXSpreadAttribute nodes
|
|
130
|
+
* @param {string[]} [paramNames]
|
|
131
|
+
* @param {Array<string>} [warnings]
|
|
132
|
+
* @returns {string} Attribute string with leading space, or empty string
|
|
133
|
+
*/
|
|
134
|
+
function serializeAttributes(attributes, paramNames, warnings) {
|
|
135
|
+
if (!attributes || attributes.length === 0) return ''
|
|
136
|
+
|
|
137
|
+
const parts = []
|
|
138
|
+
for (const attr of attributes) {
|
|
139
|
+
if (attr.type === 'JSXSpreadAttribute') {
|
|
140
|
+
// Spread attributes can't be statically serialized
|
|
141
|
+
if (warnings) {
|
|
142
|
+
warnings.push(`Spread attribute cannot be statically serialized`)
|
|
143
|
+
}
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (attr.type === 'JSXAttribute') {
|
|
148
|
+
const rawName = attr.name.type === 'JSXNamespacedName'
|
|
149
|
+
? `${attr.name.namespace.name}:${attr.name.name.name}`
|
|
150
|
+
: attr.name.name
|
|
151
|
+
const htmlName = JSX_TO_HTML_ATTRS[rawName] || rawName
|
|
152
|
+
|
|
153
|
+
if (attr.value === null || attr.value === undefined) {
|
|
154
|
+
// Boolean attribute (e.g., `disabled`)
|
|
155
|
+
parts.push(htmlName)
|
|
156
|
+
} else if (attr.value.type === 'StringLiteral') {
|
|
157
|
+
parts.push(`${htmlName}="${attr.value.value}"`)
|
|
158
|
+
} else if (attr.value.type === 'JSXExpressionContainer') {
|
|
159
|
+
const exprValue = serializeAttributeExpression(attr.value.expression, paramNames, warnings)
|
|
160
|
+
if (exprValue !== null) {
|
|
161
|
+
parts.push(`${htmlName}="${exprValue}"`)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return parts.length > 0 ? ' ' + parts.join(' ') : ''
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Serializes an expression used as an attribute value.
|
|
172
|
+
* @param {object} expression - Babel AST expression node
|
|
173
|
+
* @param {string[]} [paramNames]
|
|
174
|
+
* @param {Array<string>} [warnings]
|
|
175
|
+
* @returns {string|null} The serialized value, or null if it can't be serialized
|
|
176
|
+
*/
|
|
177
|
+
function serializeAttributeExpression(expression, paramNames, warnings) {
|
|
178
|
+
if (!expression) return null
|
|
179
|
+
|
|
180
|
+
if (expression.type === 'StringLiteral') {
|
|
181
|
+
return expression.value
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (expression.type === 'NumericLiteral') {
|
|
185
|
+
return String(expression.value)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (expression.type === 'BooleanLiteral') {
|
|
189
|
+
return String(expression.value)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (expression.type === 'Identifier') {
|
|
193
|
+
if (paramNames && paramNames.includes(expression.name)) {
|
|
194
|
+
return `{%${expression.name}%}`
|
|
195
|
+
}
|
|
196
|
+
// Dynamic expression that can't be statically serialized
|
|
197
|
+
if (warnings) {
|
|
198
|
+
warnings.push(`Dynamic expression "{${expression.name}}" cannot be statically serialized`)
|
|
199
|
+
}
|
|
200
|
+
return null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (expression.type === 'TemplateLiteral') {
|
|
204
|
+
return serializeTemplateLiteral(expression, paramNames, warnings)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Other expressions can't be statically serialized
|
|
208
|
+
if (warnings) {
|
|
209
|
+
warnings.push(`Expression of type "${expression.type}" cannot be statically serialized`)
|
|
210
|
+
}
|
|
211
|
+
return null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Serializes a template literal expression.
|
|
216
|
+
* @param {object} node - TemplateLiteral AST node
|
|
217
|
+
* @param {string[]} [paramNames]
|
|
218
|
+
* @param {Array<string>} [warnings]
|
|
219
|
+
* @returns {string|null}
|
|
220
|
+
*/
|
|
221
|
+
function serializeTemplateLiteral(node, paramNames, warnings) {
|
|
222
|
+
let result = ''
|
|
223
|
+
for (let i = 0; i < node.quasis.length; i++) {
|
|
224
|
+
result += node.quasis[i].value.raw
|
|
225
|
+
if (i < node.expressions.length) {
|
|
226
|
+
const expr = node.expressions[i]
|
|
227
|
+
if (expr.type === 'Identifier' && paramNames && paramNames.includes(expr.name)) {
|
|
228
|
+
result += `{%${expr.name}%}`
|
|
229
|
+
} else if (expr.type === 'StringLiteral') {
|
|
230
|
+
result += expr.value
|
|
231
|
+
} else if (expr.type === 'NumericLiteral') {
|
|
232
|
+
result += String(expr.value)
|
|
233
|
+
} else {
|
|
234
|
+
if (warnings) {
|
|
235
|
+
warnings.push(`Dynamic expression in template literal cannot be statically serialized`)
|
|
236
|
+
}
|
|
237
|
+
return null
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return result
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Serializes an array of JSX children nodes.
|
|
246
|
+
* @param {Array<object>} children
|
|
247
|
+
* @param {string[]} [paramNames]
|
|
248
|
+
* @param {Array<string>} [warnings]
|
|
249
|
+
* @returns {string}
|
|
250
|
+
*/
|
|
251
|
+
function serializeJsxChildren(children, paramNames, warnings) {
|
|
252
|
+
if (!children || children.length === 0) return ''
|
|
253
|
+
return children.map(child => serializeJsxToHtml(child, paramNames, warnings)).join('')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Serializes a JSXText node, handling whitespace similar to how React does.
|
|
258
|
+
* - Whitespace-only text nodes (just newlines/spaces between elements) → empty
|
|
259
|
+
* - Newlines with surrounding whitespace are collapsed to a single space
|
|
260
|
+
* - Inline spaces are preserved (they are significant content)
|
|
261
|
+
* @param {object} node - JSXText AST node
|
|
262
|
+
* @returns {string}
|
|
263
|
+
*/
|
|
264
|
+
function serializeJsxText(node) {
|
|
265
|
+
const text = node.value
|
|
266
|
+
// If the text is only whitespace (newlines, spaces, tabs), return empty
|
|
267
|
+
if (/^\s+$/.test(text)) return ''
|
|
268
|
+
// Collapse newlines and their surrounding whitespace to a single space
|
|
269
|
+
let result = text.replace(/[ \t]*\n[ \t]*/g, '\n')
|
|
270
|
+
// Split by newlines to handle multi-line text
|
|
271
|
+
const lines = result.split('\n')
|
|
272
|
+
// Trim empty leading/trailing lines but preserve inline content
|
|
273
|
+
let start = 0
|
|
274
|
+
while (start < lines.length && lines[start].trim() === '') start++
|
|
275
|
+
let end = lines.length - 1
|
|
276
|
+
while (end >= start && lines[end].trim() === '') end--
|
|
277
|
+
if (start > end) return ''
|
|
278
|
+
// Join remaining lines with a space (newlines become spaces in JSX)
|
|
279
|
+
return lines.slice(start, end + 1).map((line, i) => {
|
|
280
|
+
if (i === 0 && start > 0) return line.trimStart()
|
|
281
|
+
if (i === end - start && end < lines.length - 1) return line.trimEnd()
|
|
282
|
+
return line
|
|
283
|
+
}).join(' ')
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Serializes a JSX expression container's expression.
|
|
288
|
+
* @param {object} expression - The expression inside { }
|
|
289
|
+
* @param {string[]} [paramNames]
|
|
290
|
+
* @param {Array<string>} [warnings]
|
|
291
|
+
* @returns {string}
|
|
292
|
+
*/
|
|
293
|
+
function serializeJsxExpression(expression, paramNames, warnings) {
|
|
294
|
+
if (!expression) return ''
|
|
295
|
+
|
|
296
|
+
// JSXEmptyExpression (e.g., {/* comment */})
|
|
297
|
+
if (expression.type === 'JSXEmptyExpression') return ''
|
|
298
|
+
|
|
299
|
+
// Identifier — check if it's a param reference
|
|
300
|
+
if (expression.type === 'Identifier') {
|
|
301
|
+
if (paramNames && paramNames.includes(expression.name)) {
|
|
302
|
+
return `{%${expression.name}%}`
|
|
303
|
+
}
|
|
304
|
+
// Dynamic expression
|
|
305
|
+
if (warnings) {
|
|
306
|
+
warnings.push(`Dynamic expression "{${expression.name}}" cannot be statically serialized`)
|
|
307
|
+
}
|
|
308
|
+
return ''
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// String literal — inline the value
|
|
312
|
+
if (expression.type === 'StringLiteral') {
|
|
313
|
+
return expression.value
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Numeric literal — inline the value
|
|
317
|
+
if (expression.type === 'NumericLiteral') {
|
|
318
|
+
return String(expression.value)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Template literal
|
|
322
|
+
if (expression.type === 'TemplateLiteral') {
|
|
323
|
+
const result = serializeTemplateLiteral(expression, paramNames, warnings)
|
|
324
|
+
return result !== null ? result : ''
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Other expressions can't be statically serialized
|
|
328
|
+
if (warnings) {
|
|
329
|
+
warnings.push(`Expression of type "${expression.type}" cannot be statically serialized`)
|
|
330
|
+
}
|
|
331
|
+
return ''
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Reserved prop names that should always pass through without slot transformation.
|
|
337
|
+
* @type {Set<string>}
|
|
338
|
+
*/
|
|
339
|
+
const RESERVED_PROPS = new Set([
|
|
340
|
+
'children', 'key', 'ref', 'className', 'id', 'style', 'slot', 'is', 'dangerouslySetInnerHTML'
|
|
341
|
+
])
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Classifies a prop on a custom element to determine how it should be handled.
|
|
345
|
+
*
|
|
346
|
+
* Classification rules are applied in priority order:
|
|
347
|
+
* 1. Reserved props → passthrough
|
|
348
|
+
* 2. Event handlers (on + uppercase) → passthrough
|
|
349
|
+
* 3. data-/aria- prefixed props → passthrough
|
|
350
|
+
* 4. Props in user's exclude list → passthrough
|
|
351
|
+
* 5. Render props (render + uppercase + ArrowFunctionExpression) → renderProp
|
|
352
|
+
* 6. Non-JSX/non-string values → passthrough
|
|
353
|
+
* 7. Named slot prop (respecting slotProps option) → slot
|
|
354
|
+
*
|
|
355
|
+
* @param {string} propName - The prop name
|
|
356
|
+
* @param {object} propValue - The Babel AST node for the prop value
|
|
357
|
+
* @param {object} options - Plugin options
|
|
358
|
+
* @param {string[]} [options.exclude] - Prop names to never treat as slots
|
|
359
|
+
* @param {string[]} [options.slotProps] - Explicit list of prop names to treat as named slots
|
|
360
|
+
* @returns {{ type: 'slot', name: string, value: object } | { type: 'renderProp', slotName: string, params: string[], body: object } | { type: 'passthrough' }}
|
|
361
|
+
*/
|
|
362
|
+
export function classifyProp(propName, propValue, options = {}) {
|
|
363
|
+
const { exclude = [], slotProps } = options
|
|
364
|
+
|
|
365
|
+
// Rule 1: Reserved props always pass through
|
|
366
|
+
if (RESERVED_PROPS.has(propName)) {
|
|
367
|
+
return { type: 'passthrough' }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Rule 2: Event handlers (on + uppercase letter) always pass through
|
|
371
|
+
if (propName.length > 2 && propName[0] === 'o' && propName[1] === 'n' && propName[2] >= 'A' && propName[2] <= 'Z') {
|
|
372
|
+
return { type: 'passthrough' }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Rule 3: data- and aria- prefixed props always pass through
|
|
376
|
+
if (propName.startsWith('data-') || propName.startsWith('aria-')) {
|
|
377
|
+
return { type: 'passthrough' }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Rule 4: Props in the user's exclude list always pass through
|
|
381
|
+
if (exclude.includes(propName)) {
|
|
382
|
+
return { type: 'passthrough' }
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Rule 5: Render props (render + uppercase AND ArrowFunctionExpression value)
|
|
386
|
+
if (/^render[A-Z]/.test(propName) && propValue && propValue.type === 'ArrowFunctionExpression') {
|
|
387
|
+
const slotName = propName.slice(6)
|
|
388
|
+
const derivedSlotName = slotName[0].toLowerCase() + slotName.slice(1)
|
|
389
|
+
const params = (propValue.params || []).map(p => p.name || (p.type === 'Identifier' ? p.name : ''))
|
|
390
|
+
return {
|
|
391
|
+
type: 'renderProp',
|
|
392
|
+
slotName: derivedSlotName,
|
|
393
|
+
params,
|
|
394
|
+
body: propValue.body
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Rule 6: Non-JSX/non-string values always pass through
|
|
399
|
+
// We need to check the actual value type. In JSX, prop values come wrapped in
|
|
400
|
+
// JSXExpressionContainer. The propValue here is the raw value node.
|
|
401
|
+
if (propValue) {
|
|
402
|
+
const valueType = propValue.type
|
|
403
|
+
// String literals are slot-eligible
|
|
404
|
+
if (valueType === 'StringLiteral') {
|
|
405
|
+
// Fall through to Rule 7
|
|
406
|
+
}
|
|
407
|
+
// JSX expressions are slot-eligible
|
|
408
|
+
else if (valueType === 'JSXElement' || valueType === 'JSXFragment') {
|
|
409
|
+
// Fall through to Rule 7
|
|
410
|
+
}
|
|
411
|
+
// Everything else (NumericLiteral, BooleanLiteral, Identifier, ArrayExpression,
|
|
412
|
+
// ObjectExpression, CallExpression, etc.) passes through
|
|
413
|
+
else {
|
|
414
|
+
return { type: 'passthrough' }
|
|
415
|
+
}
|
|
416
|
+
} else {
|
|
417
|
+
// No value (boolean shorthand like `disabled`) passes through
|
|
418
|
+
return { type: 'passthrough' }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Rule 7: Named slot prop (respecting slotProps option if set)
|
|
422
|
+
if (slotProps) {
|
|
423
|
+
// When slotProps is set, only props in that list become slots
|
|
424
|
+
if (slotProps.includes(propName)) {
|
|
425
|
+
return { type: 'slot', name: propName, value: propValue }
|
|
426
|
+
}
|
|
427
|
+
// Not in the explicit list → passthrough
|
|
428
|
+
return { type: 'passthrough' }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Default heuristic: any remaining prop with JSX or string value is a slot
|
|
432
|
+
return { type: 'slot', name: propName, value: propValue }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Derives a slot name from a render prop name by stripping the `render` prefix
|
|
438
|
+
* and lowercasing the first character of the remaining name.
|
|
439
|
+
*
|
|
440
|
+
* @param {string} renderPropName - The render prop name (e.g., 'renderStats', 'renderItemRow')
|
|
441
|
+
* @returns {string} The derived slot name (e.g., 'stats', 'itemRow')
|
|
442
|
+
*
|
|
443
|
+
* @example
|
|
444
|
+
* deriveSlotName('renderStats') // → 'stats'
|
|
445
|
+
* deriveSlotName('renderItemRow') // → 'itemRow'
|
|
446
|
+
*/
|
|
447
|
+
export function deriveSlotName(renderPropName) {
|
|
448
|
+
const withoutPrefix = renderPropName.slice(6) // strip "render"
|
|
449
|
+
return withoutPrefix[0].toLowerCase() + withoutPrefix.slice(1)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Creates a Babel AST JSXElement node representing `<div slot="name">content</div>`.
|
|
455
|
+
* Used for named slot props whose value is a JSX expression.
|
|
456
|
+
*
|
|
457
|
+
* @param {string} slotName - The slot name to use in the `slot` attribute
|
|
458
|
+
* @param {object} content - A Babel JSX AST node (JSXElement, JSXFragment, etc.) to wrap as children
|
|
459
|
+
* @returns {object} A Babel JSXElement AST node
|
|
460
|
+
*/
|
|
461
|
+
export function generateNamedSlotElement(slotName, content) {
|
|
462
|
+
const slotAttr = {
|
|
463
|
+
type: 'JSXAttribute',
|
|
464
|
+
name: { type: 'JSXIdentifier', name: 'slot' },
|
|
465
|
+
value: { type: 'StringLiteral', value: slotName }
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const openingElement = {
|
|
469
|
+
type: 'JSXOpeningElement',
|
|
470
|
+
name: { type: 'JSXIdentifier', name: 'div' },
|
|
471
|
+
attributes: [slotAttr],
|
|
472
|
+
selfClosing: false
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const closingElement = {
|
|
476
|
+
type: 'JSXClosingElement',
|
|
477
|
+
name: { type: 'JSXIdentifier', name: 'div' }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Wrap content in a JSXExpressionContainer if it's not already a JSX child type
|
|
481
|
+
let children
|
|
482
|
+
if (content.type === 'JSXElement' || content.type === 'JSXFragment' || content.type === 'JSXText' || content.type === 'JSXExpressionContainer') {
|
|
483
|
+
children = [content]
|
|
484
|
+
} else {
|
|
485
|
+
children = [{ type: 'JSXExpressionContainer', expression: content }]
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
type: 'JSXElement',
|
|
490
|
+
openingElement,
|
|
491
|
+
closingElement,
|
|
492
|
+
children
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Creates a Babel AST JSXElement node representing `<span slot="name">text</span>`.
|
|
499
|
+
* Used for named slot props whose value is a string literal.
|
|
500
|
+
*
|
|
501
|
+
* @param {string} slotName - The slot name to use in the `slot` attribute
|
|
502
|
+
* @param {string} text - The string text content
|
|
503
|
+
* @returns {object} A Babel JSXElement AST node
|
|
504
|
+
*/
|
|
505
|
+
export function generateStringSlotElement(slotName, text) {
|
|
506
|
+
const slotAttr = {
|
|
507
|
+
type: 'JSXAttribute',
|
|
508
|
+
name: { type: 'JSXIdentifier', name: 'slot' },
|
|
509
|
+
value: { type: 'StringLiteral', value: slotName }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const openingElement = {
|
|
513
|
+
type: 'JSXOpeningElement',
|
|
514
|
+
name: { type: 'JSXIdentifier', name: 'span' },
|
|
515
|
+
attributes: [slotAttr],
|
|
516
|
+
selfClosing: false
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const closingElement = {
|
|
520
|
+
type: 'JSXClosingElement',
|
|
521
|
+
name: { type: 'JSXIdentifier', name: 'span' }
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
type: 'JSXElement',
|
|
526
|
+
openingElement,
|
|
527
|
+
closingElement,
|
|
528
|
+
children: [{ type: 'JSXText', value: text }]
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Creates a Babel AST JSXElement node representing:
|
|
535
|
+
* `<div slot="name" slot-props="param1,param2" dangerouslySetInnerHTML={{__html: `...`}}></div>`
|
|
536
|
+
*
|
|
537
|
+
* Used for render prop (scoped slot) transformation. The body JSX is serialized to an HTML
|
|
538
|
+
* template string with {%param%} tokens using `serializeJsxToHtml`.
|
|
539
|
+
*
|
|
540
|
+
* @param {string} slotName - The derived slot name
|
|
541
|
+
* @param {string[]} params - The arrow function parameter names
|
|
542
|
+
* @param {object} body - The Babel JSX AST node for the arrow function body
|
|
543
|
+
* @returns {object} A Babel JSXElement AST node
|
|
544
|
+
*/
|
|
545
|
+
export function generateScopedSlotElement(slotName, params, body) {
|
|
546
|
+
// Serialize the body to an HTML template string with {%param%} tokens
|
|
547
|
+
const htmlTemplate = serializeJsxToHtml(body, params)
|
|
548
|
+
|
|
549
|
+
// slot="name" attribute
|
|
550
|
+
const slotAttr = {
|
|
551
|
+
type: 'JSXAttribute',
|
|
552
|
+
name: { type: 'JSXIdentifier', name: 'slot' },
|
|
553
|
+
value: { type: 'StringLiteral', value: slotName }
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// slot-props="param1,param2" attribute
|
|
557
|
+
const slotPropsAttr = {
|
|
558
|
+
type: 'JSXAttribute',
|
|
559
|
+
name: { type: 'JSXIdentifier', name: 'slot-props' },
|
|
560
|
+
value: { type: 'StringLiteral', value: params.join(',') }
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// dangerouslySetInnerHTML={{__html: `...`}} attribute
|
|
564
|
+
const dangerouslyAttr = {
|
|
565
|
+
type: 'JSXAttribute',
|
|
566
|
+
name: { type: 'JSXIdentifier', name: 'dangerouslySetInnerHTML' },
|
|
567
|
+
value: {
|
|
568
|
+
type: 'JSXExpressionContainer',
|
|
569
|
+
expression: {
|
|
570
|
+
type: 'ObjectExpression',
|
|
571
|
+
properties: [{
|
|
572
|
+
type: 'ObjectProperty',
|
|
573
|
+
key: { type: 'Identifier', name: '__html' },
|
|
574
|
+
value: { type: 'TemplateLiteral', quasis: [{ type: 'TemplateElement', value: { raw: htmlTemplate, cooked: htmlTemplate } }], expressions: [] },
|
|
575
|
+
computed: false,
|
|
576
|
+
shorthand: false
|
|
577
|
+
}]
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const openingElement = {
|
|
583
|
+
type: 'JSXOpeningElement',
|
|
584
|
+
name: { type: 'JSXIdentifier', name: 'div' },
|
|
585
|
+
attributes: [slotAttr, slotPropsAttr, dangerouslyAttr],
|
|
586
|
+
selfClosing: false
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const closingElement = {
|
|
590
|
+
type: 'JSXClosingElement',
|
|
591
|
+
name: { type: 'JSXIdentifier', name: 'div' }
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return {
|
|
595
|
+
type: 'JSXElement',
|
|
596
|
+
openingElement,
|
|
597
|
+
closingElement,
|
|
598
|
+
children: []
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Vite plugin that transforms idiomatic React JSX slot patterns into
|
|
605
|
+
* WCC-compatible slot markup at build time.
|
|
606
|
+
*
|
|
607
|
+
* Runs with `enforce: 'pre'` so it processes JSX before @vitejs/plugin-react.
|
|
608
|
+
*
|
|
609
|
+
* @param {Object} [options]
|
|
610
|
+
* @param {string} [options.prefix] - Tag prefix filter (e.g., 'wcc-'). If set, only elements starting with this prefix are processed.
|
|
611
|
+
* @param {string[]} [options.exclude] - Prop names to never treat as slots.
|
|
612
|
+
* @param {string[]} [options.slotProps] - Explicit list of prop names to treat as named slots (overrides default heuristic).
|
|
613
|
+
* @returns {import('vite').Plugin}
|
|
614
|
+
*/
|
|
615
|
+
export function wccReactPlugin(options = {}) {
|
|
616
|
+
const { prefix, exclude = [], slotProps } = options
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
name: 'vite-plugin-wcc-react-slots',
|
|
620
|
+
enforce: 'pre',
|
|
621
|
+
transform(code, id) {
|
|
622
|
+
// Only process .jsx and .tsx files
|
|
623
|
+
if (!/\.[jt]sx$/.test(id)) {
|
|
624
|
+
return null
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
let ast
|
|
628
|
+
try {
|
|
629
|
+
ast = parse(code, {
|
|
630
|
+
sourceType: 'module',
|
|
631
|
+
plugins: ['jsx', 'typescript']
|
|
632
|
+
})
|
|
633
|
+
} catch (e) {
|
|
634
|
+
this.warn(`[wcc-react] ${id} — failed to parse: ${e.message}`)
|
|
635
|
+
return null
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
let transformed = false
|
|
639
|
+
const pluginCtx = this
|
|
640
|
+
|
|
641
|
+
traverse(ast, {
|
|
642
|
+
JSXElement(path) {
|
|
643
|
+
const openingElement = path.node.openingElement
|
|
644
|
+
const nameNode = openingElement.name
|
|
645
|
+
|
|
646
|
+
// ── Compound component transform ──
|
|
647
|
+
// <WccCard.Header>children</WccCard.Header> → <div slot="header" style={{display:'contents'}}>children</div>
|
|
648
|
+
// <WccCard.Stats>{(likes) => <span>{likes}</span>}</WccCard.Stats> → scoped slot div
|
|
649
|
+
if (nameNode.type === 'JSXMemberExpression') {
|
|
650
|
+
const objectName = nameNode.object?.name // e.g., 'WccCard'
|
|
651
|
+
const propName = nameNode.property?.name // e.g., 'Header'
|
|
652
|
+
if (!objectName || !propName) return
|
|
653
|
+
|
|
654
|
+
// Convert PascalCase object to kebab-case and check if it's a custom element
|
|
655
|
+
const kebab = objectName.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')
|
|
656
|
+
if (!kebab.includes('-')) return
|
|
657
|
+
if (prefix && !kebab.startsWith(prefix)) return
|
|
658
|
+
|
|
659
|
+
// Derive slot name: Header → header, FooterNav → footerNav (lcfirst)
|
|
660
|
+
const slotName = propName[0].toLowerCase() + propName.slice(1)
|
|
661
|
+
|
|
662
|
+
// Check if children is a function (scoped slot / render prop pattern)
|
|
663
|
+
const children = path.node.children
|
|
664
|
+
const isScopedSlot = children.length === 1
|
|
665
|
+
&& children[0].type === 'JSXExpressionContainer'
|
|
666
|
+
&& children[0].expression.type === 'ArrowFunctionExpression'
|
|
667
|
+
|
|
668
|
+
if (isScopedSlot) {
|
|
669
|
+
// Scoped slot: <WccCard.Stats>{(likes) => <span>{likes}</span>}</WccCard.Stats>
|
|
670
|
+
const arrowFn = children[0].expression
|
|
671
|
+
const params = (arrowFn.params || []).map(p => p.name || '')
|
|
672
|
+
const body = arrowFn.body
|
|
673
|
+
|
|
674
|
+
// Warn on unsupported expressions
|
|
675
|
+
const renderWarnings = []
|
|
676
|
+
serializeJsxToHtml(body, params, renderWarnings)
|
|
677
|
+
if (renderWarnings.length > 0) {
|
|
678
|
+
pluginCtx.warn(`[wcc-react] ${id} — ${objectName}.${propName}: ${renderWarnings[0]}`)
|
|
679
|
+
return
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Replace with scoped slot element
|
|
683
|
+
const scopedEl = generateScopedSlotElement(slotName, params, body)
|
|
684
|
+
path.replaceWith(scopedEl)
|
|
685
|
+
transformed = true
|
|
686
|
+
} else {
|
|
687
|
+
// Named slot: <WccCard.Header><strong>Title</strong></WccCard.Header>
|
|
688
|
+
// → <div slot="header" style={{display:'contents'}}>children</div>
|
|
689
|
+
const slotAttr = {
|
|
690
|
+
type: 'JSXAttribute',
|
|
691
|
+
name: { type: 'JSXIdentifier', name: 'slot' },
|
|
692
|
+
value: { type: 'StringLiteral', value: slotName }
|
|
693
|
+
}
|
|
694
|
+
const styleAttr = {
|
|
695
|
+
type: 'JSXAttribute',
|
|
696
|
+
name: { type: 'JSXIdentifier', name: 'style' },
|
|
697
|
+
value: {
|
|
698
|
+
type: 'JSXExpressionContainer',
|
|
699
|
+
expression: {
|
|
700
|
+
type: 'ObjectExpression',
|
|
701
|
+
properties: [{
|
|
702
|
+
type: 'ObjectProperty',
|
|
703
|
+
key: { type: 'Identifier', name: 'display' },
|
|
704
|
+
value: { type: 'StringLiteral', value: 'contents' },
|
|
705
|
+
computed: false,
|
|
706
|
+
shorthand: false
|
|
707
|
+
}]
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
openingElement.name = { type: 'JSXIdentifier', name: 'div' }
|
|
713
|
+
openingElement.attributes = [...openingElement.attributes, slotAttr, styleAttr]
|
|
714
|
+
if (path.node.closingElement) {
|
|
715
|
+
path.node.closingElement.name = { type: 'JSXIdentifier', name: 'div' }
|
|
716
|
+
}
|
|
717
|
+
transformed = true
|
|
718
|
+
}
|
|
719
|
+
return
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// ── PascalCase custom element transform ──
|
|
723
|
+
// <WccCard> → <wcc-card> (only if it maps to a hyphenated tag)
|
|
724
|
+
if (nameNode.type === 'JSXIdentifier' && /^[A-Z]/.test(nameNode.name)) {
|
|
725
|
+
const kebab = nameNode.name.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')
|
|
726
|
+
if (!kebab.includes('-')) return
|
|
727
|
+
if (prefix && !kebab.startsWith(prefix)) return
|
|
728
|
+
|
|
729
|
+
// Rewrite tag name to kebab-case
|
|
730
|
+
openingElement.name = { type: 'JSXIdentifier', name: kebab }
|
|
731
|
+
if (path.node.closingElement) {
|
|
732
|
+
path.node.closingElement.name = { type: 'JSXIdentifier', name: kebab }
|
|
733
|
+
}
|
|
734
|
+
transformed = true
|
|
735
|
+
// Fall through to process props on this element
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Only process elements with hyphenated tag names (custom elements)
|
|
739
|
+
const currentName = openingElement.name
|
|
740
|
+
if (currentName.type !== 'JSXIdentifier') return
|
|
741
|
+
const tagName = currentName.name
|
|
742
|
+
if (!tagName.includes('-')) return
|
|
743
|
+
|
|
744
|
+
// Apply prefix filtering if set
|
|
745
|
+
if (prefix && !tagName.startsWith(prefix)) return
|
|
746
|
+
|
|
747
|
+
const slotChildren = []
|
|
748
|
+
const remainingAttributes = []
|
|
749
|
+
|
|
750
|
+
for (const attr of openingElement.attributes) {
|
|
751
|
+
// Skip spread attributes — leave them unchanged
|
|
752
|
+
if (attr.type !== 'JSXAttribute') {
|
|
753
|
+
remainingAttributes.push(attr)
|
|
754
|
+
continue
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const propName = attr.name.type === 'JSXNamespacedName'
|
|
758
|
+
? `${attr.name.namespace.name}:${attr.name.name.name}`
|
|
759
|
+
: attr.name.name
|
|
760
|
+
|
|
761
|
+
// Get the prop value — unwrap JSXExpressionContainer
|
|
762
|
+
let propValue = attr.value
|
|
763
|
+
if (propValue && propValue.type === 'JSXExpressionContainer') {
|
|
764
|
+
propValue = propValue.expression
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Task 7.2: Warn on invalid render prop values (non-arrow-function)
|
|
768
|
+
if (/^render[A-Z]/.test(propName) && propValue && propValue.type !== 'ArrowFunctionExpression' && propValue.type !== 'StringLiteral') {
|
|
769
|
+
pluginCtx.warn(`[wcc-react] ${id} — ${propName}: expected ArrowFunctionExpression, got ${propValue.type}`)
|
|
770
|
+
remainingAttributes.push(attr)
|
|
771
|
+
continue
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const classification = classifyProp(propName, propValue, { exclude, slotProps })
|
|
775
|
+
|
|
776
|
+
if (classification.type === 'slot') {
|
|
777
|
+
// Task 7.4: Warn on dynamic expressions in named slot props — leave prop unchanged
|
|
778
|
+
if (classification.value.type === 'JSXElement' || classification.value.type === 'JSXFragment') {
|
|
779
|
+
const slotWarnings = []
|
|
780
|
+
serializeJsxToHtml(classification.value, [], slotWarnings)
|
|
781
|
+
if (slotWarnings.length > 0) {
|
|
782
|
+
pluginCtx.warn(`[wcc-react] ${id} — ${propName}: ${slotWarnings[0]}`)
|
|
783
|
+
remainingAttributes.push(attr)
|
|
784
|
+
continue
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
// Generate slot child element
|
|
788
|
+
if (classification.value.type === 'StringLiteral') {
|
|
789
|
+
slotChildren.push(generateStringSlotElement(classification.name, classification.value.value))
|
|
790
|
+
} else {
|
|
791
|
+
slotChildren.push(generateNamedSlotElement(classification.name, classification.value))
|
|
792
|
+
}
|
|
793
|
+
transformed = true
|
|
794
|
+
} else if (classification.type === 'renderProp') {
|
|
795
|
+
// Task 7.3: Warn on unsupported expressions in render prop bodies — leave prop unchanged
|
|
796
|
+
const renderWarnings = []
|
|
797
|
+
serializeJsxToHtml(classification.body, classification.params, renderWarnings)
|
|
798
|
+
if (renderWarnings.length > 0) {
|
|
799
|
+
pluginCtx.warn(`[wcc-react] ${id} — ${propName}: ${renderWarnings[0]}`)
|
|
800
|
+
remainingAttributes.push(attr)
|
|
801
|
+
continue
|
|
802
|
+
}
|
|
803
|
+
// Generate scoped slot element
|
|
804
|
+
slotChildren.push(generateScopedSlotElement(
|
|
805
|
+
classification.slotName,
|
|
806
|
+
classification.params,
|
|
807
|
+
classification.body
|
|
808
|
+
))
|
|
809
|
+
transformed = true
|
|
810
|
+
} else {
|
|
811
|
+
// passthrough — keep the attribute
|
|
812
|
+
remainingAttributes.push(attr)
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (slotChildren.length > 0) {
|
|
817
|
+
// Remove transformed slot props from the element's attributes
|
|
818
|
+
openingElement.attributes = remainingAttributes
|
|
819
|
+
|
|
820
|
+
// If element was self-closing, convert to open/close pair
|
|
821
|
+
if (openingElement.selfClosing) {
|
|
822
|
+
openingElement.selfClosing = false
|
|
823
|
+
path.node.closingElement = { type: 'JSXClosingElement', name: { ...nameNode } }
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Append generated slot elements after existing children
|
|
827
|
+
path.node.children = [...path.node.children, ...slotChildren]
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
if (!transformed) {
|
|
833
|
+
return null
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const result = generate(ast, { sourceMaps: true, sourceFileName: id }, code)
|
|
837
|
+
return { code: result.code, map: result.map }
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* @deprecated Use the CLI-generated stubs instead (dist/wcc-react.js).
|
|
845
|
+
* The `wcc build` command now auto-generates importable stubs with types.
|
|
846
|
+
* This virtual module plugin is kept for backward compatibility but will be removed.
|
|
847
|
+
*
|
|
848
|
+
* Migration:
|
|
849
|
+
* Before: import { WccCard } from '@wcc/react' (virtual module)
|
|
850
|
+
* After: import { WccCard } from './dist/wcc-react' (real file, tree-shakeable)
|
|
851
|
+
*/
|
|
852
|
+
export function wccReactComponents(options = {}) {
|
|
853
|
+
return {
|
|
854
|
+
name: 'vite-plugin-wcc-react-components-deprecated',
|
|
855
|
+
buildStart() {
|
|
856
|
+
this.warn('[wcc] wccReactComponents() is deprecated. Use the CLI-generated stubs from dist/wcc-react.js instead.')
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|