@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.
@@ -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
+ }