@sprlab/wccompiler 0.9.1 → 0.9.3

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,5 +1,73 @@
1
1
  /**
2
- * Angular adapter for WCC defineModel (OPTIONAL).
2
+ * Angular adapter for WCC (defineModel + Scoped Slots).
3
+ *
4
+ * This module provides Angular integration for WCC components:
5
+ *
6
+ * ═══════════════════════════════════════════════════════════════════════════════
7
+ * SCOPED SLOTS (Native Angular Syntax)
8
+ * ═══════════════════════════════════════════════════════════════════════════════
9
+ *
10
+ * For native Angular scoped slot support, use the TypeScript directives:
11
+ *
12
+ * import { WccSlotsDirective, WccSlotDef } from '@sprlab/wccompiler/adapters/angular';
13
+ *
14
+ * Setup:
15
+ * @Component({
16
+ * imports: [WccSlotsDirective, WccSlotDef],
17
+ * schemas: [CUSTOM_ELEMENTS_SCHEMA],
18
+ * template: `...`
19
+ * })
20
+ *
21
+ * The directives auto-activate on custom elements (tags with hyphen).
22
+ * No [wccSlots] attribute is needed on the host element.
23
+ *
24
+ * Slot declaration uses ng-template[slot] syntax:
25
+ *
26
+ * <!-- Named slot (static content, no let-*) -->
27
+ * <wcc-card>
28
+ * <ng-template slot="header"><strong>My Header</strong></ng-template>
29
+ * </wcc-card>
30
+ *
31
+ * <!-- Scoped slot (reactive data via let-*) -->
32
+ * <wcc-card>
33
+ * <ng-template slot="stats" let-likes>{{ likes }} likes</ng-template>
34
+ * </wcc-card>
35
+ *
36
+ * <!-- Multiple props in scoped slot -->
37
+ * <wcc-card>
38
+ * <ng-template slot="details" let-data let-likes="likes" let-total="total">
39
+ * {{ likes }}/{{ total }}
40
+ * </ng-template>
41
+ * </wcc-card>
42
+ *
43
+ * How it works:
44
+ * - WccSlotDef captures the TemplateRef and slot name from the 'slot' attribute
45
+ * - WccSlotsDirective classifies slots as named or scoped using __scopedSlots
46
+ * - Named slots render immediately into <div slot="name" style="display:contents">
47
+ * - Scoped slots register a renderer via element.registerSlotRenderer()
48
+ * - When slot props change, the renderer updates the Angular EmbeddedView
49
+ * - Compatible with OnPush change detection strategy
50
+ *
51
+ * The directive source is in adapters/angular.ts (TypeScript with Angular decorators).
52
+ *
53
+ * ═══════════════════════════════════════════════════════════════════════════════
54
+ * BACKWARD COMPATIBILITY: slot-template-* (Token Replacement)
55
+ * ═══════════════════════════════════════════════════════════════════════════════
56
+ *
57
+ * The legacy slot-template-* attribute approach continues to work without
58
+ * importing the directives:
59
+ *
60
+ * <wcc-list>
61
+ * <div slot-template-item="<li>{%item%}</li>"></div>
62
+ * </wcc-list>
63
+ *
64
+ * When WccSlotsDirective IS imported, ng-template[slot] takes priority over
65
+ * slot-template-* for the same slot name. Slots not covered by ng-template
66
+ * continue using the token replacement path.
67
+ *
68
+ * ═══════════════════════════════════════════════════════════════════════════════
69
+ * defineModel (Two-Way Binding)
70
+ * ═══════════════════════════════════════════════════════════════════════════════
3
71
  *
4
72
  * The WCC component already emits `propNameChange` directly from _modelSet,
5
73
  * so Angular's [(prop)] banana-box syntax works WITHOUT this adapter.
@@ -9,7 +77,7 @@
9
77
  * 2. The ControlValueAccessor guide for ngModel support
10
78
  *
11
79
  * Setup (Angular):
12
- * // No adapter import needed! Just use CUSTOM_ELEMENTS_SCHEMA:
80
+ * // No adapter import needed for [(prop)]! Just use CUSTOM_ELEMENTS_SCHEMA:
13
81
  * import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'
14
82
  * @Component({ schemas: [CUSTOM_ELEMENTS_SCHEMA] })
15
83
  *
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Angular adapter for WCC Scoped Slots.
3
+ *
4
+ * Exports:
5
+ * - WccSlotDef: Auxiliary directive for ng-template[slot]
6
+ * - WccSlotsDirective: Main directive that auto-activates on custom elements
7
+ * - SlotContext: Interface for template context typing
8
+ *
9
+ * Usage:
10
+ * import { WccSlotsDirective, WccSlotDef } from '@sprlab/wccompiler/adapters/angular';
11
+ *
12
+ * @Component({
13
+ * imports: [WccSlotsDirective, WccSlotDef],
14
+ * schemas: [CUSTOM_ELEMENTS_SCHEMA],
15
+ * template: `
16
+ * <wcc-card>
17
+ * <ng-template slot="header"><strong>Header</strong></ng-template>
18
+ * <ng-template slot="stats" let-likes>{{ likes }} likes</ng-template>
19
+ * </wcc-card>
20
+ * `
21
+ * })
22
+ *
23
+ * @module @sprlab/wccompiler/adapters/angular
24
+ */
25
+
26
+ import {
27
+ Directive,
28
+ TemplateRef,
29
+ ElementRef,
30
+ ViewContainerRef,
31
+ ChangeDetectorRef,
32
+ ContentChildren,
33
+ QueryList,
34
+ EmbeddedViewRef,
35
+ AfterContentInit,
36
+ OnDestroy,
37
+ inject,
38
+ Attribute,
39
+ } from '@angular/core';
40
+
41
+ // ─── Interfaces ─────────────────────────────────────────────────────────────
42
+
43
+ /** Context object passed to createEmbeddedView for scoped slots */
44
+ export interface SlotContext {
45
+ $implicit: any;
46
+ [key: string]: any;
47
+ }
48
+
49
+ type SlotType = 'named' | 'scoped';
50
+
51
+ interface SlotState {
52
+ type: SlotType;
53
+ slotDef: WccSlotDef;
54
+ viewRef: EmbeddedViewRef<SlotContext> | null;
55
+ cleanup: (() => void) | null;
56
+ wrapperEl: HTMLElement | null;
57
+ context: SlotContext | null;
58
+ }
59
+
60
+ // ─── WccSlotDef — Auxiliary Directive ───────────────────────────────────────
61
+
62
+ /**
63
+ * Auxiliary directive that marks an ng-template as slot content.
64
+ * Captures the TemplateRef and the slot name from the HTML 'slot' attribute.
65
+ *
66
+ * Usage:
67
+ * <ng-template slot="header">...</ng-template>
68
+ * <ng-template slot="stats" let-likes>{{likes}}</ng-template>
69
+ */
70
+ @Directive({
71
+ selector: 'ng-template[slot]',
72
+ standalone: true,
73
+ })
74
+ export class WccSlotDef {
75
+ public readonly templateRef = inject<TemplateRef<any>>(TemplateRef);
76
+ public readonly slotName: string;
77
+
78
+ constructor(@Attribute('slot') name: string | null) {
79
+ this.slotName = name || '';
80
+ }
81
+ }
82
+
83
+ // ─── WccSlotsDirective — Main Directive ─────────────────────────────────────
84
+
85
+ /**
86
+ * Exclusion selector: lists all standard HTML elements.
87
+ * Custom elements (which MUST contain a hyphen) are not excluded.
88
+ */
89
+ const STANDARD_ELEMENTS = 'div,span,p,a,button,input,form,section,article,header,footer,nav,main,ul,ol,li,table,tr,td,th,thead,tbody,tfoot,img,h1,h2,h3,h4,h5,h6,label,select,textarea,option,fieldset,legend,details,summary,dialog,slot,template,canvas,video,audio,source,iframe,pre,code,blockquote,hr,br,strong,em,small,sub,sup,mark,del,ins,figure,figcaption,picture,svg,math,body,html,head,script,style,link,meta,title,base,col,colgroup,caption,abbr,address,area,aside,b,bdi,bdo,cite,data,dd,dfn,dl,dt,i,kbd,map,meter,noscript,output,progress,q,rp,rt,ruby,s,samp,time,u,var,wbr';
90
+
91
+ /** Build the exclusion selector string */
92
+ const EXCLUSION_SELECTOR = STANDARD_ELEMENTS.split(',').map(t => `:not(${t})`).join('');
93
+
94
+ /**
95
+ * Main directive that auto-activates on custom elements (tags with hyphen).
96
+ * Classifies ng-template[slot] children as named or scoped slots and manages
97
+ * their lifecycle.
98
+ */
99
+ @Directive({
100
+ selector: EXCLUSION_SELECTOR,
101
+ standalone: true,
102
+ })
103
+ export class WccSlotsDirective implements AfterContentInit, OnDestroy {
104
+ @ContentChildren(WccSlotDef) slotDefs!: QueryList<WccSlotDef>;
105
+
106
+ private el = inject<ElementRef<HTMLElement>>(ElementRef);
107
+ private vcr = inject(ViewContainerRef);
108
+ private cdr = inject(ChangeDetectorRef);
109
+
110
+ private slots = new Map<string, SlotState>();
111
+ private eventCleanups: (() => void)[] = [];
112
+ private destroyed = false;
113
+
114
+ ngAfterContentInit(): void {
115
+ // Runtime guard: only proceed for custom elements (tag name contains hyphen)
116
+ if (!this.el.nativeElement.tagName.toLowerCase().includes('-')) return;
117
+
118
+ this.classifyAndInitSlots();
119
+ }
120
+
121
+ ngOnDestroy(): void {
122
+ this.destroyed = true;
123
+ this.cleanup();
124
+ }
125
+
126
+ // ─── Classification ─────────────────────────────────────────────────────
127
+
128
+ /** Classifies slots using __scopedSlots from the host element and initializes them */
129
+ private classifyAndInitSlots(): void {
130
+ const element = this.el.nativeElement as any;
131
+ const scopedNames: string[] = element.__scopedSlots || [];
132
+
133
+ for (const slotDef of this.slotDefs) {
134
+ // Ignore templates with empty slot name
135
+ if (!slotDef.slotName) continue;
136
+
137
+ if (scopedNames.includes(slotDef.slotName)) {
138
+ this.initScopedSlot(slotDef);
139
+ } else {
140
+ this.initNamedSlot(slotDef);
141
+ }
142
+ }
143
+ }
144
+
145
+ // ─── Named Slot ─────────────────────────────────────────────────────────
146
+
147
+ /** Named Slot: immediate static rendering */
148
+ private initNamedSlot(slotDef: WccSlotDef): void {
149
+ const hostEl = this.el.nativeElement;
150
+ const wrapper = document.createElement('div');
151
+ wrapper.setAttribute('slot', slotDef.slotName);
152
+ wrapper.style.display = 'contents';
153
+
154
+ const viewRef = this.vcr.createEmbeddedView(slotDef.templateRef);
155
+ for (const node of viewRef.rootNodes) {
156
+ wrapper.appendChild(node);
157
+ }
158
+ hostEl.appendChild(wrapper);
159
+
160
+ this.slots.set(slotDef.slotName, {
161
+ type: 'named',
162
+ slotDef,
163
+ viewRef,
164
+ cleanup: null,
165
+ wrapperEl: wrapper,
166
+ context: null,
167
+ });
168
+ }
169
+
170
+ // ─── Scoped Slot ────────────────────────────────────────────────────────
171
+
172
+ /** Scoped Slot: async registration + reactive rendering */
173
+ private async initScopedSlot(slotDef: WccSlotDef): Promise<void> {
174
+ const hostEl = this.el.nativeElement;
175
+ const tagName = hostEl.tagName.toLowerCase();
176
+
177
+ // Wait for the custom element to be defined
178
+ await customElements.whenDefined(tagName);
179
+ if (this.destroyed) return;
180
+
181
+ const state: SlotState = {
182
+ type: 'scoped',
183
+ slotDef,
184
+ viewRef: null,
185
+ cleanup: null,
186
+ wrapperEl: null,
187
+ context: null,
188
+ };
189
+ this.slots.set(slotDef.slotName, state);
190
+
191
+ // Register renderer
192
+ const element = hostEl as any;
193
+ if (typeof element.registerSlotRenderer === 'function') {
194
+ state.cleanup = element.registerSlotRenderer(
195
+ slotDef.slotName,
196
+ (props: Record<string, any>) => this.renderSlot(slotDef.slotName, props)
197
+ );
198
+ } else {
199
+ // Fallback: listen for wcc:slot-update event
200
+ const handler = (e: CustomEvent) => {
201
+ if (e.detail?.slot === slotDef.slotName) {
202
+ this.renderSlot(slotDef.slotName, e.detail.props);
203
+ }
204
+ };
205
+ hostEl.addEventListener('wcc:slot-update', handler as EventListener);
206
+ this.eventCleanups.push(() =>
207
+ hostEl.removeEventListener('wcc:slot-update', handler as EventListener)
208
+ );
209
+ }
210
+ }
211
+
212
+ // ─── Context Construction ───────────────────────────────────────────────
213
+
214
+ /**
215
+ * Builds the Angular context for createEmbeddedView.
216
+ *
217
+ * Rules:
218
+ * - 0 props: $implicit = undefined
219
+ * - 1 prop: $implicit = that single value, plus the named prop key
220
+ * - N props (N > 1): $implicit = full props object, plus all named props
221
+ */
222
+ buildContext(props: Record<string, any>): SlotContext {
223
+ const keys = Object.keys(props);
224
+ if (keys.length === 0) {
225
+ return { $implicit: undefined };
226
+ }
227
+ if (keys.length === 1) {
228
+ return { $implicit: props[keys[0]], ...props };
229
+ }
230
+ return { $implicit: props, ...props };
231
+ }
232
+
233
+ // ─── Render Slot ────────────────────────────────────────────────────────
234
+
235
+ /** Creates or updates the EmbeddedView of a scoped slot */
236
+ private renderSlot(slotName: string, props: Record<string, any> | null): void {
237
+ const state = this.slots.get(slotName);
238
+ if (!state || this.destroyed) return;
239
+
240
+ // Props null/undefined: clear the view
241
+ if (props == null) {
242
+ if (state.viewRef) {
243
+ state.viewRef.destroy();
244
+ state.viewRef = null;
245
+ }
246
+ return;
247
+ }
248
+
249
+ const context = this.buildContext(props);
250
+ state.context = context;
251
+
252
+ if (state.viewRef) {
253
+ // Update existing context
254
+ Object.assign(state.viewRef.context, context);
255
+ state.viewRef.markForCheck();
256
+ } else {
257
+ // Create new view
258
+ state.viewRef = this.vcr.createEmbeddedView(state.slotDef.templateRef, context);
259
+ this.insertView(slotName, state);
260
+ }
261
+
262
+ this.cdr.markForCheck();
263
+ }
264
+
265
+ // ─── DOM Insertion ──────────────────────────────────────────────────────
266
+
267
+ /** Inserts view root nodes into the custom element's DOM via a wrapper div */
268
+ private insertView(slotName: string, state: SlotState): void {
269
+ if (!state.viewRef) return;
270
+ const hostEl = this.el.nativeElement;
271
+
272
+ if (!state.wrapperEl) {
273
+ state.wrapperEl = document.createElement('div');
274
+ state.wrapperEl.setAttribute('slot', slotName);
275
+ state.wrapperEl.style.display = 'contents';
276
+ hostEl.appendChild(state.wrapperEl);
277
+ }
278
+
279
+ // Clear previous wrapper content and append new nodes
280
+ state.wrapperEl.innerHTML = '';
281
+ for (const node of state.viewRef.rootNodes) {
282
+ state.wrapperEl.appendChild(node);
283
+ }
284
+ }
285
+
286
+ // ─── Cleanup ────────────────────────────────────────────────────────────
287
+
288
+ /** Full cleanup on destroy */
289
+ private cleanup(): void {
290
+ // Destroy views, invoke cleanup functions, remove wrappers
291
+ for (const [, state] of this.slots) {
292
+ if (state.viewRef) {
293
+ state.viewRef.destroy();
294
+ }
295
+ if (state.cleanup) {
296
+ state.cleanup();
297
+ }
298
+ if (state.wrapperEl && state.wrapperEl.parentNode) {
299
+ state.wrapperEl.parentNode.removeChild(state.wrapperEl);
300
+ }
301
+ }
302
+ this.slots.clear();
303
+
304
+ // Remove event listeners
305
+ for (const fn of this.eventCleanups) {
306
+ fn();
307
+ }
308
+ this.eventCleanups = [];
309
+ }
310
+ }
@@ -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
@@ -962,10 +962,24 @@ export function generateComponent(parseResult, options = {}) {
962
962
  lines.push('');
963
963
  }
