@sprlab/wccompiler 0.10.5 → 0.10.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -36
- package/adapters/react.js +23 -269
- package/integrations/react.js +95 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -192,6 +192,11 @@ watch(count, (newVal, oldVal) => {
|
|
|
192
192
|
watch(() => props.count, (newVal, oldVal) => {
|
|
193
193
|
console.log(`Prop changed: ${oldVal} → ${newVal}`)
|
|
194
194
|
})
|
|
195
|
+
|
|
196
|
+
// Watch a derived expression
|
|
197
|
+
watch(() => count() * 2, (newVal, oldVal) => {
|
|
198
|
+
console.log(`Doubled changed: ${oldVal} → ${newVal}`)
|
|
199
|
+
})
|
|
195
200
|
```
|
|
196
201
|
|
|
197
202
|
`watch` observes a specific signal or getter and provides both old and new values. The callback does not run on initial mount — only on subsequent changes.
|
|
@@ -248,6 +253,58 @@ const emit = defineEmits<{ (e: 'change', value: number): void }>()
|
|
|
248
253
|
|
|
249
254
|
The compiler validates emit calls against declared events at compile time.
|
|
250
255
|
|
|
256
|
+
## defineModel (Two-Way Binding)
|
|
257
|
+
|
|
258
|
+
`defineModel` declares a prop that supports two-way binding across frameworks:
|
|
259
|
+
|
|
260
|
+
```js
|
|
261
|
+
import { defineModel } from 'wcc'
|
|
262
|
+
|
|
263
|
+
const count = defineModel({ name: 'count', default: 0 })
|
|
264
|
+
const title = defineModel({ name: 'title', default: 'untitled' })
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Read and write like a signal:
|
|
268
|
+
```js
|
|
269
|
+
count() // read current value
|
|
270
|
+
count.set(5) // write — updates internal state + emits events
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
**Events emitted on write:**
|
|
274
|
+
|
|
275
|
+
| Event | Purpose |
|
|
276
|
+
|-------|---------|
|
|
277
|
+
| `count-changed` | Kebab-case — for Vue plugin, addEventListener |
|
|
278
|
+
| `countChanged` | camelCase — for Angular, direct binding |
|
|
279
|
+
| `countChange` | Angular `[(count)]` banana-box syntax |
|
|
280
|
+
| `wcc:model` | Generic — for vanilla JS and WCC-to-WCC |
|
|
281
|
+
|
|
282
|
+
**Usage per framework:**
|
|
283
|
+
|
|
284
|
+
```vue
|
|
285
|
+
<!-- Vue (with plugin) -->
|
|
286
|
+
<wcc-counter v-model:count="ref"></wcc-counter>
|
|
287
|
+
|
|
288
|
+
<!-- Vue (without plugin) -->
|
|
289
|
+
<wcc-counter :count="ref" @count-changed="ref = $event.detail"></wcc-counter>
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
```jsx
|
|
293
|
+
// React (with wrapper)
|
|
294
|
+
<WccCounter count={count} onCountChange={(value) => setCount(value)} />
|
|
295
|
+
|
|
296
|
+
// React (native CE)
|
|
297
|
+
<wcc-counter count={count} oncountchanged={(e) => setCount(e.detail)} />
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
```html
|
|
301
|
+
<!-- Angular (zero-config two-way) -->
|
|
302
|
+
<wcc-counter [(count)]="signal"></wcc-counter>
|
|
303
|
+
|
|
304
|
+
<!-- Angular (manual) -->
|
|
305
|
+
<wcc-counter [count]="signal()" (countChange)="signal.set($event.detail)"></wcc-counter>
|
|
306
|
+
```
|
|
307
|
+
|
|
251
308
|
## Template Directives
|
|
252
309
|
|
|
253
310
|
### Text Interpolation
|
|
@@ -638,63 +695,96 @@ Each standalone component has its own isolated reactive runtime. Signals from co
|
|
|
638
695
|
|
|
639
696
|
## Framework Integrations
|
|
640
697
|
|
|
641
|
-
WCC components are native custom elements — they work in any framework.
|
|
698
|
+
WCC components are native custom elements — they work in any framework. Props, events, and named slots work natively with zero WCC-specific config. Two-way binding is zero-config in Angular; Vue and React require a lightweight plugin. Scoped slots require a framework plugin for idiomatic syntax.
|
|
642
699
|
|
|
643
|
-
###
|
|
700
|
+
### Zero-Config (works immediately)
|
|
701
|
+
|
|
702
|
+
Just import the compiled component and use it:
|
|
703
|
+
|
|
704
|
+
```html
|
|
705
|
+
<script type="module" src="dist/wcc-counter.js"></script>
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
**Vue:**
|
|
709
|
+
```vue
|
|
710
|
+
<wcc-counter :count="ref" @count-changed="handler($event.detail)">
|
|
711
|
+
<div slot="footer">Footer content</div>
|
|
712
|
+
</wcc-counter>
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
**React 19:**
|
|
716
|
+
```jsx
|
|
717
|
+
<wcc-counter count={state} onCountchanged={(e) => handler(e.detail)}>
|
|
718
|
+
<div slot="footer">Footer content</div>
|
|
719
|
+
</wcc-counter>
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
**Angular:**
|
|
723
|
+
```html
|
|
724
|
+
<wcc-counter [count]="signal()" (count-changed)="handler($event.detail)" [(count)]="signal">
|
|
725
|
+
<div slot="footer">Footer content</div>
|
|
726
|
+
</wcc-counter>
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
### Vue Plugin (v-model, modifiers, scoped slots)
|
|
644
730
|
|
|
645
731
|
```js
|
|
646
732
|
// vite.config.js
|
|
647
733
|
import { wccVuePlugin } from '@sprlab/wccompiler/integrations/vue'
|
|
734
|
+
export default defineConfig({ plugins: [wccVuePlugin()] })
|
|
735
|
+
```
|
|
648
736
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
737
|
+
```vue
|
|
738
|
+
<wcc-input v-model="text" v-model:count.number="count"></wcc-input>
|
|
739
|
+
<wcc-card>
|
|
740
|
+
<template #header><strong>Title</strong></template>
|
|
741
|
+
<template #stats="{ likes }">{{ likes }} likes</template>
|
|
742
|
+
</wcc-card>
|
|
655
743
|
```
|
|
656
744
|
|
|
657
|
-
|
|
745
|
+
The plugin is needed for: v-model:prop (Vue assigns raw Event, not detail), v-model modifiers (.trim, .number), and scoped slot syntax (`{{prop}}` → `{%prop%}` escape).
|
|
746
|
+
|
|
747
|
+
Without the plugin, use `:prop` + `@prop-changed` for two-way binding manually.
|
|
748
|
+
|
|
749
|
+
### React Plugin + Wrappers (scoped slots, typed events, two-way)
|
|
658
750
|
|
|
659
|
-
|
|
751
|
+
```js
|
|
752
|
+
// vite.config.js
|
|
753
|
+
import { wccReactPlugin } from '@sprlab/wccompiler/integrations/react'
|
|
754
|
+
import react from '@vitejs/plugin-react'
|
|
755
|
+
export default defineConfig({ plugins: [wccReactPlugin(), react()] })
|
|
756
|
+
```
|
|
660
757
|
|
|
661
758
|
```jsx
|
|
662
|
-
|
|
663
|
-
import {
|
|
664
|
-
|
|
665
|
-
function App() {
|
|
666
|
-
// Form 1: Pass an existing ref
|
|
667
|
-
const counterRef = useRef(null)
|
|
668
|
-
useWccEvent(counterRef, 'change', (e) => console.log(e.detail))
|
|
669
|
-
return <wcc-counter ref={counterRef}></wcc-counter>
|
|
670
|
-
}
|
|
759
|
+
// Auto-generated wrappers — events unwrapped, React-idiomatic naming
|
|
760
|
+
import { createWccWrappers } from '@sprlab/wccompiler/adapters/react'
|
|
761
|
+
const { WccCounter, WccCard } = createWccWrappers()
|
|
671
762
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
}
|
|
763
|
+
<WccCounter count={count} onCountChange={(value) => setCount(value)} />
|
|
764
|
+
<WccCard renderStats={(likes) => <span>{likes} likes</span>}>
|
|
765
|
+
<p>Body</p>
|
|
766
|
+
</WccCard>
|
|
677
767
|
```
|
|
678
768
|
|
|
679
|
-
|
|
769
|
+
Wrappers read component metadata (`static __meta`) automatically — no manual event/model config needed.
|
|
770
|
+
|
|
771
|
+
### Angular Directive (scoped slots only)
|
|
680
772
|
|
|
681
|
-
|
|
773
|
+
Angular needs no plugin for props, events, or two-way binding. The directive is only needed for scoped slots:
|
|
682
774
|
|
|
683
775
|
```ts
|
|
684
|
-
import {
|
|
776
|
+
import { WccSlotsDirective, WccSlotDef } from '@sprlab/wccompiler/adapters/angular'
|
|
685
777
|
|
|
686
|
-
// Standalone component (Angular 17+)
|
|
687
778
|
@Component({
|
|
779
|
+
imports: [WccSlotsDirective, WccSlotDef],
|
|
688
780
|
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
|
689
|
-
template:
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
|
781
|
+
template: `
|
|
782
|
+
<wcc-card wccSlots>
|
|
783
|
+
<ng-template slot="header"><strong>Title</strong></ng-template>
|
|
784
|
+
<ng-template slot="stats" let-likes>{{ likes }} likes</ng-template>
|
|
785
|
+
</wcc-card>
|
|
786
|
+
`
|
|
696
787
|
})
|
|
697
|
-
export class AppModule {}
|
|
698
788
|
```
|
|
699
789
|
|
|
700
790
|
### Vanilla
|
package/adapters/react.js
CHANGED
|
@@ -4,25 +4,24 @@
|
|
|
4
4
|
*
|
|
5
5
|
* @module @sprlab/wccompiler/adapters/react
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* NOTE: These hooks are optional utilities for React 18 or edge cases.
|
|
8
|
+
* With React 19 + wccReactPlugin, WCC components work natively:
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* <WccCounter count={state} oncountchanged={(e) => setState(e.detail)} />
|
|
11
|
+
* <WccCard>
|
|
12
|
+
* <WccCard.Header><strong>Title</strong></WccCard.Header>
|
|
13
|
+
* </WccCard>
|
|
12
14
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* <wcc-counter ref={ref}></wcc-counter>
|
|
15
|
+
* The plugin handles PascalCase → kebab-case, compound components, and
|
|
16
|
+
* scoped slots at build time. No runtime wrappers needed.
|
|
16
17
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* })
|
|
22
|
-
* <WccCounter onChange={handler} count={count} onCountChanged={setCount} />
|
|
18
|
+
* These hooks are for cases where you need manual event bridging
|
|
19
|
+
* (e.g., React 18, non-Vite builds, or dynamic event names):
|
|
20
|
+
*
|
|
21
|
+
* import { useWccEvent, useWccModel } from '@sprlab/wccompiler/adapters/react'
|
|
23
22
|
*/
|
|
24
23
|
|
|
25
|
-
import
|
|
24
|
+
import { useRef, useEffect } from 'react'
|
|
26
25
|
|
|
27
26
|
/**
|
|
28
27
|
* Hook that attaches a CustomEvent listener to a DOM element via ref.
|
|
@@ -35,6 +34,16 @@ import React, { useRef, useEffect } from 'react'
|
|
|
35
34
|
* @param {string | ((event: CustomEvent) => void)} eventNameOrHandler
|
|
36
35
|
* @param {((event: CustomEvent) => void)} [handler]
|
|
37
36
|
* @returns {import('react').RefObject<HTMLElement> | void}
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* // Form 1: Let the hook create the ref
|
|
40
|
+
* const ref = useWccEvent('count-changed', (e) => setCount(e.detail))
|
|
41
|
+
* <wcc-counter ref={ref}></wcc-counter>
|
|
42
|
+
*
|
|
43
|
+
* // Form 2: Pass an existing ref
|
|
44
|
+
* const myRef = useRef(null)
|
|
45
|
+
* useWccEvent(myRef, 'count-changed', (e) => setCount(e.detail))
|
|
46
|
+
* <wcc-counter ref={myRef}></wcc-counter>
|
|
38
47
|
*/
|
|
39
48
|
export function useWccEvent(refOrEventName, eventNameOrHandler, handler) {
|
|
40
49
|
const isRefForm = typeof refOrEventName !== 'string'
|
|
@@ -103,258 +112,3 @@ export function useWccModel(propName, value, setValue, existingRef) {
|
|
|
103
112
|
|
|
104
113
|
return elementRef
|
|
105
114
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Converts a kebab-case event name to a React-idiomatic prop name.
|
|
110
|
-
*
|
|
111
|
-
* Rules:
|
|
112
|
-
* - 'change' → 'onChange'
|
|
113
|
-
* - 'count-changed' → 'onCountChange' (strips trailing 'd' from past tense)
|
|
114
|
-
* - 'value-updated' → 'onValueUpdate' (strips trailing 'd')
|
|
115
|
-
* - 'reset' → 'onReset'
|
|
116
|
-
* - 'item-click' → 'onItemClick'
|
|
117
|
-
*
|
|
118
|
-
* @param {string} eventName - kebab-case event name
|
|
119
|
-
* @returns {string} React prop name (onCamelCase)
|
|
120
|
-
*/
|
|
121
|
-
function toReactEventProp(eventName) {
|
|
122
|
-
const parts = eventName.split('-')
|
|
123
|
-
const camel = parts.map(s => s[0].toUpperCase() + s.slice(1)).join('')
|
|
124
|
-
// Strip trailing 'd' from past tense verbs (changed→Change, updated→Update)
|
|
125
|
-
const normalized = camel.replace(/(Changed|Updated|Removed|Added|Closed|Opened|Submitted|Cancelled)$/, (m) => m.slice(0, -1))
|
|
126
|
-
return 'on' + normalized
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Creates a React wrapper component for a WCC custom element.
|
|
131
|
-
*
|
|
132
|
-
* The wrapper provides idiomatic React DX:
|
|
133
|
-
* - Event props: `onChange`, `onCountChange` → automatically wired via addEventListener
|
|
134
|
-
* Handlers receive the unwrapped value (event.detail), not the raw CustomEvent.
|
|
135
|
-
* - Model props: two-way binding via attribute + event listener
|
|
136
|
-
* - Regular props: passed as attributes on the custom element
|
|
137
|
-
* - Children: passed through as-is (use `<div slot="name">` for named slots)
|
|
138
|
-
* - Ref forwarding: supports React refs via forwardRef
|
|
139
|
-
*
|
|
140
|
-
* @param {string} tagName - The custom element tag name (e.g., 'wcc-card')
|
|
141
|
-
* @param {Object} [config] - Configuration for the wrapper
|
|
142
|
-
* @param {string[]} [config.events] - Custom event names to expose as onEventName props
|
|
143
|
-
* Event names are converted: 'count-changed' → onCountChange prop (React convention)
|
|
144
|
-
* @param {string[]} [config.models] - Model prop names for two-way binding
|
|
145
|
-
* Each model 'name' creates: `name` prop (sets attribute) + `onNameChange` event
|
|
146
|
-
* @returns {import('react').ForwardRefExoticComponent} A React component
|
|
147
|
-
*
|
|
148
|
-
* @example
|
|
149
|
-
* const WccCounter = createWccWrapper('wcc-counter', {
|
|
150
|
-
* events: ['change'],
|
|
151
|
-
* models: ['count']
|
|
152
|
-
* })
|
|
153
|
-
*
|
|
154
|
-
* function App() {
|
|
155
|
-
* const [count, setCount] = useState(0)
|
|
156
|
-
* return (
|
|
157
|
-
* <WccCounter
|
|
158
|
-
* count={count}
|
|
159
|
-
* onCountChange={(value) => setCount(value)}
|
|
160
|
-
* onChange={(value) => console.log('changed', value)}
|
|
161
|
-
* label="Clicks"
|
|
162
|
-
* >
|
|
163
|
-
* <div slot="footer">Footer content</div>
|
|
164
|
-
* </WccCounter>
|
|
165
|
-
* )
|
|
166
|
-
* }
|
|
167
|
-
*/
|
|
168
|
-
export function createWccWrapper(tagName, config = {}) {
|
|
169
|
-
const { events = [], models = [] } = config
|
|
170
|
-
|
|
171
|
-
// Build a set of event prop names for quick lookup
|
|
172
|
-
// Convention: kebab-case event → React onCamelCase (without trailing 'd' from 'changed')
|
|
173
|
-
// 'count-changed' → 'onCountChange' (not 'onCountChanged')
|
|
174
|
-
// 'change' → 'onChange'
|
|
175
|
-
// 'value-updated' → 'onValueUpdate' (strips trailing 'd' from past tense)
|
|
176
|
-
const eventPropMap = new Map()
|
|
177
|
-
for (const eventName of events) {
|
|
178
|
-
const propName = toReactEventProp(eventName)
|
|
179
|
-
eventPropMap.set(propName, eventName)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Model events: 'count' → 'count-changed' → 'onCountChange'
|
|
183
|
-
const modelEventMap = new Map()
|
|
184
|
-
for (const modelName of models) {
|
|
185
|
-
const eventName = `${modelName}-changed`
|
|
186
|
-
const propName = toReactEventProp(eventName)
|
|
187
|
-
eventPropMap.set(propName, eventName)
|
|
188
|
-
modelEventMap.set(modelName, eventName)
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Reserved prop names that should not be passed as attributes
|
|
192
|
-
const SKIP_PROPS = new Set(['children', 'key', 'ref', 'style', 'className', 'dangerouslySetInnerHTML'])
|
|
193
|
-
|
|
194
|
-
const WccWrapper = React.forwardRef(function WccWrapper(props, externalRef) {
|
|
195
|
-
const internalRef = useRef(null)
|
|
196
|
-
const ref = externalRef || internalRef
|
|
197
|
-
|
|
198
|
-
// Store event handlers in a ref to avoid re-subscribing on every render
|
|
199
|
-
const handlersRef = useRef({})
|
|
200
|
-
|
|
201
|
-
// Collect event handlers and regular props
|
|
202
|
-
const regularProps = {}
|
|
203
|
-
const eventHandlers = {}
|
|
204
|
-
|
|
205
|
-
for (const [key, value] of Object.entries(props)) {
|
|
206
|
-
if (SKIP_PROPS.has(key)) continue
|
|
207
|
-
|
|
208
|
-
if (eventPropMap.has(key)) {
|
|
209
|
-
eventHandlers[eventPropMap.get(key)] = value
|
|
210
|
-
} else if (key.startsWith('on') && key.length > 2 && key[2] >= 'A' && key[2] <= 'Z') {
|
|
211
|
-
// Generic React event handler pattern: onClick, onFocus, etc.
|
|
212
|
-
// Convert onSomething → 'something' (lowercase first char)
|
|
213
|
-
const nativeEvent = key[2].toLowerCase() + key.slice(3)
|
|
214
|
-
eventHandlers[nativeEvent] = value
|
|
215
|
-
} else {
|
|
216
|
-
regularProps[key] = value
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// Update handlers ref
|
|
221
|
-
handlersRef.current = eventHandlers
|
|
222
|
-
|
|
223
|
-
// Subscribe to custom events
|
|
224
|
-
useEffect(() => {
|
|
225
|
-
const el = typeof ref === 'function' ? null : ref?.current
|
|
226
|
-
if (!el) return
|
|
227
|
-
|
|
228
|
-
const listeners = []
|
|
229
|
-
const allEvents = new Set([...eventPropMap.values(), ...Object.keys(eventHandlers)])
|
|
230
|
-
|
|
231
|
-
for (const eventName of allEvents) {
|
|
232
|
-
const listener = (e) => {
|
|
233
|
-
const handler = handlersRef.current[eventName]
|
|
234
|
-
if (handler) handler(e instanceof CustomEvent ? e.detail : e)
|
|
235
|
-
}
|
|
236
|
-
el.addEventListener(eventName, listener)
|
|
237
|
-
listeners.push([eventName, listener])
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
return () => {
|
|
241
|
-
for (const [name, listener] of listeners) {
|
|
242
|
-
el.removeEventListener(name, listener)
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
246
|
-
|
|
247
|
-
// Sync regular props as attributes
|
|
248
|
-
useEffect(() => {
|
|
249
|
-
const el = typeof ref === 'function' ? null : ref?.current
|
|
250
|
-
if (!el) return
|
|
251
|
-
|
|
252
|
-
for (const [key, value] of Object.entries(regularProps)) {
|
|
253
|
-
if (value == null || value === false) {
|
|
254
|
-
el.removeAttribute(key)
|
|
255
|
-
} else if (value === true) {
|
|
256
|
-
el.setAttribute(key, '')
|
|
257
|
-
} else {
|
|
258
|
-
el.setAttribute(key, String(value))
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
})
|
|
262
|
-
|
|
263
|
-
// Build the element props for React's createElement
|
|
264
|
-
const elementProps = { ref }
|
|
265
|
-
if (props.style) elementProps.style = props.style
|
|
266
|
-
if (props.className) elementProps.className = props.className
|
|
267
|
-
|
|
268
|
-
return React.createElement(tagName, elementProps, props.children)
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
WccWrapper.displayName = tagName.split('-').map(s => s[0].toUpperCase() + s.slice(1)).join('')
|
|
272
|
-
|
|
273
|
-
return WccWrapper
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* Creates a React wrapper from a WCC component class that has `static __meta`.
|
|
280
|
-
*
|
|
281
|
-
* Unlike `createWccWrapper` which requires manual event/model configuration,
|
|
282
|
-
* this function reads the metadata directly from the compiled component class.
|
|
283
|
-
*
|
|
284
|
-
* @param {Function} WccClass - The WCC custom element class (must have static __meta)
|
|
285
|
-
* @returns {import('react').ForwardRefExoticComponent} A React component
|
|
286
|
-
*
|
|
287
|
-
* @example
|
|
288
|
-
* import { wrapWccComponent } from '@sprlab/wccompiler/adapters/react'
|
|
289
|
-
* import '../wcc-components/wcc-counter.js' // registers the custom element
|
|
290
|
-
*
|
|
291
|
-
* // Read metadata directly from the registered class
|
|
292
|
-
* const WccCounter = wrapWccComponent(customElements.get('wcc-counter'))
|
|
293
|
-
*
|
|
294
|
-
* // Use idiomatically — no manual config needed
|
|
295
|
-
* // Handlers receive the value directly (not the event)
|
|
296
|
-
* <WccCounter count={count} onCountChange={setCount} onChange={(val) => console.log(val)} />
|
|
297
|
-
*/
|
|
298
|
-
export function wrapWccComponent(WccClass) {
|
|
299
|
-
const meta = WccClass?.__meta
|
|
300
|
-
if (!meta) {
|
|
301
|
-
throw new Error(`wrapWccComponent: class does not have static __meta. Is it a compiled WCC component?`)
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
return createWccWrapper(meta.tag, {
|
|
305
|
-
events: meta.events || [],
|
|
306
|
-
models: meta.models || [],
|
|
307
|
-
})
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Creates React wrappers for all registered WCC custom elements matching a prefix.
|
|
312
|
-
*
|
|
313
|
-
* Scans the custom elements registry for components with `static __meta` and
|
|
314
|
-
* generates typed wrapper components for each one.
|
|
315
|
-
*
|
|
316
|
-
* @param {Object} [options]
|
|
317
|
-
* @param {string} [options.prefix='wcc-'] - Tag prefix to filter components
|
|
318
|
-
* @returns {Record<string, import('react').ForwardRefExoticComponent>} Map of PascalCase name → React component
|
|
319
|
-
*
|
|
320
|
-
* @example
|
|
321
|
-
* // In your app entry point, after importing all WCC components:
|
|
322
|
-
* import '../wcc-components/wcc-counter.js'
|
|
323
|
-
* import '../wcc-components/wcc-card.js'
|
|
324
|
-
* import { createWccWrappers } from '@sprlab/wccompiler/adapters/react'
|
|
325
|
-
*
|
|
326
|
-
* export const { WccCounter, WccCard } = createWccWrappers()
|
|
327
|
-
*
|
|
328
|
-
* // Then use anywhere:
|
|
329
|
-
* <WccCounter count={count} onCountChanged={setCount} />
|
|
330
|
-
* <WccCard><div slot="header">Title</div></WccCard>
|
|
331
|
-
*/
|
|
332
|
-
export function createWccWrappers(options = {}) {
|
|
333
|
-
const { prefix = 'wcc-' } = options
|
|
334
|
-
const wrappers = {}
|
|
335
|
-
|
|
336
|
-
// Note: customElements registry doesn't have a list API,
|
|
337
|
-
// so we need the component files to be imported first (which registers them).
|
|
338
|
-
// This function is meant to be called after all component imports.
|
|
339
|
-
|
|
340
|
-
// We'll use a Proxy that lazily creates wrappers on first access
|
|
341
|
-
return new Proxy(wrappers, {
|
|
342
|
-
get(target, prop) {
|
|
343
|
-
if (typeof prop !== 'string') return undefined
|
|
344
|
-
if (prop in target) return target[prop]
|
|
345
|
-
|
|
346
|
-
// Convert PascalCase to kebab-case: WccCounter → wcc-counter
|
|
347
|
-
const kebab = prop.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')
|
|
348
|
-
|
|
349
|
-
// Check if it starts with the prefix
|
|
350
|
-
if (!kebab.startsWith(prefix)) return undefined
|
|
351
|
-
|
|
352
|
-
const ctor = customElements.get(kebab)
|
|
353
|
-
if (!ctor || !(ctor).__meta) return undefined
|
|
354
|
-
|
|
355
|
-
const wrapper = wrapWccComponent(ctor)
|
|
356
|
-
target[prop] = wrapper
|
|
357
|
-
return wrapper
|
|
358
|
-
}
|
|
359
|
-
})
|
|
360
|
-
}
|
package/integrations/react.js
CHANGED
|
@@ -643,9 +643,102 @@ export function wccReactPlugin(options = {}) {
|
|
|
643
643
|
const openingElement = path.node.openingElement
|
|
644
644
|
const nameNode = openingElement.name
|
|
645
645
|
|
|
646
|
+
// ── Compound component transform ──
|
|
647
|
+
// <WccCard.Header>children</WccCard.Header> → <div slot="header" style={{display:'contents'}}>children</div>
|
|
648
|
+
// <WccCard.Stats>{(likes) => <span>{likes}</span>}</WccCard.Stats> → scoped slot div
|
|
649
|
+
if (nameNode.type === 'JSXMemberExpression') {
|
|
650
|
+
const objectName = nameNode.object?.name // e.g., 'WccCard'
|
|
651
|
+
const propName = nameNode.property?.name // e.g., 'Header'
|
|
652
|
+
if (!objectName || !propName) return
|
|
653
|
+
|
|
654
|
+
// Convert PascalCase object to kebab-case and check if it's a custom element
|
|
655
|
+
const kebab = objectName.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')
|
|
656
|
+
if (!kebab.includes('-')) return
|
|
657
|
+
if (prefix && !kebab.startsWith(prefix)) return
|
|
658
|
+
|
|
659
|
+
// Derive slot name: Header → header, FooterNav → footerNav (lcfirst)
|
|
660
|
+
const slotName = propName[0].toLowerCase() + propName.slice(1)
|
|
661
|
+
|
|
662
|
+
// Check if children is a function (scoped slot / render prop pattern)
|
|
663
|
+
const children = path.node.children
|
|
664
|
+
const isScopedSlot = children.length === 1
|
|
665
|
+
&& children[0].type === 'JSXExpressionContainer'
|
|
666
|
+
&& children[0].expression.type === 'ArrowFunctionExpression'
|
|
667
|
+
|
|
668
|
+
if (isScopedSlot) {
|
|
669
|
+
// Scoped slot: <WccCard.Stats>{(likes) => <span>{likes}</span>}</WccCard.Stats>
|
|
670
|
+
const arrowFn = children[0].expression
|
|
671
|
+
const params = (arrowFn.params || []).map(p => p.name || '')
|
|
672
|
+
const body = arrowFn.body
|
|
673
|
+
|
|
674
|
+
// Warn on unsupported expressions
|
|
675
|
+
const renderWarnings = []
|
|
676
|
+
serializeJsxToHtml(body, params, renderWarnings)
|
|
677
|
+
if (renderWarnings.length > 0) {
|
|
678
|
+
pluginCtx.warn(`[wcc-react] ${id} — ${objectName}.${propName}: ${renderWarnings[0]}`)
|
|
679
|
+
return
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Replace with scoped slot element
|
|
683
|
+
const scopedEl = generateScopedSlotElement(slotName, params, body)
|
|
684
|
+
path.replaceWith(scopedEl)
|
|
685
|
+
transformed = true
|
|
686
|
+
} else {
|
|
687
|
+
// Named slot: <WccCard.Header><strong>Title</strong></WccCard.Header>
|
|
688
|
+
// → <div slot="header" style={{display:'contents'}}>children</div>
|
|
689
|
+
const slotAttr = {
|
|
690
|
+
type: 'JSXAttribute',
|
|
691
|
+
name: { type: 'JSXIdentifier', name: 'slot' },
|
|
692
|
+
value: { type: 'StringLiteral', value: slotName }
|
|
693
|
+
}
|
|
694
|
+
const styleAttr = {
|
|
695
|
+
type: 'JSXAttribute',
|
|
696
|
+
name: { type: 'JSXIdentifier', name: 'style' },
|
|
697
|
+
value: {
|
|
698
|
+
type: 'JSXExpressionContainer',
|
|
699
|
+
expression: {
|
|
700
|
+
type: 'ObjectExpression',
|
|
701
|
+
properties: [{
|
|
702
|
+
type: 'ObjectProperty',
|
|
703
|
+
key: { type: 'Identifier', name: 'display' },
|
|
704
|
+
value: { type: 'StringLiteral', value: 'contents' },
|
|
705
|
+
computed: false,
|
|
706
|
+
shorthand: false
|
|
707
|
+
}]
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
openingElement.name = { type: 'JSXIdentifier', name: 'div' }
|
|
713
|
+
openingElement.attributes = [...openingElement.attributes, slotAttr, styleAttr]
|
|
714
|
+
if (path.node.closingElement) {
|
|
715
|
+
path.node.closingElement.name = { type: 'JSXIdentifier', name: 'div' }
|
|
716
|
+
}
|
|
717
|
+
transformed = true
|
|
718
|
+
}
|
|
719
|
+
return
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// ── PascalCase custom element transform ──
|
|
723
|
+
// <WccCard> → <wcc-card> (only if it maps to a hyphenated tag)
|
|
724
|
+
if (nameNode.type === 'JSXIdentifier' && /^[A-Z]/.test(nameNode.name)) {
|
|
725
|
+
const kebab = nameNode.name.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')
|
|
726
|
+
if (!kebab.includes('-')) return
|
|
727
|
+
if (prefix && !kebab.startsWith(prefix)) return
|
|
728
|
+
|
|
729
|
+
// Rewrite tag name to kebab-case
|
|
730
|
+
openingElement.name = { type: 'JSXIdentifier', name: kebab }
|
|
731
|
+
if (path.node.closingElement) {
|
|
732
|
+
path.node.closingElement.name = { type: 'JSXIdentifier', name: kebab }
|
|
733
|
+
}
|
|
734
|
+
transformed = true
|
|
735
|
+
// Fall through to process props on this element
|
|
736
|
+
}
|
|
737
|
+
|
|
646
738
|
// Only process elements with hyphenated tag names (custom elements)
|
|
647
|
-
|
|
648
|
-
|
|
739
|
+
const currentName = openingElement.name
|
|
740
|
+
if (currentName.type !== 'JSXIdentifier') return
|
|
741
|
+
const tagName = currentName.name
|
|
649
742
|
if (!tagName.includes('-')) return
|
|
650
743
|
|
|
651
744
|
// Apply prefix filtering if set
|
package/package.json
CHANGED