@sprlab/wccompiler 0.10.1 → 0.10.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/react.js CHANGED
@@ -105,11 +105,33 @@ export function useWccModel(propName, value, setValue, existingRef) {
105
105
  }
106
106
 
107
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
+
108
129
  /**
109
130
  * Creates a React wrapper component for a WCC custom element.
110
131
  *
111
132
  * The wrapper provides idiomatic React DX:
112
- * - Event props: `onChange`, `onCountChanged` → automatically wired via addEventListener
133
+ * - Event props: `onChange`, `onCountChange` → automatically wired via addEventListener
134
+ * Handlers receive the unwrapped value (event.detail), not the raw CustomEvent.
113
135
  * - Model props: two-way binding via attribute + event listener
114
136
  * - Regular props: passed as attributes on the custom element
115
137
  * - Children: passed through as-is (use `<div slot="name">` for named slots)
@@ -118,9 +140,9 @@ export function useWccModel(propName, value, setValue, existingRef) {
118
140
  * @param {string} tagName - The custom element tag name (e.g., 'wcc-card')
119
141
  * @param {Object} [config] - Configuration for the wrapper
120
142
  * @param {string[]} [config.events] - Custom event names to expose as onEventName props
121
- * Event names are converted: 'count-changed' → onCountChanged prop
143
+ * Event names are converted: 'count-changed' → onCountChange prop (React convention)
122
144
  * @param {string[]} [config.models] - Model prop names for two-way binding
123
- * Each model 'name' creates: `name` prop (sets attribute) + `onNameChanged` event
145
+ * Each model 'name' creates: `name` prop (sets attribute) + `onNameChange` event
124
146
  * @returns {import('react').ForwardRefExoticComponent} A React component
125
147
  *
126
148
  * @example
@@ -134,8 +156,8 @@ export function useWccModel(propName, value, setValue, existingRef) {
134
156
  * return (
135
157
  * <WccCounter
136
158
  * count={count}
137
- * onCountChanged={(e) => setCount(e.detail)}
138
- * onChange={(e) => console.log('changed', e.detail)}
159
+ * onCountChange={(value) => setCount(value)}
160
+ * onChange={(value) => console.log('changed', value)}
139
161
  * label="Clicks"
140
162
  * >
141
163
  * <div slot="footer">Footer content</div>
@@ -147,19 +169,21 @@ export function createWccWrapper(tagName, config = {}) {
147
169
  const { events = [], models = [] } = config
148
170
 
149
171
  // Build a set of event prop names for quick lookup
150
- // 'count-changed' → 'onCountChanged'
172
+ // Convention: kebab-case event React onCamelCase (without trailing 'd' from 'changed')
173
+ // 'count-changed' → 'onCountChange' (not 'onCountChanged')
151
174
  // 'change' → 'onChange'
175
+ // 'value-updated' → 'onValueUpdate' (strips trailing 'd' from past tense)
152
176
  const eventPropMap = new Map()
153
177
  for (const eventName of events) {
154
- const propName = 'on' + eventName.split('-').map(s => s[0].toUpperCase() + s.slice(1)).join('')
178
+ const propName = toReactEventProp(eventName)
155
179
  eventPropMap.set(propName, eventName)
156
180
  }
157
181
 
158
- // Model events: 'count' → 'count-changed' → 'onCountChanged'
182
+ // Model events: 'count' → 'count-changed' → 'onCountChange'
159
183
  const modelEventMap = new Map()
160
184
  for (const modelName of models) {
161
185
  const eventName = `${modelName}-changed`
162
- const propName = 'on' + eventName.split('-').map(s => s[0].toUpperCase() + s.slice(1)).join('')
186
+ const propName = toReactEventProp(eventName)
163
187
  eventPropMap.set(propName, eventName)
164
188
  modelEventMap.set(modelName, eventName)
165
189
  }
@@ -207,7 +231,7 @@ export function createWccWrapper(tagName, config = {}) {
207
231
  for (const eventName of allEvents) {
208
232
  const listener = (e) => {
209
233
  const handler = handlersRef.current[eventName]
210
- if (handler) handler(e)
234
+ if (handler) handler(e instanceof CustomEvent ? e.detail : e)
211
235
  }
212
236
  el.addEventListener(eventName, listener)
213
237
  listeners.push([eventName, listener])
@@ -249,3 +273,88 @@ export function createWccWrapper(tagName, config = {}) {
249
273
  return WccWrapper
250
274
  }
251
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
+ }
@@ -745,3 +745,120 @@ export function wccReactPlugin(options = {}) {
745
745
  }
746
746
  }
747
747
  }
748
+
749
+
750
+ /**
751
+ * Vite plugin that generates a virtual module `@wcc/react` (or custom id)
752
+ * exporting pre-built React wrapper components for all WCC components found
753
+ * in the project's compiled output directory.
754
+ *
755
+ * This enables the ideal import experience:
756
+ * import { WccCounter, WccCard } from '@wcc/react'
757
+ *
758
+ * The plugin scans the output directory for compiled .js files, reads their
759
+ * `static __meta` declarations, and generates wrapper code using createWccWrapper.
760
+ *
761
+ * @param {Object} [options]
762
+ * @param {string} [options.moduleId='@wcc/react'] - Virtual module ID for imports
763
+ * @param {string} [options.componentsDir='./dist'] - Directory containing compiled WCC .js files
764
+ * @param {string} [options.prefix='wcc-'] - Tag prefix filter
765
+ * @returns {import('vite').Plugin}
766
+ *
767
+ * @example vite.config.js
768
+ * ```js
769
+ * import { wccReactPlugin, wccReactComponents } from '@sprlab/wccompiler/integrations/react'
770
+ * export default {
771
+ * plugins: [
772
+ * wccReactPlugin(),
773
+ * wccReactComponents({ componentsDir: './src/wcc' })
774
+ * ]
775
+ * }
776
+ * ```
777
+ *
778
+ * @example Component.jsx
779
+ * ```jsx
780
+ * import { WccCounter, WccCard } from '@wcc/react'
781
+ *
782
+ * <WccCounter count={count} onCountChange={setCount} />
783
+ * <WccCard><div slot="header">Title</div></WccCard>
784
+ * ```
785
+ */
786
+ export function wccReactComponents(options = {}) {
787
+ const {
788
+ moduleId = '@wcc/react',
789
+ componentsDir = './dist',
790
+ prefix = 'wcc-'
791
+ } = options
792
+
793
+ const resolvedId = '\0' + moduleId
794
+
795
+ return {
796
+ name: 'vite-plugin-wcc-react-components',
797
+ resolveId(id) {
798
+ if (id === moduleId) return resolvedId
799
+ return null
800
+ },
801
+ async load(id) {
802
+ if (id !== resolvedId) return null
803
+
804
+ // Scan componentsDir for .js files and extract __meta
805
+ const fs = await import('fs')
806
+ const path = await import('path')
807
+
808
+ const dir = path.default.resolve(componentsDir)
809
+ if (!fs.default.existsSync(dir)) {
810
+ this.warn(`[wcc-react-components] Directory not found: ${dir}`)
811
+ return 'export {}'
812
+ }
813
+
814
+ const files = fs.default.readdirSync(dir).filter(f => f.endsWith('.js'))
815
+ const components = []
816
+
817
+ for (const file of files) {
818
+ const content = fs.default.readFileSync(path.default.join(dir, file), 'utf-8')
819
+ const metaMatch = content.match(/static __meta\s*=\s*(\{[^}]+\})/)
820
+ if (!metaMatch) continue
821
+
822
+ try {
823
+ // Parse the meta object (it's a JS object literal, evaluate safely)
824
+ const metaStr = metaMatch[1]
825
+ .replace(/'/g, '"')
826
+ .replace(/(\w+):/g, '"$1":')
827
+ .replace(/,\s*}/g, '}')
828
+ .replace(/,\s*]/g, ']')
829
+ const meta = JSON.parse(metaStr)
830
+
831
+ if (!meta.tag || !meta.tag.startsWith(prefix)) continue
832
+
833
+ const pascalName = meta.tag.split('-').map(s => s[0].toUpperCase() + s.slice(1)).join('')
834
+ components.push({ meta, pascalName, file })
835
+ } catch (e) {
836
+ // Skip files with unparseable meta
837
+ }
838
+ }
839
+
840
+ if (components.length === 0) {
841
+ return 'export {}'
842
+ }
843
+
844
+ // Generate the virtual module code
845
+ let code = `import { createWccWrapper } from '@sprlab/wccompiler/adapters/react';\n`
846
+
847
+ // Import each component file to ensure registration
848
+ for (const comp of components) {
849
+ code += `import '${path.default.resolve(dir, comp.file)}';\n`
850
+ }
851
+
852
+ code += '\n'
853
+
854
+ // Generate wrapper exports
855
+ for (const comp of components) {
856
+ const eventsArr = JSON.stringify(comp.meta.events || [])
857
+ const modelsArr = JSON.stringify(comp.meta.models || [])
858
+ code += `export const ${comp.pascalName} = createWccWrapper('${comp.meta.tag}', { events: ${eventsArr}, models: ${modelsArr} });\n`
859
+ }
860
+
861
+ return code
862
+ }
863
+ }
864
+ }
package/lib/codegen.js CHANGED
@@ -970,6 +970,16 @@ export function generateComponent(parseResult, options = {}) {
970
970
  lines.push('');
971
971
  }
972
972
 
973
+ // Static __meta — component metadata for framework adapters (React wrappers, Angular events, etc.)
974
+ {
975
+ const metaProps = propDefs.map(p => `{ name: '${p.name}', default: ${p.default} }`).join(', ');
976
+ const metaEvents = emits.map(e => `'${e}'`).join(', ');
977
+ const metaModels = modelDefs.map(m => `'${m.name}'`).join(', ');
978
+ const metaSlots = slots.filter(s => s.name).map(s => `'${s.name}'`).join(', ');
979
+ lines.push(` static __meta = { tag: '${tagName}', props: [${metaProps}], events: [${metaEvents}], models: [${metaModels}], slots: [${metaSlots}] };`);
980
+ lines.push('');
981
+ }
982
+
973
983
  // Constructor — reactive state only (no DOM manipulation per Custom Elements spec)
974
984
  lines.push(' constructor() {');
975
985
  lines.push(' super();');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprlab/wccompiler",
3
- "version": "0.10.1",
3
+ "version": "0.10.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": {