@sprlab/wccompiler 0.10.6 → 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/adapters/react.js CHANGED
@@ -4,25 +4,24 @@
4
4
  *
5
5
  * @module @sprlab/wccompiler/adapters/react
6
6
  *
7
- * IMPORTANT: Import hooks from THIS file (not from integrations/react).
8
- * The integrations/react file is for vite.config.js only (contains Babel).
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
- * Usage:
11
- * import { useWccEvent, useWccModel, createWccWrapper } from '@sprlab/wccompiler/adapters/react'
10
+ * <WccCounter count={state} oncountchanged={(e) => setState(e.detail)} />
11
+ * <WccCard>
12
+ * <WccCard.Header><strong>Title</strong></WccCard.Header>
13
+ * </WccCard>
12
14
  *
13
- * // Option A: Low-level hooks (full control)
14
- * const ref = useWccEvent('change', (e) => console.log(e.detail))
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
- * // Option B: Wrapper components (idiomatic React DX)
18
- * const WccCounter = createWccWrapper('wcc-counter', {
19
- * events: ['change'],
20
- * models: ['count']
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 React, { useRef, useEffect } from 'react'
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,286 +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
- * @param {string[]} [config.slots] - Named slot names for compound sub-components
147
- * Each slot 'name' creates a `.Name` sub-component that renders `<div slot="name">`
148
- * @returns {import('react').ForwardRefExoticComponent} A React component with compound sub-components
149
- *
150
- * @example
151
- * const WccCounter = createWccWrapper('wcc-counter', {
152
- * events: ['change'],
153
- * models: ['count'],
154
- * slots: ['header', 'footer']
155
- * })
156
- *
157
- * function App() {
158
- * const [count, setCount] = useState(0)
159
- * return (
160
- * <WccCounter
161
- * count={count}
162
- * onCountChange={(value) => setCount(value)}
163
- * onChange={(value) => console.log('changed', value)}
164
- * label="Clicks"
165
- * >
166
- * <WccCounter.Header><strong>Title</strong></WccCounter.Header>
167
- * <p>Body content</p>
168
- * <WccCounter.Footer>Footer text</WccCounter.Footer>
169
- * </WccCounter>
170
- * )
171
- * }
172
- */
173
- export function createWccWrapper(tagName, config = {}) {
174
- const { events = [], models = [], slots = [] } = config
175
-
176
- // Build a set of event prop names for quick lookup
177
- // Convention: kebab-case event → React onCamelCase (without trailing 'd' from 'changed')
178
- // 'count-changed' → 'onCountChange' (not 'onCountChanged')
179
- // 'change' → 'onChange'
180
- // 'value-updated' → 'onValueUpdate' (strips trailing 'd' from past tense)
181
- const eventPropMap = new Map()
182
- for (const eventName of events) {
183
- const propName = toReactEventProp(eventName)
184
- eventPropMap.set(propName, eventName)
185
- }
186
-
187
- // Model events: 'count' → 'count-changed' → 'onCountChange'
188
- const modelEventMap = new Map()
189
- for (const modelName of models) {
190
- const eventName = `${modelName}-changed`
191
- const propName = toReactEventProp(eventName)
192
- eventPropMap.set(propName, eventName)
193
- modelEventMap.set(modelName, eventName)
194
- }
195
-
196
- // Reserved prop names that should not be passed as attributes
197
- const SKIP_PROPS = new Set(['children', 'key', 'ref', 'style', 'className', 'dangerouslySetInnerHTML'])
198
-
199
- const WccWrapper = React.forwardRef(function WccWrapper(props, externalRef) {
200
- const internalRef = useRef(null)
201
- const ref = externalRef || internalRef
202
-
203
- // Store event handlers in a ref to avoid re-subscribing on every render
204
- const handlersRef = useRef({})
205
-
206
- // Collect event handlers and regular props
207
- const regularProps = {}
208
- const eventHandlers = {}
209
-
210
- for (const [key, value] of Object.entries(props)) {
211
- if (SKIP_PROPS.has(key)) continue
212
-
213
- if (eventPropMap.has(key)) {
214
- eventHandlers[eventPropMap.get(key)] = value
215
- } else if (key.startsWith('on') && key.length > 2 && key[2] >= 'A' && key[2] <= 'Z') {
216
- // Generic React event handler pattern: onClick, onFocus, etc.
217
- // Convert onSomething → 'something' (lowercase first char)
218
- const nativeEvent = key[2].toLowerCase() + key.slice(3)
219
- eventHandlers[nativeEvent] = value
220
- } else {
221
- regularProps[key] = value
222
- }
223
- }
224
-
225
- // Update handlers ref
226
- handlersRef.current = eventHandlers
227
-
228
- // Subscribe to custom events
229
- useEffect(() => {
230
- const el = typeof ref === 'function' ? null : ref?.current
231
- if (!el) return
232
-
233
- const listeners = []
234
- const allEvents = new Set([...eventPropMap.values(), ...Object.keys(eventHandlers)])
235
-
236
- for (const eventName of allEvents) {
237
- const listener = (e) => {
238
- const handler = handlersRef.current[eventName]
239
- if (handler) handler(e instanceof CustomEvent ? e.detail : e)
240
- }
241
- el.addEventListener(eventName, listener)
242
- listeners.push([eventName, listener])
243
- }
244
-
245
- return () => {
246
- for (const [name, listener] of listeners) {
247
- el.removeEventListener(name, listener)
248
- }
249
- }
250
- }, []) // eslint-disable-line react-hooks/exhaustive-deps
251
-
252
- // Sync regular props as attributes
253
- useEffect(() => {
254
- const el = typeof ref === 'function' ? null : ref?.current
255
- if (!el) return
256
-
257
- for (const [key, value] of Object.entries(regularProps)) {
258
- if (value == null || value === false) {
259
- el.removeAttribute(key)
260
- } else if (value === true) {
261
- el.setAttribute(key, '')
262
- } else {
263
- el.setAttribute(key, String(value))
264
- }
265
- }
266
- })
267
-
268
- // Build the element props for React's createElement
269
- const elementProps = { ref }
270
- if (props.style) elementProps.style = props.style
271
- if (props.className) elementProps.className = props.className
272
-
273
- return React.createElement(tagName, elementProps, props.children)
274
- })
275
-
276
- WccWrapper.displayName = tagName.split('-').map(s => s[0].toUpperCase() + s.slice(1)).join('')
277
-
278
- // Compound components: generate .SlotName sub-components for each named slot
279
- // This enables the pattern: <WccLayout.Header>content</WccLayout.Header>
280
- // which renders as: <div slot="header">content</div>
281
- for (const slotName of slots) {
282
- if (!slotName) continue // skip default slot
283
- const pascalSlot = slotName[0].toUpperCase() + slotName.slice(1)
284
- const SlotComponent = function WccSlot({ children, ...rest }) {
285
- const slotProps = { slot: slotName, style: { display: 'contents' } }
286
- // Pass through any extra props as attributes on the wrapper div
287
- for (const [key, value] of Object.entries(rest)) {
288
- slotProps[key] = value
289
- }
290
- return React.createElement('div', slotProps, children)
291
- }
292
- SlotComponent.displayName = `${WccWrapper.displayName}.${pascalSlot}`
293
- WccWrapper[pascalSlot] = SlotComponent
294
- }
295
-
296
- return WccWrapper
297
- }
298
-
299
-
300
-
301
- /**
302
- * Creates a React wrapper from a WCC component class that has `static __meta`.
303
- *
304
- * Unlike `createWccWrapper` which requires manual event/model configuration,
305
- * this function reads the metadata directly from the compiled component class.
306
- *
307
- * @param {Function} WccClass - The WCC custom element class (must have static __meta)
308
- * @returns {import('react').ForwardRefExoticComponent} A React component
309
- *
310
- * @example
311
- * import { wrapWccComponent } from '@sprlab/wccompiler/adapters/react'
312
- * import '../wcc-components/wcc-counter.js' // registers the custom element
313
- *
314
- * // Read metadata directly from the registered class
315
- * const WccCounter = wrapWccComponent(customElements.get('wcc-counter'))
316
- *
317
- * // Use idiomatically — no manual config needed
318
- * // Handlers receive the value directly (not the event)
319
- * <WccCounter count={count} onCountChange={setCount} onChange={(val) => console.log(val)} />
320
- */
321
- export function wrapWccComponent(WccClass) {
322
- const meta = WccClass?.__meta
323
- if (!meta) {
324
- throw new Error(`wrapWccComponent: class does not have static __meta. Is it a compiled WCC component?`)
325
- }
326
-
327
- return createWccWrapper(meta.tag, {
328
- events: meta.events || [],
329
- models: meta.models || [],
330
- slots: meta.slots || [],
331
- })
332
- }
333
-
334
- /**
335
- * Creates React wrappers for all registered WCC custom elements matching a prefix.
336
- *
337
- * Scans the custom elements registry for components with `static __meta` and
338
- * generates typed wrapper components for each one.
339
- *
340
- * @param {Object} [options]
341
- * @param {string} [options.prefix='wcc-'] - Tag prefix to filter components
342
- * @returns {Record<string, import('react').ForwardRefExoticComponent>} Map of PascalCase name → React component
343
- *
344
- * @example
345
- * // In your app entry point, after importing all WCC components:
346
- * import '../wcc-components/wcc-counter.js'
347
- * import '../wcc-components/wcc-card.js'
348
- * import { createWccWrappers } from '@sprlab/wccompiler/adapters/react'
349
- *
350
- * export const { WccCounter, WccCard } = createWccWrappers()
351
- *
352
- * // Then use anywhere:
353
- * <WccCounter count={count} onCountChange={setCount} />
354
- * <WccCard>
355
- * <WccCard.Header><strong>Title</strong></WccCard.Header>
356
- * <p>Body</p>
357
- * <WccCard.Footer>Footer</WccCard.Footer>
358
- * </WccCard>
359
- */
360
- export function createWccWrappers(options = {}) {
361
- const { prefix = 'wcc-' } = options
362
- const wrappers = {}
363
-
364
- // Note: customElements registry doesn't have a list API,
365
- // so we need the component files to be imported first (which registers them).
366
- // This function is meant to be called after all component imports.
367
-
368
- // We'll use a Proxy that lazily creates wrappers on first access
369
- return new Proxy(wrappers, {
370
- get(target, prop) {
371
- if (typeof prop !== 'string') return undefined
372
- if (prop in target) return target[prop]
373
-
374
- // Convert PascalCase to kebab-case: WccCounter → wcc-counter
375
- const kebab = prop.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')
376
-
377
- // Check if it starts with the prefix
378
- if (!kebab.startsWith(prefix)) return undefined
379
-
380
- const ctor = customElements.get(kebab)
381
- if (!ctor || !(ctor).__meta) return undefined
382
-
383
- const wrapper = wrapWccComponent(ctor)
384
- target[prop] = wrapper
385
- return wrapper
386
- }
387
- })
388
- }
@@ -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
- if (nameNode.type !== 'JSXIdentifier') return
648
- const tagName = nameNode.name
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprlab/wccompiler",
3
- "version": "0.10.6",
3
+ "version": "0.10.7",
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": {