@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.
- package/adapters/angular.js +70 -2
- package/adapters/angular.ts +310 -0
- package/integrations/react.js +727 -0
- package/lib/codegen.js +46 -4
- package/package.json +19 -7
package/adapters/angular.js
CHANGED
|
@@ -1,5 +1,73 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Angular adapter for WCC defineModel
|
|
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
|
+
}
|
package/integrations/react.js
CHANGED
|
@@ -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
|
-
|
|
1281
|
-
lines.push(
|
|
1282
|
-
|
|
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.
|
|
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
|
-
"
|
|
55
|
+
"vue": ">=3.0.0"
|
|
52
56
|
},
|
|
53
57
|
"peerDependenciesMeta": {
|
|
54
|
-
"@
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"@
|
|
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",
|