964
964
 
965
+ // Static __scopedSlots array (lists slot names with reactive props)
966
+ const scopedSlotNames = slots.filter(s => s.name && s.slotProps.length > 0).map(s => s.name);
967
+ if (scopedSlotNames.length > 0) {
968
+ const scopedArr = scopedSlotNames.map(n => `'${n}'`).join(', ');
969
+ lines.push(` static __scopedSlots = [${scopedArr}];`);
970
+ lines.push('');
971
+ }
972
+
965
973
  // Constructor — reactive state only (no DOM manipulation per Custom Elements spec)
966
974
  lines.push(' constructor() {');
967
975
  lines.push(' super();');
968
976
 
977
+ // Scoped slot storage initialization
978
+ if (scopedSlotNames.length > 0) {
979
+ lines.push(' this.__slotRenderers = {};');
980
+ lines.push(' this.__slotProps = {};');
981
+ }
982
+
969
983
  // Prop signal initialization (BEFORE user signals)
970
984
  for (const p of propDefs) {
971
985
  lines.push(` this._s_${p.name} = __signal(${p.default});`);
@@ -1277,11 +1291,20 @@ export function generateComponent(parseResult, options = {}) {
1277
1291
  lines.push(` if (this.__slotTpl_${s.name}) {`);
1278
1292
  lines.push(' __effect(() => {');
1279
1293
  lines.push(` const __props = { ${propsObj} };`);
1280
- lines.push(` let __html = this.__slotTpl_${s.name};`);
1281
- lines.push(" for (const [k, v] of Object.entries(__props)) {");
1282
- lines.push(` __html = __html.replace(new RegExp('(?:\\\\{\\\\{|\\\\{%)\\\\s*' + k + '(\\\\(\\\\))?\\\\s*(?:\\\\}\\\\}|%\\\\})', 'g'), v ?? '');`);
1294
+ // Task 3.1: Store current props in __slotProps
1295
+ lines.push(` this.__slotProps['${s.name}'] = __props;`);
1296
+ // Task 3.2: Emit wcc:slot-update event
1297
+ lines.push(` this.dispatchEvent(new CustomEvent('wcc:slot-update', { detail: { slot: '${s.name}', props: __props }, bubbles: false }));`);
1298
+ // Task 3.3: Check for registered renderer, skip token replacement if present
1299
+ lines.push(` if (this.__slotRenderers && this.__slotRenderers['${s.name}']) {`);
1300
+ lines.push(` this.__slotRenderers['${s.name}'](__props);`);
1301
+ lines.push(' } else {');
1302
+ lines.push(` let __html = this.__slotTpl_${s.name};`);
1303
+ lines.push(" for (const [k, v] of Object.entries(__props)) {");
1304
+ lines.push(` __html = __html.replace(new RegExp('(?:\\\\{\\\\{|\\\\{%)\\\\s*' + k + '(\\\\(\\\\))?\\\\s*(?:\\\\}\\\\}|%\\\\})', 'g'), v ?? '');`);
1305
+ lines.push(' }');
1306
+ lines.push(` this.${s.varName}.innerHTML = __html;`);
1283
1307
  lines.push(' }');
1284
- lines.push(` this.${s.varName}.innerHTML = __html;`);
1285
1308
  lines.push(' });');
1286
1309
  lines.push(' }');
1287
1310
  }
@@ -1769,6 +1792,25 @@ export function generateComponent(parseResult, options = {}) {
1769
1792
  lines.push('');
1770
1793
  }
1771
1794
 
1795
+ // __scopedSlots instance getter and registerSlotRenderer (if scoped slots exist)
1796
+ if (scopedSlotNames.length > 0) {
1797
+ lines.push(' get __scopedSlots() { return this.constructor.__scopedSlots || []; }');
1798
+ lines.push('');
1799
+ lines.push(' registerSlotRenderer(slotName, callback) {');
1800
+ lines.push(' if (!this.__slotRenderers) this.__slotRenderers = {};');
1801
+ lines.push(' this.__slotRenderers[slotName] = callback;');
1802
+ lines.push(' if (this.__slotProps && this.__slotProps[slotName]) {');
1803
+ lines.push(' callback(this.__slotProps[slotName]);');
1804
+ lines.push(' }');
1805
+ lines.push(' return () => {');
1806
+ lines.push(' if (this.__slotRenderers) {');
1807
+ lines.push(' delete this.__slotRenderers[slotName];');
1808
+ lines.push(' }');
1809
+ lines.push(' };');
1810
+ lines.push(' }');
1811
+ lines.push('');
1812
+ }
1813
+
1772
1814
  // User methods (prefixed with _)
1773
1815
  for (const m of methods) {
1774
1816
  const body = transformMethodBody(m.body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, refVarNames, constantNames, modelVarMap);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprlab/wccompiler",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
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",