@sprlab/wccompiler 0.9.0 → 0.9.2

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