@sprlab/wccompiler 0.8.1 → 0.8.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/vue.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Vue adapter for WCC defineModel — enables v-model and multi-model binding.
3
3
  *
4
- * Usage (ONE line in main.js):
4
+ * Setup (ONE line in main.js):
5
5
  * import { createApp } from 'vue'
6
6
  * import { wccVue } from '@sprlab/wccompiler/adapters/vue'
7
7
  *
@@ -9,23 +9,29 @@
9
9
  * app.use(wccVue) // registers adapter + v-wcc-model directive globally
10
10
  * app.mount('#app')
11
11
  *
12
- * What it does:
13
- * 1. Registers document-level wcc:model update:propName translation (enables v-model)
14
- * 2. Registers v-wcc-model directive globally (enables multi-prop two-way binding)
12
+ * IMPORTANT: Also use wccVuePlugin() in vite.config.js to enable v-model:propName
13
+ * on custom elements (via AST nodeTransform):
14
+ * import { wccVuePlugin } from '@sprlab/wccompiler/integrations/vue'
15
+ * export default { plugins: [wccVuePlugin()] }
15
16
  *
16
- * Template usage:
17
- * <!-- Single model prop (Vue's native v-model) -->
18
- * <!-- Component must declare: defineModel({ name: 'modelValue', default: '' }) -->
17
+ * With both configured, you can use:
18
+ * <!-- Native v-model:propName (preferred, requires wccVuePlugin) -->
19
+ * <wcc-input v-model:value="text"></wcc-input>
20
+ * <wcc-form v-model:count="countRef" v-model:title="titleRef"></wcc-form>
21
+ *
22
+ * <!-- v-model without argument (uses modelValue convention) -->
19
23
  * <wcc-input v-model="text"></wcc-input>
20
24
  *
21
- * <!-- Multiple model props (v-wcc-model:propName) -->
22
- * <wcc-form v-model="mainValue" v-wcc-model:count="countRef" v-wcc-model:title="titleRef"></wcc-form>
25
+ * <!-- Fallback directive (for non-Vite setups without nodeTransform) -->
26
+ * <wcc-input v-wcc-model:value="textRef"></wcc-input>
23
27
  *
24
28
  * @module @sprlab/wccompiler/adapters/vue
25
29
  */
26
30
 
27
31
  // ── Document-level adapter: wcc:model → update:propName ─────────────
28
32
  // This enables Vue's native v-model on WCC custom elements.
33
+ // Vue v-model on custom elements listens for `update:modelValue` by default.
34
+ // The nodeTransform in wccVuePlugin makes v-model:propName listen for `update:propName`.
29
35
 
30
36
  if (typeof document !== 'undefined') {
31
37
  document.addEventListener('wcc:model', (e) => {
@@ -38,16 +44,20 @@ if (typeof document !== 'undefined') {
38
44
  }
39
45
 
40
46
  // ── Vue directive: v-wcc-model ──────────────────────────────────────
47
+ // Fallback for non-Vite setups. If using wccVuePlugin(), prefer v-model:propName instead.
48
+ //
49
+ // Usage:
50
+ // <wcc-input v-wcc-model:value="textRef"></wcc-input>
51
+ //
52
+ // The bound value MUST be a Vue ref (or reactive property).
53
+ // The directive writes directly to ref.value for WCC→Vue updates.
41
54
 
42
55
  /**
43
56
  * Vue custom directive for two-way binding with WCC defineModel props.
44
- *
45
- * Binds a Vue ref to a WCC component's model prop bidirectionally:
46
- * - Parent → Child: sets the attribute when the Vue ref changes
47
- * - Child → Parent: updates the Vue ref when wcc:model fires for the matching prop
57
+ * This is a FALLBACK — prefer v-model:propName with wccVuePlugin() nodeTransform.
48
58
  *
49
59
  * @example
50
- * <wcc-counter v-wcc-model:count="myCount"></wcc-counter>
60
+ * <wcc-counter v-wcc-model:count="myCountRef"></wcc-counter>
51
61
  */
52
62
  export const vWccModel = {
53
63
  mounted(el, binding) {
@@ -58,31 +68,53 @@ export const vWccModel = {
58
68
  }
59
69
 
60
70
  // Set initial value (parent → child)
71
+ // Vue sets camelCase attributes, so set both camelCase and kebab-case
61
72
  if (binding.value != null) {
62
73
  el.setAttribute(propName, String(binding.value));
63
74
  }
64
75
 
65
- // Listen for child → parent changes via the update:propName event
66
- // (which is already dispatched by the document-level adapter above)
67
- const handler = (e) => {
68
- // Use the update:propName event dispatched by the adapter
69
- // Vue will handle the ref update through the directive binding
70
- };
71
-
72
- // Listen directly for wcc:model to update the binding
76
+ // Listen for child → parent changes
73
77
  const wccHandler = (e) => {
74
78
  if (e.detail && e.detail.prop === propName) {
75
- // Trigger Vue reactivity by emitting on the component instance
76
- // This works because Vue tracks directive bindings
77
- el.dispatchEvent(new CustomEvent(`update:${propName}`, {
78
- detail: e.detail.value,
79
- bubbles: false
80
- }));
79
+ const newValue = e.detail.value;
80
+
81
+ // Try to update the Vue ref directly
82
+ // In Vue 3, if the binding expression is a ref, binding.value is the ref's current value
83
+ // We need to find the ref on the component instance and write to it
84
+ const instance = binding.instance;
85
+ if (instance) {
86
+ // Access the setup state to find the ref
87
+ const setupState = instance.$.setupState;
88
+ // The binding expression is stored in the directive's internal data
89
+ // In Vue 3, we can use the dir's exp to find the variable name
90
+ // Fallback: emit a custom event that a parent @update handler can catch
91
+ const refName = binding.dir?.__wccRefName?.[el]?.[propName];
92
+ if (refName && setupState[refName] !== undefined) {
93
+ // Direct ref write
94
+ if (setupState[refName]?.value !== undefined) {
95
+ setupState[refName].value = newValue;
96
+ } else {
97
+ setupState[refName] = newValue;
98
+ }
99
+ } else {
100
+ // Fallback: try to find by matching current value
101
+ for (const key of Object.keys(setupState)) {
102
+ const val = setupState[key];
103
+ if (val?.value === binding.value || val === binding.value) {
104
+ if (val?.value !== undefined) {
105
+ val.value = newValue;
106
+ } else {
107
+ setupState[key] = newValue;
108
+ }
109
+ break;
110
+ }
111
+ }
112
+ }
113
+ }
81
114
  }
82
115
  };
83
116
 
84
117
  el.addEventListener('wcc:model', wccHandler);
85
- // Store handler for cleanup
86
118
  el.__wccModelHandlers = el.__wccModelHandlers || {};
87
119
  el.__wccModelHandlers[propName] = wccHandler;
88
120
  },
@@ -103,7 +135,6 @@ export const vWccModel = {
103
135
  const propName = binding.arg;
104
136
  if (!propName) return;
105
137
 
106
- // Cleanup listener
107
138
  const handler = el.__wccModelHandlers?.[propName];
108
139
  if (handler) {
109
140
  el.removeEventListener('wcc:model', handler);
@@ -115,7 +146,8 @@ export const vWccModel = {
115
146
  // ── Vue Plugin: app.use(wccVue) ─────────────────────────────────────
116
147
 
117
148
  /**
118
- * Vue plugin that registers the wcc:model adapter and v-wcc-model directive globally.
149
+ * Vue plugin that registers the v-wcc-model directive globally.
150
+ * The document-level adapter is registered on import (side-effect above).
119
151
  *
120
152
  * @example
121
153
  * import { createApp } from 'vue'
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Vue Vite plugin for WCC custom elements.
3
- * Configures isCustomElement to recognize WCC component tags.
3
+ * Configures isCustomElement and enables v-model:propName on custom elements.
4
4
  *
5
5
  * @module @sprlab/wccompiler/integrations/vue
6
6
  *
7
7
  * IMPORTANT: This file is for vite.config.js (Node.js context).
8
- * For browser-side model adapter, import '@sprlab/wccompiler/adapters/vue' in your main.js.
8
+ * For browser-side model adapter, use app.use(wccVue) from '@sprlab/wccompiler/adapters/vue'.
9
9
  *
10
10
  * @example vite.config.js
11
11
  * ```js
@@ -13,9 +13,16 @@
13
13
  * export default { plugins: [wccVuePlugin()] }
14
14
  * ```
15
15
  *
16
- * @example main.js (browser — enables v-model on WCC components)
16
+ * @example main.js (browser — enables v-model event translation)
17
17
  * ```js
18
- * import '@sprlab/wccompiler/adapters/vue'
18
+ * import { wccVue } from '@sprlab/wccompiler/adapters/vue'
19
+ * app.use(wccVue)
20
+ * ```
21
+ *
22
+ * With this plugin, v-model:propName works natively on WCC custom elements:
23
+ * ```vue
24
+ * <wcc-input v-model="text"></wcc-input>
25
+ * <wcc-form v-model:count="countRef" v-model:title="titleRef"></wcc-form>
19
26
  * ```
20
27
  */
21
28
 
@@ -26,9 +33,95 @@ import vue from '@vitejs/plugin-vue'
26
33
  * @property {string} [prefix='wcc-'] - Tag prefix for custom element detection
27
34
  */
28
35
 
36
+ /**
37
+ * AST node transform that enables v-model:propName on custom elements.
38
+ *
39
+ * Vue's compiler normally doesn't support v-model with arguments on custom elements.
40
+ * This transform intercepts v-model:arg directives on custom elements and rewrites them
41
+ * to the equivalent :prop + @update:prop binding that Vue understands.
42
+ *
43
+ * Transforms:
44
+ * <wcc-input v-model:value="text" />
45
+ * Into the equivalent of:
46
+ * <wcc-input :value="text" @update:value="text = $event" />
47
+ *
48
+ * @param {object} node - Vue compiler AST node
49
+ * @param {object} context - Vue compiler transform context
50
+ */
51
+ function wccVModelTransform(node, context) {
52
+ // Only process element nodes (type 1 = ELEMENT)
53
+ if (node.type !== 1) return;
54
+
55
+ // Only process custom elements (tag contains a hyphen)
56
+ if (!node.tag.includes('-')) return;
57
+
58
+ // Find v-model directives with arguments
59
+ const newProps = [];
60
+ let modified = false;
61
+
62
+ for (const prop of node.props) {
63
+ // Check if this is a v-model directive with an argument
64
+ if (
65
+ prop.type === 7 && // DIRECTIVE
66
+ prop.name === 'model' &&
67
+ prop.arg // has argument (v-model:name)
68
+ ) {
69
+ const propName = prop.arg.content;
70
+ const expr = prop.exp;
71
+
72
+ if (!expr) {
73
+ newProps.push(prop);
74
+ continue;
75
+ }
76
+
77
+ // Replace v-model:propName="expr" with:
78
+ // :propName="expr" (bind directive)
79
+ newProps.push({
80
+ type: 7, // DIRECTIVE
81
+ name: 'bind',
82
+ arg: prop.arg,
83
+ exp: expr,
84
+ modifiers: [],
85
+ loc: prop.loc
86
+ });
87
+
88
+ // @update:propName="$event => { expr = $event }" (on directive)
89
+ newProps.push({
90
+ type: 7, // DIRECTIVE
91
+ name: 'on',
92
+ arg: {
93
+ type: 4, // SIMPLE_EXPRESSION
94
+ content: `update:${propName}`,
95
+ isStatic: true,
96
+ constType: 3,
97
+ loc: prop.loc
98
+ },
99
+ exp: {
100
+ type: 4, // SIMPLE_EXPRESSION
101
+ content: `$event => { ${expr.content} = $event }`,
102
+ isStatic: false,
103
+ constType: 0,
104
+ loc: prop.loc
105
+ },
106
+ modifiers: [],
107
+ loc: prop.loc
108
+ });
109
+
110
+ modified = true;
111
+ } else {
112
+ newProps.push(prop);
113
+ }
114
+ }
115
+
116
+ if (modified) {
117
+ node.props = newProps;
118
+ }
119
+ }
120
+
29
121
  /**
30
122
  * Creates a Vite plugin that configures Vue's template compiler
31
- * to recognize custom elements with the given prefix.
123
+ * to recognize custom elements with the given prefix and enables
124
+ * v-model:propName on those elements.
32
125
  *
33
126
  * @param {WccVuePluginOptions} [options]
34
127
  * @returns {import('vite').Plugin}
@@ -38,7 +131,8 @@ export function wccVuePlugin(options = {}) {
38
131
  return vue({
39
132
  template: {
40
133
  compilerOptions: {
41
- isCustomElement: (tag) => tag.startsWith(prefix)
134
+ isCustomElement: (tag) => tag.startsWith(prefix),
135
+ nodeTransforms: [wccVModelTransform]
42
136
  }
43
137
  }
44
138
  })
package/lib/codegen.js CHANGED
@@ -945,7 +945,18 @@ export function generateComponent(parseResult, options = {}) {
945
945
  const modelAttrNames = modelDefs.map(md => camelToKebab(md.name));
946
946
  if (propDefs.length > 0 || modelDefs.length > 0) {
947
947
  const propAttrNames = propDefs.map(p => `'${p.attrName}'`);
948
- const modelAttrEntries = modelAttrNames.map(a => `'${a}'`);
948
+ // For model props, observe BOTH kebab-case AND camelCase forms
949
+ // Vue sets camelCase (modelValue), native HTML uses kebab-case (model-value)
950
+ const modelAttrEntries = [];
951
+ for (let i = 0; i < modelDefs.length; i++) {
952
+ const kebab = modelAttrNames[i];
953
+ const camel = modelDefs[i].name;
954
+ modelAttrEntries.push(`'${kebab}'`);
955
+ // Only add camelCase if it differs from kebab-case
956
+ if (kebab !== camel) {
957
+ modelAttrEntries.push(`'${camel}'`);
958
+ }
959
+ }
949
960
  const allAttrNames = [...propAttrNames, ...modelAttrEntries].join(', ');
950
961
  lines.push(` static get observedAttributes() { return [${allAttrNames}]; }`);
951
962
  lines.push('');
@@ -1149,8 +1160,11 @@ export function generateComponent(parseResult, options = {}) {
1149
1160
  lines.push(` this.${b.varName}.textContent = this._s_${b.name}() ?? '';`);
1150
1161
  lines.push(' }));');
1151
1162
  } else if (b.type === 'signal') {
1163
+ // Check if this is a model var (needs _m_ prefix instead of _)
1164
+ const modelPropName = modelVarMap.get(b.name);
1165
+ const signalRef = modelPropName ? `this._m_${modelPropName}()` : `this._${b.name}()`;
1152
1166
  lines.push(' this.__disposers.push(__effect(() => {');
1153
- lines.push(` this.${b.varName}.textContent = this._${b.name}() ?? '';`);
1167
+ lines.push(` this.${b.varName}.textContent = ${signalRef} ?? '';`);
1154
1168
  lines.push(' }));');
1155
1169
  } else if (b.type === 'computed') {
1156
1170
  lines.push(' this.__disposers.push(__effect(() => {');
@@ -1201,7 +1215,8 @@ export function generateComponent(parseResult, options = {}) {
1201
1215
  } else if (pb.type === 'computed') {
1202
1216
  ref = `this._c_${pb.expr}()`;
1203
1217
  } else if (pb.type === 'signal') {
1204
- ref = `this._${pb.expr}()`;
1218
+ const modelPropName = modelVarMap.get(pb.expr);
1219
+ ref = modelPropName ? `this._m_${modelPropName}()` : `this._${pb.expr}()`;
1205
1220
  } else if (pb.type === 'constant') {
1206
1221
  ref = `this._const_${pb.expr}`;
1207
1222
  } else {
@@ -1595,6 +1610,7 @@ export function generateComponent(parseResult, options = {}) {
1595
1610
  for (let i = 0; i < modelDefs.length; i++) {
1596
1611
  const md = modelDefs[i];
1597
1612
  const attrName = modelAttrNames[i];
1613
+ const camelName = md.name;
1598
1614
  const defaultVal = md.default;
1599
1615
  let updateExpr;
1600
1616
 
@@ -1612,7 +1628,12 @@ export function generateComponent(parseResult, options = {}) {
1612
1628
  updateExpr = `this._m_${md.name}(newVal ?? ${defaultVal})`;
1613
1629
  }
1614
1630
 
1615
- lines.push(` if (name === '${attrName}') ${updateExpr};`);
1631
+ // Handle both kebab-case (native HTML) and camelCase (Vue) attribute names
1632
+ if (attrName !== camelName) {
1633
+ lines.push(` if (name === '${attrName}' || name === '${camelName}') ${updateExpr};`);
1634
+ } else {
1635
+ lines.push(` if (name === '${attrName}') ${updateExpr};`);
1636
+ }
1616
1637
  }
1617
1638
 
1618
1639
  lines.push(' }');
@@ -1725,7 +1746,9 @@ export function generateComponent(parseResult, options = {}) {
1725
1746
  if (b.type === 'prop') {
1726
1747
  lines.push(` __effect(() => { ${b.varName}.textContent = this._s_${b.name}() ?? ''; });`);
1727
1748
  } else if (b.type === 'signal') {
1728
- lines.push(` __effect(() => { ${b.varName}.textContent = this._${b.name}() ?? ''; });`);
1749
+ const modelPropName = modelVarMap.get(b.name);
1750
+ const signalRef = modelPropName ? `this._m_${modelPropName}()` : `this._${b.name}()`;
1751
+ lines.push(` __effect(() => { ${b.varName}.textContent = ${signalRef} ?? ''; });`);
1729
1752
  } else if (b.type === 'computed') {
1730
1753
  lines.push(` __effect(() => { ${b.varName}.textContent = this._c_${b.name}() ?? ''; });`);
1731
1754
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprlab/wccompiler",
3
- "version": "0.8.1",
3
+ "version": "0.8.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": {