@sprlab/wccompiler 0.7.2 → 0.8.0

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.
@@ -0,0 +1,9 @@
1
+ if (typeof document !== 'undefined') {
2
+ document.addEventListener('wcc:model', (e) => {
3
+ const { prop, value } = e.detail;
4
+ e.target.dispatchEvent(new CustomEvent(`${prop}Change`, {
5
+ detail: value,
6
+ bubbles: true
7
+ }));
8
+ });
9
+ }
@@ -0,0 +1,9 @@
1
+ if (typeof document !== 'undefined') {
2
+ document.addEventListener('wcc:model', (e) => {
3
+ const { prop, value } = e.detail;
4
+ e.target.dispatchEvent(new CustomEvent(`update:${prop}`, {
5
+ detail: value,
6
+ bubbles: true
7
+ }));
8
+ });
9
+ }
@@ -30,10 +30,24 @@
30
30
  * export class AppModule {}
31
31
  * ```
32
32
  *
33
+ * @example Two-way binding with defineModel
34
+ * ```ts
35
+ * // The adapter translates wcc:model events to Angular's propNameChange convention.
36
+ * // Import the integration once in your main.ts or app module:
37
+ * import '@sprlab/wccompiler/integrations/angular'
38
+ *
39
+ * // Then use Angular's banana-in-a-box syntax:
40
+ * // <wcc-input [(value)]="myValue"></wcc-input>
41
+ * ```
42
+ *
33
43
  * That's it — one line of config. WCC components work as native custom elements
34
44
  * in Angular without any additional wrapper or helper.
35
45
  */
36
46
 
47
+ // Side-effect: registers document-level wcc:model → propNameChange translation
48
+ // This enables [(propName)] two-way binding on WCC components in Angular templates.
49
+ import '../adapters/angular.js'
50
+
37
51
  /**
38
52
  * Configuration instructions for Angular projects using WCC components.
39
53
  * This is a documentation-only export — Angular's AOT compiler requires
@@ -13,9 +13,14 @@
13
13
  * // Form 2: Let the hook create the ref
14
14
  * const ref = useWccEvent('change', (e) => console.log(e.detail))
15
15
  * <wcc-counter ref={ref}></wcc-counter>
16
+ *
17
+ * // Form 3: Two-way binding with defineModel
18
+ * const [value, setValue] = useState('')
19
+ * const ref = useWccModel('value', value, setValue)
20
+ * <wcc-input ref={ref}></wcc-input>
16
21
  */
17
22
 
18
- import { useRef, useEffect } from 'react'
23
+ import { useRef, useEffect, useCallback } from 'react'
19
24
 
20
25
  /**
21
26
  * Hook that attaches a CustomEvent listener to a DOM element via ref.
@@ -50,3 +55,62 @@ export function useWccEvent(refOrEventName, eventNameOrHandler, handler) {
50
55
  // Only return ref if we created it (Form 2)
51
56
  if (!isRefForm) return elementRef
52
57
  }
58
+
59
+
60
+ /**
61
+ * Hook for two-way binding with WCC defineModel props.
62
+ *
63
+ * Listens for `wcc:model` events on the element and calls the setter
64
+ * when the matching prop changes internally. Also syncs the React state
65
+ * to the element's attribute when the value changes externally.
66
+ *
67
+ * @param {string} propName - The model prop name (e.g., 'value', 'count')
68
+ * @param {*} value - Current React state value
69
+ * @param {(newValue: *) => void} setValue - React state setter
70
+ * @param {import('react').RefObject<HTMLElement>} [existingRef] - Optional existing ref
71
+ * @returns {import('react').RefObject<HTMLElement>} Ref to attach to the WCC element
72
+ *
73
+ * @example
74
+ * ```jsx
75
+ * function App() {
76
+ * const [text, setText] = useState('')
77
+ * const inputRef = useWccModel('value', text, setText)
78
+ * return <wcc-input ref={inputRef}></wcc-input>
79
+ * }
80
+ * ```
81
+ */
82
+ export function useWccModel(propName, value, setValue, existingRef) {
83
+ const internalRef = useRef(null)
84
+ const elementRef = existingRef || internalRef
85
+
86
+ const setValueRef = useRef(setValue)
87
+ setValueRef.current = setValue
88
+
89
+ // Listen for wcc:model events from the component (child → parent)
90
+ useEffect(() => {
91
+ const el = elementRef.current
92
+ if (!el) return
93
+
94
+ const listener = (e) => {
95
+ if (e.detail && e.detail.prop === propName) {
96
+ setValueRef.current(e.detail.value)
97
+ }
98
+ }
99
+
100
+ el.addEventListener('wcc:model', listener)
101
+ return () => el.removeEventListener('wcc:model', listener)
102
+ }, [propName])
103
+
104
+ // Sync React state to the element's attribute (parent → child)
105
+ useEffect(() => {
106
+ const el = elementRef.current
107
+ if (!el) return
108
+ if (value != null) {
109
+ el.setAttribute(propName, String(value))
110
+ } else {
111
+ el.removeAttribute(propName)
112
+ }
113
+ }, [propName, value])
114
+
115
+ return elementRef
116
+ }
@@ -1,12 +1,17 @@
1
1
  /**
2
2
  * Vue Vite plugin for WCC custom elements.
3
3
  * Configures isCustomElement to recognize WCC component tags.
4
+ * Also re-exports the defineModel adapter for v-model support.
4
5
  *
5
6
  * @module @sprlab/wccompiler/integrations/vue
6
7
  */
7
8
 
8
9
  import vue from '@vitejs/plugin-vue'
9
10
 
11
+ // Side-effect: registers document-level wcc:model → update:propName translation
12
+ // This enables v-model:propName on WCC components in Vue templates.
13
+ import '../adapters/vue.js'
14
+
10
15
  /**
11
16
  * @typedef {Object} WccVuePluginOptions
12
17
  * @property {string} [prefix='wcc-'] - Tag prefix for custom element detection
package/lib/codegen.js CHANGED
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { reactiveRuntime } from './reactive-runtime.js';
17
17
  import { scopeCSS } from './css-scoper.js';
18
+ import { camelToKebab } from './parser-extractors.js';
18
19
 
19
20
  /** @import { ParseResult } from './types.js' */
20
21
 
@@ -75,7 +76,7 @@ function slotPropRef(source, signalNames, computedNames, propNames) {
75
76
  * @param {string|null} [emitsObjectName] — Emits object variable name
76
77
  * @returns {string}
77
78
  */
78
- export function transformExpr(expr, signalNames, computedNames, propsObjectName = null, propNames = new Set(), emitsObjectName = null, constantNames = [], methodNames = []) {
79
+ export function transformExpr(expr, signalNames, computedNames, propsObjectName = null, propNames = new Set(), emitsObjectName = null, constantNames = [], methodNames = [], modelVarMap = new Map()) {
79
80
  let result = expr;
80
81
 
81
82
  // Transform emit calls: emitsObjectName( → this._emit(
@@ -111,6 +112,18 @@ export function transformExpr(expr, signalNames, computedNames, propsObjectName
111
112
  result = result.replace(bareRe, `this._s_${propName}()`);
112
113
  }
113
114
 
115
+ // Transform model signal reads: varName() → this._m_{propName}() (BEFORE regular signals)
116
+ for (const [varName, propNameVal] of modelVarMap) {
117
+ if (propsObjectName && varName === propsObjectName) continue;
118
+ if (emitsObjectName && varName === emitsObjectName) continue;
119
+ // First: transform varName() calls → this._m_propName()
120
+ const callRe = new RegExp(`\\b${varName}\\(\\)`, 'g');
121
+ result = result.replace(callRe, `this._m_${propNameVal}()`);
122
+ // Then: transform bare varName references (not followed by ( or .set()) → this._m_propName()
123
+ const bareRe = new RegExp(`\\b(${varName})\\b(?!\\.set\\()(?!\\()`, 'g');
124
+ result = result.replace(bareRe, `this._m_${propNameVal}()`);
125
+ }
126
+
114
127
  // Transform computed names first (to avoid partial matches with signals)
115
128
  for (const name of computedNames) {
116
129
  // Skip propsObjectName and emitsObjectName
@@ -153,7 +166,9 @@ export function transformExpr(expr, signalNames, computedNames, propsObjectName
153
166
  *
154
167
  * - `emitsObjectName(` → `this._emit(` (emit call)
155
168
  * - `props.x` → `this._s_x()` (prop access)
169
+ * - `varName.set(value)` → `this._modelSet_{propName}(value)` (model signal write)
156
170
  * - `x.set(value)` → `this._x(value)` (signal write via setter)
171
+ * - `varName()` → `this._m_{propName}()` (model signal read)
157
172
  * - `x()` → `this._x()` (signal read)
158
173
  * - Computed `x()` → `this._c_x()` (computed read)
159
174
  *
@@ -164,9 +179,11 @@ export function transformExpr(expr, signalNames, computedNames, propsObjectName
164
179
  * @param {Set<string>} [propNames] — Set of prop names
165
180
  * @param {string|null} [emitsObjectName] — Emits object variable name
166
181
  * @param {string[]} [refVarNames] — Ref variable names from templateRef declarations
182
+ * @param {string[]} [constantNames] — Constant variable names
183
+ * @param {Map<string,string>} [modelVarMap] — Map from model varName → propName
167
184
  * @returns {string}
168
185
  */
169
- export function transformMethodBody(body, signalNames, computedNames, propsObjectName = null, propNames = new Set(), emitsObjectName = null, refVarNames = [], constantNames = []) {
186
+ export function transformMethodBody(body, signalNames, computedNames, propsObjectName = null, propNames = new Set(), emitsObjectName = null, refVarNames = [], constantNames = [], modelVarMap = new Map()) {
170
187
  let result = body;
171
188
 
172
189
  // 0a. Transform emit calls: emitsObjectName( → this._emit(
@@ -192,6 +209,15 @@ export function transformMethodBody(body, signalNames, computedNames, propsObjec
192
209
  result = result.replace(refRe, `this._${name}.value`);
193
210
  }
194
211
 
212
+ // 0d. Transform model signal writes: varName.set(expr) → this._modelSet_{propName}(expr)
213
+ // Must run BEFORE regular signal .set() transforms
214
+ for (const [varName, propNameVal] of modelVarMap) {
215
+ if (propsObjectName && varName === propsObjectName) continue;
216
+ if (emitsObjectName && varName === emitsObjectName) continue;
217
+ const setRe = new RegExp(`\\b${varName}\\.set\\(`, 'g');
218
+ result = result.replace(setRe, `this._modelSet_${propNameVal}(`);
219
+ }
220
+
195
221
  // 1. Transform signal writes: x.set(value) → this._x(value)
196
222
  for (const name of signalNames) {
197
223
  if (propsObjectName && name === propsObjectName) continue;
@@ -200,6 +226,15 @@ export function transformMethodBody(body, signalNames, computedNames, propsObjec
200
226
  result = result.replace(setRe, `this._${name}(`);
201
227
  }
202
228
 
229
+ // 1b. Transform model signal reads: varName() → this._m_{propName}()
230
+ // Must run BEFORE regular signal read transforms
231
+ for (const [varName, propNameVal] of modelVarMap) {
232
+ if (propsObjectName && varName === propsObjectName) continue;
233
+ if (emitsObjectName && varName === emitsObjectName) continue;
234
+ const readRe = new RegExp(`\\b${varName}\\(\\)`, 'g');
235
+ result = result.replace(readRe, `this._m_${propNameVal}()`);
236
+ }
237
+
203
238
  // 2. Transform computed reads: x() → this._c_x()
204
239
  for (const name of computedNames) {
205
240
  if (propsObjectName && name === propsObjectName) continue;
@@ -327,22 +362,23 @@ export function isStaticForExpr(expr, itemVar, indexVar, propsSet, rootVarNames,
327
362
  * @param {Set<string>} propNames
328
363
  * @param {string|null} emitsObjectName
329
364
  * @param {string[]} constantNames
365
+ * @param {Map<string,string>} [modelVarMap] — Map from model varName → propName
330
366
  * @returns {string}
331
367
  */
332
- export function generateEventHandler(handler, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames) {
368
+ export function generateEventHandler(handler, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, modelVarMap = new Map()) {
333
369
  if (handler.includes('=>')) {
334
370
  // Arrow function expression: (e) => removeItem(item)
335
371
  const arrowIdx = handler.indexOf('=>');
336
372
  const params = handler.slice(0, arrowIdx).trim();
337
373
  let body = handler.slice(arrowIdx + 2).trim();
338
- body = transformMethodBody(body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, [], constantNames);
374
+ body = transformMethodBody(body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, [], constantNames, modelVarMap);
339
375
  return `${params} => { ${body}; }`;
340
376
  } else if (handler.includes('(')) {
341
377
  // Function call expression: removeItem(item)
342
378
  const parenIdx = handler.indexOf('(');
343
379
  const fnName = handler.slice(0, parenIdx).trim();
344
380
  const args = handler.slice(parenIdx + 1, handler.lastIndexOf(')')).trim();
345
- const transformedArgs = args ? transformExpr(args, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames) : '';
381
+ const transformedArgs = args ? transformExpr(args, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames, modelVarMap) : '';
346
382
  return `(e) => { this._${fnName}(${transformedArgs}); }`;
347
383
  } else {
348
384
  // Simple method name
@@ -829,6 +865,7 @@ export function generateComponent(parseResult, options = {}) {
829
865
  onMountHooks = [],
830
866
  onDestroyHooks = [],
831
867
  modelBindings = [],
868
+ modelPropBindings = [],
832
869
  attrBindings = [],
833
870
  slots = [],
834
871
  constantVars = [],
@@ -838,6 +875,7 @@ export function generateComponent(parseResult, options = {}) {
838
875
  childComponents = [],
839
876
  childImports = [],
840
877
  exposeNames = [],
878
+ modelDefs = [],
841
879
  } = parseResult;
842
880
 
843
881
  const signalNames = signals.map(s => s.name);
@@ -847,6 +885,12 @@ export function generateComponent(parseResult, options = {}) {
847
885
  const refVarNames = refs.map(r => r.varName);
848
886
  const propNames = new Set(propDefs.map(p => p.name));
849
887
 
888
+ // Build model var name → prop name map for transform functions
889
+ const modelVarMap = new Map();
890
+ for (const md of modelDefs) {
891
+ modelVarMap.set(md.varName, md.name);
892
+ }
893
+
850
894
  const lines = [];
851
895
 
852
896
  // ── 0. Source comment ──
@@ -859,7 +903,7 @@ export function generateComponent(parseResult, options = {}) {
859
903
  // Tree-shake: only import what this component actually uses
860
904
  const usedRuntime = new Set(['__signal']); // always need __signal
861
905
  if (computeds.length > 0) usedRuntime.add('__computed');
862
- if (effects.length > 0 || bindings.length > 0 || showBindings.length > 0 || modelBindings.length > 0 || attrBindings.length > 0 || ifBlocks.length > 0 || forBlocks.length > 0 || watchers.length > 0 || childComponents.length > 0 || slots.some(s => s.slotProps.length > 0)) usedRuntime.add('__effect');
906
+ if (effects.length > 0 || bindings.length > 0 || showBindings.length > 0 || modelBindings.length > 0 || modelPropBindings.length > 0 || attrBindings.length > 0 || ifBlocks.length > 0 || forBlocks.length > 0 || watchers.length > 0 || childComponents.length > 0 || slots.some(s => s.slotProps.length > 0)) usedRuntime.add('__effect');
863
907
  if (watchers.length > 0) usedRuntime.add('__untrack');
864
908
  const imports = [...usedRuntime].join(', ');
865
909
  lines.push(`import { ${imports} } from '${options.runtimeImportPath}';`);
@@ -897,10 +941,13 @@ export function generateComponent(parseResult, options = {}) {
897
941
  // ── 4. HTMLElement class ──
898
942
  lines.push(`class ${className} extends HTMLElement {`);
899
943
 
900
- // Static observedAttributes (if props exist)
901
- if (propDefs.length > 0) {
902
- const attrNames = propDefs.map(p => `'${p.attrName}'`).join(', ');
903
- lines.push(` static get observedAttributes() { return [${attrNames}]; }`);
944
+ // Static observedAttributes (if props or model props exist)
945
+ const modelAttrNames = modelDefs.map(md => camelToKebab(md.name));
946
+ if (propDefs.length > 0 || modelDefs.length > 0) {
947
+ const propAttrNames = propDefs.map(p => `'${p.attrName}'`);
948
+ const modelAttrEntries = modelAttrNames.map(a => `'${a}'`);
949
+ const allAttrNames = [...propAttrNames, ...modelAttrEntries].join(', ');
950
+ lines.push(` static get observedAttributes() { return [${allAttrNames}]; }`);
904
951
  lines.push('');
905
952
  }
906
953
 
@@ -918,6 +965,11 @@ export function generateComponent(parseResult, options = {}) {
918
965
  lines.push(` this._${s.name} = __signal(${s.value});`);
919
966
  }
920
967
 
968
+ // Model signal initialization
969
+ for (const md of modelDefs) {
970
+ lines.push(` this._m_${md.name} = __signal(${md.default});`);
971
+ }
972
+
921
973
  // Constant initialization
922
974
  for (const c of constantVars) {
923
975
  lines.push(` this._const_${c.name} = ${c.value};`);
@@ -925,7 +977,7 @@ export function generateComponent(parseResult, options = {}) {
925
977
 
926
978
  // Computed initialization
927
979
  for (const c of computeds) {
928
- const body = transformExpr(c.body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames);
980
+ const body = transformExpr(c.body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames, modelVarMap);
929
981
  lines.push(` this._c_${c.name} = __computed(() => ${body});`);
930
982
  }
931
983
 
@@ -1005,6 +1057,11 @@ export function generateComponent(parseResult, options = {}) {
1005
1057
  lines.push(` this.${mb.varName} = ${pathExpr(mb.path, '__root')};`);
1006
1058
  }
1007
1059
 
1060
+ // Assign DOM refs for model:propName bindings
1061
+ for (const mpb of modelPropBindings) {
1062
+ lines.push(` this.${mpb.varName} = ${pathExpr(mpb.path, '__root')};`);
1063
+ }
1064
+
1008
1065
  // Assign DOM refs for slot placeholders
1009
1066
  for (const s of slots) {
1010
1067
  lines.push(` this.${s.varName} = ${pathExpr(s.path, '__root')};`);
@@ -1107,7 +1164,7 @@ export function generateComponent(parseResult, options = {}) {
1107
1164
  ref = `this._s_${propName}()`;
1108
1165
  } else {
1109
1166
  // Use transformExpr for complex expressions (e.g. items().length, ternary)
1110
- ref = transformExpr(b.name, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames);
1167
+ ref = transformExpr(b.name, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames, modelVarMap);
1111
1168
  }
1112
1169
  lines.push(' this.__disposers.push(__effect(() => {');
1113
1170
  lines.push(` this.${b.varName}.textContent = ${ref} ?? '';`);
@@ -1158,7 +1215,7 @@ export function generateComponent(parseResult, options = {}) {
1158
1215
 
1159
1216
  // User effects
1160
1217
  for (const eff of effects) {
1161
- const body = transformMethodBody(eff.body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, refVarNames, constantNames);
1218
+ const body = transformMethodBody(eff.body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, refVarNames, constantNames, modelVarMap);
1162
1219
  lines.push(' this.__disposers.push(__effect(() => {');
1163
1220
  // Indent each line of the body
1164
1221
  const bodyLines = body.split('\n');
@@ -1171,7 +1228,7 @@ export function generateComponent(parseResult, options = {}) {
1171
1228
  // Watcher effects
1172
1229
  for (let idx = 0; idx < watchers.length; idx++) {
1173
1230
  const w = watchers[idx];
1174
- const body = transformMethodBody(w.body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, refVarNames, constantNames);
1231
+ const body = transformMethodBody(w.body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, refVarNames, constantNames, modelVarMap);
1175
1232
 
1176
1233
  if (w.kind === 'signal') {
1177
1234
  // Determine the signal reference for the watch target
@@ -1186,7 +1243,9 @@ export function generateComponent(parseResult, options = {}) {
1186
1243
  lines.push(' this.__disposers.push(__effect(() => {');
1187
1244
  lines.push(` const ${w.newParam} = ${watchRef};`);
1188
1245
  lines.push(` if (this.__prev_${w.target} !== undefined && this.__prev_${w.target} !== ${w.newParam}) {`);
1189
- lines.push(` const ${w.oldParam} = this.__prev_${w.target};`);
1246
+ if (w.oldParam) {
1247
+ lines.push(` const ${w.oldParam} = this.__prev_${w.target};`);
1248
+ }
1190
1249
  lines.push(' __untrack(() => {');
1191
1250
  const bodyLines = body.split('\n');
1192
1251
  for (const line of bodyLines) {
@@ -1198,12 +1257,14 @@ export function generateComponent(parseResult, options = {}) {
1198
1257
  lines.push(' }));');
1199
1258
  } else {
1200
1259
  // kind === 'getter' — transform the getter expression and use it directly
1201
- const getterExpr = transformMethodBody(w.target, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, refVarNames, constantNames);
1260
+ const getterExpr = transformMethodBody(w.target, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, refVarNames, constantNames, modelVarMap);
1202
1261
  const prevName = `__prev_watch${idx}`;
1203
1262
  lines.push(' this.__disposers.push(__effect(() => {');
1204
1263
  lines.push(` const ${w.newParam} = ${getterExpr};`);
1205
1264
  lines.push(` if (this.${prevName} !== undefined && this.${prevName} !== ${w.newParam}) {`);
1206
- lines.push(` const ${w.oldParam} = this.${prevName};`);
1265
+ if (w.oldParam) {
1266
+ lines.push(` const ${w.oldParam} = this.${prevName};`);
1267
+ }
1207
1268
  lines.push(' __untrack(() => {');
1208
1269
  const bodyLines2 = body.split('\n');
1209
1270
  for (const line of bodyLines2) {
@@ -1218,13 +1279,13 @@ export function generateComponent(parseResult, options = {}) {
1218
1279
 
1219
1280
  // Event listeners (with AbortController signal for cleanup)
1220
1281
  for (const e of events) {
1221
- const handlerExpr = generateEventHandler(e.handler, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames);
1282
+ const handlerExpr = generateEventHandler(e.handler, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, modelVarMap);
1222
1283
  lines.push(` this.${e.varName}.addEventListener('${e.event}', ${handlerExpr}, { signal: this.__ac.signal });`);
1223
1284
  }
1224
1285
 
1225
1286
  // Show effects — one __effect per ShowBinding
1226
1287
  for (const sb of showBindings) {
1227
- const expr = transformExpr(sb.expression, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames);
1288
+ const expr = transformExpr(sb.expression, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames, modelVarMap);
1228
1289
  lines.push(' this.__disposers.push(__effect(() => {');
1229
1290
  lines.push(` this.${sb.varName}.style.display = (${expr}) ? '' : 'none';`);
1230
1291
  lines.push(' }));');
@@ -1264,9 +1325,35 @@ export function generateComponent(parseResult, options = {}) {
1264
1325
  }
1265
1326
  }
1266
1327
 
1328
+ // model:propName effects and listeners — bidirectional WCC-to-WCC binding
1329
+ for (const mpb of modelPropBindings) {
1330
+ // Determine the signal read/write expressions
1331
+ // If the signal is a model var, use this._m_propName(); otherwise use this._signalName()
1332
+ const isModelVar = modelVarMap.has(mpb.signal);
1333
+ const readExpr = isModelVar
1334
+ ? `this._m_${modelVarMap.get(mpb.signal)}()`
1335
+ : `this._${mpb.signal}()`;
1336
+ const writeExpr = isModelVar
1337
+ ? `this._m_${modelVarMap.get(mpb.signal)}`
1338
+ : `this._${mpb.signal}`;
1339
+
1340
+ // Reactive parent → child sync: set child's attribute from parent signal
1341
+ const attrName = camelToKebab(mpb.propName);
1342
+ lines.push(' this.__disposers.push(__effect(() => {');
1343
+ lines.push(` this.${mpb.varName}.setAttribute('${attrName}', ${readExpr} ?? '');`);
1344
+ lines.push(' }));');
1345
+
1346
+ // Child → parent sync: listen for wcc:model on child, update parent signal
1347
+ lines.push(` this.${mpb.varName}.addEventListener('wcc:model', (e) => {`);
1348
+ lines.push(` if (e.detail.prop === '${mpb.propName}') {`);
1349
+ lines.push(` ${writeExpr}(e.detail.value);`);
1350
+ lines.push(' }');
1351
+ lines.push(' }, { signal: this.__ac.signal });');
1352
+ }
1353
+
1267
1354
  // Attr binding effects — one __effect per AttrBinding
1268
1355
  for (const ab of attrBindings) {
1269
- const expr = transformExpr(ab.expression, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames);
1356
+ const expr = transformExpr(ab.expression, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames, modelVarMap);
1270
1357
  if (ab.kind === 'attr') {
1271
1358
  lines.push(' this.__disposers.push(__effect(() => {');
1272
1359
  lines.push(` const __v = ${expr};`);
@@ -1318,10 +1405,10 @@ export function generateComponent(parseResult, options = {}) {
1318
1405
  for (let i = 0; i < ifBlock.branches.length; i++) {
1319
1406
  const branch = ifBlock.branches[i];
1320
1407
  if (branch.type === 'if') {
1321
- const expr = transformExpr(branch.expression, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames);
1408
+ const expr = transformExpr(branch.expression, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames, modelVarMap);
1322
1409
  lines.push(` if (${expr}) { __branch = ${i}; }`);
1323
1410
  } else if (branch.type === 'else-if') {
1324
- const expr = transformExpr(branch.expression, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames);
1411
+ const expr = transformExpr(branch.expression, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames, modelVarMap);
1325
1412
  lines.push(` else if (${expr}) { __branch = ${i}; }`);
1326
1413
  } else {
1327
1414
  // else
@@ -1431,7 +1518,7 @@ export function generateComponent(parseResult, options = {}) {
1431
1518
 
1432
1519
  // Lifecycle: onMount hooks (at the very end of connectedCallback)
1433
1520
  for (const hook of onMountHooks) {
1434
- const body = transformMethodBody(hook.body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, refVarNames, constantNames);
1521
+ const body = transformMethodBody(hook.body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, refVarNames, constantNames, modelVarMap);
1435
1522
  if (hook.async) {
1436
1523
  lines.push(' ;(async () => {');
1437
1524
  const bodyLines = body.split('\n');
@@ -1459,7 +1546,7 @@ export function generateComponent(parseResult, options = {}) {
1459
1546
  lines.push(' this.__disposers.forEach(d => d());');
1460
1547
  if (onDestroyHooks.length > 0) {
1461
1548
  for (const hook of onDestroyHooks) {
1462
- const body = transformMethodBody(hook.body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, refVarNames, constantNames);
1549
+ const body = transformMethodBody(hook.body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, refVarNames, constantNames, modelVarMap);
1463
1550
  if (hook.async) {
1464
1551
  lines.push(' ;(async () => {');
1465
1552
  const bodyLines = body.split('\n');
@@ -1480,8 +1567,8 @@ export function generateComponent(parseResult, options = {}) {
1480
1567
  lines.push(' }');
1481
1568
  lines.push('');
1482
1569
 
1483
- // attributeChangedCallback (if props exist)
1484
- if (propDefs.length > 0) {
1570
+ // attributeChangedCallback (if props or model props exist)
1571
+ if (propDefs.length > 0 || modelDefs.length > 0) {
1485
1572
  lines.push(' attributeChangedCallback(name, oldVal, newVal) {');
1486
1573
  for (const p of propDefs) {
1487
1574
  const defaultVal = p.default;
@@ -1503,6 +1590,31 @@ export function generateComponent(parseResult, options = {}) {
1503
1590
 
1504
1591
  lines.push(` if (name === '${p.attrName}') ${updateExpr};`);
1505
1592
  }
1593
+
1594
+ // Model props — update signal directly (NO event emission)
1595
+ for (let i = 0; i < modelDefs.length; i++) {
1596
+ const md = modelDefs[i];
1597
+ const attrName = modelAttrNames[i];
1598
+ const defaultVal = md.default;
1599
+ let updateExpr;
1600
+
1601
+ if (defaultVal === 'true' || defaultVal === 'false') {
1602
+ // Boolean coercion: attribute presence = true
1603
+ updateExpr = `this._m_${md.name}(newVal != null)`;
1604
+ } else if (/^-?\d+(\.\d+)?$/.test(defaultVal)) {
1605
+ // Number coercion
1606
+ updateExpr = `this._m_${md.name}(newVal != null ? Number(newVal) : ${defaultVal})`;
1607
+ } else if (defaultVal === 'undefined') {
1608
+ // Undefined default — pass through
1609
+ updateExpr = `this._m_${md.name}(newVal)`;
1610
+ } else {
1611
+ // String default — use nullish coalescing
1612
+ updateExpr = `this._m_${md.name}(newVal ?? ${defaultVal})`;
1613
+ }
1614
+
1615
+ lines.push(` if (name === '${attrName}') ${updateExpr};`);
1616
+ }
1617
+
1506
1618
  lines.push(' }');
1507
1619
  lines.push('');
1508
1620
 
@@ -1512,6 +1624,15 @@ export function generateComponent(parseResult, options = {}) {
1512
1624
  lines.push(` set ${p.name}(val) { this._s_${p.name}(val); this.setAttribute('${p.attrName}', String(val)); }`);
1513
1625
  lines.push('');
1514
1626
  }
1627
+
1628
+ // Public getters and setters for model props
1629
+ for (let i = 0; i < modelDefs.length; i++) {
1630
+ const md = modelDefs[i];
1631
+ const attrName = modelAttrNames[i];
1632
+ lines.push(` get ${md.name}() { return this._m_${md.name}(); }`);
1633
+ lines.push(` set ${md.name}(val) { this._m_${md.name}(val); this.setAttribute('${attrName}', String(val)); }`);
1634
+ lines.push('');
1635
+ }
1515
1636
  }
1516
1637
 
1517
1638
  // _emit method (if emits declared)
@@ -1522,9 +1643,23 @@ export function generateComponent(parseResult, options = {}) {
1522
1643
  lines.push('');
1523
1644
  }
1524
1645
 
1646
+ // _modelSet methods (one per defineModel prop — emits wcc:model on internal write)
1647
+ for (const md of modelDefs) {
1648
+ lines.push(` _modelSet_${md.name}(newVal) {`);
1649
+ lines.push(` const oldVal = this._m_${md.name}();`);
1650
+ lines.push(` this._m_${md.name}(newVal);`);
1651
+ lines.push(` this.dispatchEvent(new CustomEvent('wcc:model', {`);
1652
+ lines.push(` detail: { prop: '${md.name}', value: newVal, oldValue: oldVal },`);
1653
+ lines.push(` bubbles: true,`);
1654
+ lines.push(` composed: true`);
1655
+ lines.push(` }));`);
1656
+ lines.push(' }');
1657
+ lines.push('');
1658
+ }
1659
+
1525
1660
  // User methods (prefixed with _)
1526
1661
  for (const m of methods) {
1527
- const body = transformMethodBody(m.body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, refVarNames, constantNames);
1662
+ const body = transformMethodBody(m.body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, refVarNames, constantNames, modelVarMap);
1528
1663
  lines.push(` _${m.name}(${m.params}) {`);
1529
1664
  const bodyLines = body.split('\n');
1530
1665
  for (const line of bodyLines) {
@@ -1600,7 +1735,7 @@ export function generateComponent(parseResult, options = {}) {
1600
1735
  const propName = b.name.slice(propsObjectName.length + 1);
1601
1736
  ref = `this._s_${propName}()`;
1602
1737
  } else {
1603
- ref = transformExpr(b.name, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames);
1738
+ ref = transformExpr(b.name, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames, modelVarMap);
1604
1739
  }
1605
1740
  lines.push(` __effect(() => { ${b.varName}.textContent = ${ref} ?? ''; });`);
1606
1741
  }
@@ -1608,21 +1743,21 @@ export function generateComponent(parseResult, options = {}) {
1608
1743
 
1609
1744
  // Events: generate addEventListener
1610
1745
  for (const e of branch.events) {
1611
- const handlerExpr = generateEventHandler(e.handler, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames);
1746
+ const handlerExpr = generateEventHandler(e.handler, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, modelVarMap);
1612
1747
  lines.push(` const ${e.varName} = ${pathExpr(e.path, 'node')};`);
1613
1748
  lines.push(` ${e.varName}.addEventListener('${e.event}', ${handlerExpr});`);
1614
1749
  }
1615
1750
 
1616
1751
  // Show bindings: generate effects
1617
1752
  for (const sb of branch.showBindings) {
1618
- const expr = transformExpr(sb.expression, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames);
1753
+ const expr = transformExpr(sb.expression, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames, modelVarMap);
1619
1754
  lines.push(` const ${sb.varName} = ${pathExpr(sb.path, 'node')};`);
1620
1755
  lines.push(` __effect(() => { ${sb.varName}.style.display = (${expr}) ? '' : 'none'; });`);
1621
1756
  }
1622
1757
 
1623
1758
  // Attr bindings: generate effects
1624
1759
  for (const ab of branch.attrBindings) {
1625
- const expr = transformExpr(ab.expression, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames);
1760
+ const expr = transformExpr(ab.expression, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames, modelVarMap);
1626
1761
  lines.push(` const ${ab.varName} = ${pathExpr(ab.path, 'node')};`);
1627
1762
  lines.push(` __effect(() => {`);
1628
1763
  lines.push(` const __val = ${expr};`);
@@ -69,10 +69,11 @@ function walkTree(rootEl, signalNames, computedNames, propNames = new Set()) {
69
69
  const events = [];
70
70
  const showBindings = [];
71
71
  const modelBindings = [];
72
+ const modelPropBindings = [];
72
73
  const attrBindings = [];
73
74
  const slots = [];
74
75
  const childComponents = [];
75
- let bindIdx = 0, eventIdx = 0, showIdx = 0, modelIdx = 0, attrIdx = 0, slotIdx = 0, childIdx = 0;
76
+ let bindIdx = 0, eventIdx = 0, showIdx = 0, modelIdx = 0, modelPropIdx = 0, attrIdx = 0, slotIdx = 0, childIdx = 0;
76
77
 
77
78
  function bindingType(name) {
78
79
  if (propNames.has(name)) return 'prop';
@@ -105,7 +106,7 @@ function walkTree(rootEl, signalNames, computedNames, propNames = new Set()) {
105
106
  if (tagLower.includes('-') && tagLower !== rootEl.tagName?.toLowerCase()) {
106
107
  const propBindings = [];
107
108
  for (const attr of Array.from(el.attributes)) {
108
- if (attr.name.startsWith('@') || attr.name.startsWith(':') || attr.name.startsWith('bind:')) continue;
109
+ if (attr.name.startsWith('@') || attr.name.startsWith(':') || attr.name.startsWith('bind:') || attr.name.startsWith('model:')) continue;
109
110
  if (['show', 'model', 'if', 'else-if', 'else', 'each', 'ref'].includes(attr.name)) continue;
110
111
  const interpMatch = attr.value.match(/^\{\{([\w.]+)\}\}$/);
111
112
  if (interpMatch) {
@@ -158,6 +159,24 @@ function walkTree(rootEl, signalNames, computedNames, propNames = new Set()) {
158
159
  modelBindings.push({ varName: `__model${modelIdx++}`, signal: signalName, prop, event, coerce, radioValue, path: [...pathParts] });
159
160
  el.removeAttribute('model');
160
161
  }
162
+
163
+ // Detect model:propName="signalName" attributes (for custom element binding)
164
+ const modelPropAttrsToRemove = [];
165
+ for (const attr of Array.from(el.attributes)) {
166
+ if (attr.name.startsWith('model:')) {
167
+ const propName = attr.name.slice(6);
168
+ const signal = attr.value;
169
+ const tag = el.tagName.toLowerCase();
170
+ if (!tag.includes('-')) {
171
+ const error = new Error(`model:propName is only valid on custom elements (tag must contain a hyphen)`);
172
+ error.code = 'MODEL_PROP_INVALID_TARGET';
173
+ throw error;
174
+ }
175
+ modelPropBindings.push({ varName: `__modelProp${modelPropIdx++}`, propName, signal, path: [...pathParts] });
176
+ modelPropAttrsToRemove.push(attr.name);
177
+ }
178
+ }
179
+ modelPropAttrsToRemove.forEach(a => el.removeAttribute(a));
161
180
  }
162
181
 
163
182
  if (node.nodeType === 3 && /\{\{[\w.]+\}\}/.test(node.textContent)) {
@@ -201,7 +220,7 @@ function walkTree(rootEl, signalNames, computedNames, propNames = new Set()) {
201
220
  }
202
221
 
203
222
  walk(rootEl, []);
204
- return { bindings, events, showBindings, modelBindings, attrBindings, slots, childComponents };
223
+ return { bindings, events, showBindings, modelBindings, modelPropBindings, attrBindings, slots, childComponents };
205
224
  }
206
225
 
207
226
  function recomputeAnchorPath(rootEl, targetNode) {
@@ -486,7 +505,7 @@ export async function compileFromStrings({ script, template, style = '', tag, la
486
505
  for (const ib of ifBlocks) ib.anchorPath = recomputeAnchorPath(rootEl, ib._anchorNode);
487
506
 
488
507
  // 11. Walk tree
489
- const { bindings, events, showBindings, modelBindings, attrBindings, slots, childComponents } = walkTree(rootEl, signalNameSet, computedNameSet, propNameSet);
508
+ const { bindings, events, showBindings, modelBindings, modelPropBindings, attrBindings, slots, childComponents } = walkTree(rootEl, signalNameSet, computedNameSet, propNameSet);
490
509
 
491
510
  // 12. Detect refs
492
511
  const refBindings = detectRefs(rootEl);
package/lib/compiler.js CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  extractRefs,
34
34
  extractConstants,
35
35
  extractExpose,
36
+ extractModels,
36
37
  validatePropsAssignment,
37
38
  validateDuplicateProps,
38
39
  validatePropsConflicts,
@@ -158,6 +159,7 @@ async function compileSFC(filePath, config) {
158
159
  const refs = extractRefs(sourceForExtraction);
159
160
  const constantVars = extractConstants(sourceForExtraction);
160
161
  const exposeNames = extractExpose(source);
162
+ const modelDefs = extractModels(sourceForExtraction);
161
163
 
162
164
  // 9. Extract props (array form — after type strip, if generic didn't find any)
163
165
  const propsFromArray = propsFromGeneric.length > 0 ? [] : extractPropsArray(source);
@@ -197,6 +199,58 @@ async function compileSFC(filePath, config) {
197
199
  validateEmitsConflicts(emitsObjectName, signalNameSet, computedNameSet, constantNameSet, propNameSet, propsObjectName, filePath);
198
200
  validateUndeclaredEmits(source, emitsObjectName, emitNames, filePath);
199
201
 
202
+ // 14b. Validate defineModel declarations
203
+ // MODEL_NO_ASSIGNMENT: detect bare defineModel() calls not assigned to a variable
204
+ const bareModelRe = /\bdefineModel\s*\(/g;
205
+ const assignedModelRe = /(?:const|let|var)\s+\w+\s*=\s*defineModel\s*\(/g;
206
+ const bareModelCount = (sourceForExtraction.match(bareModelRe) || []).length;
207
+ const assignedModelCount = (sourceForExtraction.match(assignedModelRe) || []).length;
208
+ if (bareModelCount > assignedModelCount) {
209
+ const error = new Error(`defineModel() must be assigned to a variable`);
210
+ /** @ts-expect-error — custom error code */
211
+ error.code = 'MODEL_NO_ASSIGNMENT';
212
+ throw error;
213
+ }
214
+
215
+ // MODEL_MISSING_NAME: check each extracted model has a name property
216
+ for (const md of modelDefs) {
217
+ if (!md.name) {
218
+ const error = new Error(`defineModel() requires a 'name' property in the options object`);
219
+ /** @ts-expect-error — custom error code */
220
+ error.code = 'MODEL_MISSING_NAME';
221
+ throw error;
222
+ }
223
+ }
224
+
225
+ // MODEL_NAME_CONFLICT: check model prop names against signals, computeds, constants, and props
226
+ for (const md of modelDefs) {
227
+ if (!md.name) continue;
228
+ if (signalNameSet.has(md.name)) {
229
+ const error = new Error(`defineModel prop '${md.name}' conflicts with existing signal '${md.name}'`);
230
+ /** @ts-expect-error — custom error code */
231
+ error.code = 'MODEL_NAME_CONFLICT';
232
+ throw error;
233
+ }
234
+ if (computedNameSet.has(md.name)) {
235
+ const error = new Error(`defineModel prop '${md.name}' conflicts with existing computed '${md.name}'`);
236
+ /** @ts-expect-error — custom error code */
237
+ error.code = 'MODEL_NAME_CONFLICT';
238
+ throw error;
239
+ }
240
+ if (constantNameSet.has(md.name)) {
241
+ const error = new Error(`defineModel prop '${md.name}' conflicts with existing constant '${md.name}'`);
242
+ /** @ts-expect-error — custom error code */
243
+ error.code = 'MODEL_NAME_CONFLICT';
244
+ throw error;
245
+ }
246
+ if (propNameSet.has(md.name)) {
247
+ const error = new Error(`defineModel prop '${md.name}' conflicts with existing prop '${md.name}'`);
248
+ /** @ts-expect-error — custom error code */
249
+ error.code = 'MODEL_NAME_CONFLICT';
250
+ throw error;
251
+ }
252
+ }
253
+
200
254
  // 15. Build initial ParseResult
201
255
  /** @type {import('./types.js').ParseResult} */
202
256
  const parseResult = {
@@ -223,6 +277,7 @@ async function compileSFC(filePath, config) {
223
277
  onMountHooks,
224
278
  onDestroyHooks,
225
279
  modelBindings: [],
280
+ modelPropBindings: [],
226
281
  attrBindings: [],
227
282
  slots: [],
228
283
  refs,
@@ -230,6 +285,7 @@ async function compileSFC(filePath, config) {
230
285
  childComponents: [],
231
286
  childImports: [],
232
287
  exposeNames,
288
+ modelDefs,
233
289
  };
234
290
 
235
291
  // 16. Process template through linkedom → tree-walker → codegen
@@ -237,6 +293,10 @@ async function compileSFC(filePath, config) {
237
293
  const rootEl = document.getElementById('__root');
238
294
 
239
295
  const signalNames = new Set(signals.map(s => s.name));
296
+ // Add model var names so they are recognized as writable signals in tree-walker
297
+ for (const md of modelDefs) {
298
+ signalNames.add(md.varName);
299
+ }
240
300
  const computedNames = new Set(computeds.map(c => c.name));
241
301
  const propNamesSet = new Set(propDefs.map(p => p.name));
242
302
 
@@ -252,7 +312,7 @@ async function compileSFC(filePath, config) {
252
312
  ib.anchorPath = recomputeAnchorPath(rootEl, ib._anchorNode);
253
313
  }
254
314
 
255
- const { bindings, events, showBindings, modelBindings, attrBindings, slots, childComponents } = walkTree(rootEl, signalNames, computedNames, propNamesSet);
315
+ const { bindings, events, showBindings, modelBindings, modelPropBindings, attrBindings, slots, childComponents } = walkTree(rootEl, signalNames, computedNames, propNamesSet);
256
316
 
257
317
  const refBindings = detectRefs(rootEl);
258
318
 
@@ -300,6 +360,38 @@ async function compileSFC(filePath, config) {
300
360
  }
301
361
  }
302
362
 
363
+ // 17c. Validate model:propName bindings
364
+ for (const mpb of modelPropBindings) {
365
+ const name = mpb.signal;
366
+ // Check if the referenced variable exists at all
367
+ const isKnown = signalNames.has(name) || computedNames.has(name) || propNamesSet.has(name) || constantNamesForModel.has(name);
368
+ if (!isKnown) {
369
+ const error = new Error(`model:propName references undeclared variable '${name}'`);
370
+ /** @ts-expect-error — custom error code */
371
+ error.code = 'MODEL_PROP_UNKNOWN_VAR';
372
+ throw error;
373
+ }
374
+ // Check if the referenced variable is read-only
375
+ if (propNamesSet.has(name)) {
376
+ const error = new Error(`model:propName cannot bind to prop '${name}' (read-only)`);
377
+ /** @ts-expect-error — custom error code */
378
+ error.code = 'MODEL_PROP_READONLY';
379
+ throw error;
380
+ }
381
+ if (computedNames.has(name)) {
382
+ const error = new Error(`model:propName cannot bind to computed '${name}' (read-only)`);
383
+ /** @ts-expect-error — custom error code */
384
+ error.code = 'MODEL_PROP_READONLY';
385
+ throw error;
386
+ }
387
+ if (constantNamesForModel.has(name)) {
388
+ const error = new Error(`model:propName cannot bind to constant '${name}' (read-only)`);
389
+ /** @ts-expect-error — custom error code */
390
+ error.code = 'MODEL_PROP_READONLY';
391
+ throw error;
392
+ }
393
+ }
394
+
303
395
  // 18. Resolve child component imports (from main template + if branches + each blocks)
304
396
  /** @type {import('./types.js').ChildComponentImport[]} */
305
397
  const childImports = [];
@@ -339,6 +431,7 @@ async function compileSFC(filePath, config) {
339
431
  parseResult.events = events;
340
432
  parseResult.showBindings = showBindings;
341
433
  parseResult.modelBindings = modelBindings;
434
+ parseResult.modelPropBindings = modelPropBindings;
342
435
  parseResult.attrBindings = attrBindings;
343
436
  parseResult.ifBlocks = ifBlocks;
344
437
  parseResult.forBlocks = forBlocks;
@@ -596,7 +596,7 @@ export function extractSignals(source) {
596
596
  /**
597
597
  * Known macro/reactive call patterns that should NOT be treated as constants.
598
598
  */
599
- export const REACTIVE_CALLS = /\b(?:signal|computed|effect|watch|defineProps|defineEmits|defineComponent|templateRef|defineExpose|onMount|onDestroy)\s*[<(]/;
599
+ export const REACTIVE_CALLS = /\b(?:signal|computed|effect|watch|defineProps|defineEmits|defineModel|defineComponent|templateRef|defineExpose|onMount|onDestroy)\s*[<(]/;
600
600
 
601
601
  /**
602
602
  * Extract plain const/let/var declarations that are NOT reactive calls.
@@ -782,10 +782,10 @@ export function extractWatchers(source) {
782
782
  while (i < lines.length) {
783
783
  const line = lines[i];
784
784
 
785
- // Form 2 — Getter function: watch(() => expr, (newVal, oldVal) => {
786
- const mGetter = line.match(/\bwatch\s*\(\s*\(\)\s*=>\s*(.+?)\s*,\s*\((\w+)\s*,\s*(\w+)\)\s*=>\s*\{/);
787
- // Form 1 — Signal direct: watch(identifier, (newVal, oldVal) => {
788
- const mSignal = !mGetter ? line.match(/\bwatch\s*\(\s*(\w+)\s*,\s*\((\w+)\s*,\s*(\w+)\)\s*=>\s*\{/) : null;
785
+ // Form 2 — Getter function: watch(() => expr, (newVal, oldVal) => { OR watch(() => expr, (newVal) => {
786
+ const mGetter = line.match(/\bwatch\s*\(\s*\(\)\s*=>\s*(.+?)\s*,\s*\((\w+)(?:\s*,\s*(\w+))?\)\s*=>\s*\{/);
787
+ // Form 1 — Signal direct: watch(identifier, (newVal, oldVal) => { OR watch(identifier, (newVal) => {
788
+ const mSignal = !mGetter ? line.match(/\bwatch\s*\(\s*(\w+)\s*,\s*\((\w+)(?:\s*,\s*(\w+))?\)\s*=>\s*\{/) : null;
789
789
 
790
790
  const m = mGetter || mSignal;
791
791
 
@@ -1028,6 +1028,110 @@ export function extractRefs(source) {
1028
1028
  return refs;
1029
1029
  }
1030
1030
 
1031
+ // ── defineModel extraction ───────────────────────────────────────────
1032
+
1033
+ /**
1034
+ * Extract defineModel() declarations from source.
1035
+ * Pattern: const/let/var varName = defineModel({ name: 'propName', default: value })
1036
+ * const/let/var varName = defineModel({ name: 'propName', required: true })
1037
+ *
1038
+ * @param {string} source
1039
+ * @returns {{ varName: string, name: string, default: string, required: boolean }[]}
1040
+ */
1041
+ export function extractModels(source) {
1042
+ /** @type {{ varName: string, name: string, default: string, required: boolean }[]} */
1043
+ const models = [];
1044
+ const re = /(?:const|let|var)\s+(\w+)\s*=\s*defineModel\(\s*\{/g;
1045
+ let m;
1046
+
1047
+ while ((m = re.exec(source)) !== null) {
1048
+ const varName = m[1];
1049
+ const objStart = m.index + m[0].length - 1; // position of '{'
1050
+
1051
+ // Use depth counting to extract the full object literal
1052
+ let depth = 0;
1053
+ let i = objStart;
1054
+ /** @type {string | null} */
1055
+ let inString = null;
1056
+
1057
+ for (; i < source.length; i++) {
1058
+ const ch = source[i];
1059
+
1060
+ if (inString) {
1061
+ if (ch === '\\') { i++; continue; }
1062
+ if (ch === inString) inString = null;
1063
+ continue;
1064
+ }
1065
+
1066
+ if (ch === '"' || ch === "'" || ch === '`') {
1067
+ inString = ch;
1068
+ continue;
1069
+ }
1070
+
1071
+ if (ch === '{') depth++;
1072
+ if (ch === '}') {
1073
+ depth--;
1074
+ if (depth === 0) { i++; break; }
1075
+ }
1076
+ }
1077
+
1078
+ const objLiteral = source.slice(objStart, i).trim();
1079
+ // Remove outer braces
1080
+ const inner = objLiteral.slice(1, -1).trim();
1081
+
1082
+ // Extract 'name' property
1083
+ const nameMatch = inner.match(/name\s*:\s*['"]([^'"]+)['"]/);
1084
+ const propName = nameMatch ? nameMatch[1] : '';
1085
+
1086
+ // Extract 'default' property using depth counting
1087
+ let defaultValue = 'undefined';
1088
+ const defaultIdx = inner.search(/\bdefault\s*:\s*/);
1089
+ if (defaultIdx !== -1) {
1090
+ const afterDefault = inner.slice(defaultIdx);
1091
+ const colonMatch = afterDefault.match(/^default\s*:\s*/);
1092
+ if (colonMatch) {
1093
+ const valStart = defaultIdx + colonMatch[0].length;
1094
+ let valDepth = 0;
1095
+ let pos = valStart;
1096
+ /** @type {string | null} */
1097
+ let valInString = null;
1098
+
1099
+ for (; pos < inner.length; pos++) {
1100
+ const ch = inner[pos];
1101
+
1102
+ if (valInString) {
1103
+ if (ch === '\\') { pos++; continue; }
1104
+ if (ch === valInString) valInString = null;
1105
+ continue;
1106
+ }
1107
+
1108
+ if (ch === '"' || ch === "'" || ch === '`') {
1109
+ valInString = ch;
1110
+ continue;
1111
+ }
1112
+
1113
+ if (ch === '(' || ch === '[' || ch === '{') valDepth++;
1114
+ if (ch === ')' || ch === ']' || ch === '}') valDepth--;
1115
+
1116
+ if (valDepth === 0 && ch === ',') {
1117
+ break;
1118
+ }
1119
+ }
1120
+
1121
+ defaultValue = inner.slice(valStart, pos).trim();
1122
+ }
1123
+ }
1124
+
1125
+ // Extract 'required' property
1126
+ const requiredMatch = inner.match(/required\s*:\s*true/);
1127
+ const required = !!requiredMatch;
1128
+
1129
+ models.push({ varName, name: propName, default: defaultValue, required });
1130
+ }
1131
+
1132
+ return models;
1133
+ }
1134
+
1031
1135
  // ── defineExpose extraction ─────────────────────────────────────────
1032
1136
 
1033
1137
  /**
@@ -15,7 +15,7 @@
15
15
  import { parseHTML } from 'linkedom';
16
16
  import { BOOLEAN_ATTRIBUTES } from './types.js';
17
17
 
18
- /** @import { Binding, EventBinding, IfBlock, IfBranch, ShowBinding, AttrBinding, ForBlock, ModelBinding, SlotBinding, SlotProp, RefBinding, ChildComponentBinding, ChildPropBinding } from './types.js' */
18
+ /** @import { Binding, EventBinding, IfBlock, IfBranch, ShowBinding, AttrBinding, ForBlock, ModelBinding, ModelPropBinding, SlotBinding, SlotProp, RefBinding, ChildComponentBinding, ChildPropBinding } from './types.js' */
19
19
 
20
20
  /**
21
21
  * Walk a DOM tree rooted at rootEl, discovering bindings and events.
@@ -24,7 +24,7 @@ import { BOOLEAN_ATTRIBUTES } from './types.js';
24
24
  * @param {Set<string>} signalNames — Set of signal variable names
25
25
  * @param {Set<string>} computedNames — Set of computed variable names
26
26
  * @param {Set<string>} [propNames] — Set of prop names from defineProps
27
- * @returns {{ bindings: Binding[], events: EventBinding[], showBindings: ShowBinding[], modelBindings: ModelBinding[], attrBindings: AttrBinding[], slots: SlotBinding[], childComponents: ChildComponentBinding[] }}
27
+ * @returns {{ bindings: Binding[], events: EventBinding[], showBindings: ShowBinding[], modelBindings: ModelBinding[], modelPropBindings: ModelPropBinding[], attrBindings: AttrBinding[], slots: SlotBinding[], childComponents: ChildComponentBinding[] }}
28
28
  */
29
29
  export function walkTree(rootEl, signalNames, computedNames, propNames = new Set()) {
30
30
  /** @type {Binding[]} */
@@ -35,6 +35,8 @@ export function walkTree(rootEl, signalNames, computedNames, propNames = new Set
35
35
  const showBindings = [];
36
36
  /** @type {ModelBinding[]} */
37
37
  const modelBindings = [];
38
+ /** @type {ModelPropBinding[]} */
39
+ const modelPropBindings = [];
38
40
  /** @type {AttrBinding[]} */
39
41
  const attrBindings = [];
40
42
  /** @type {SlotBinding[]} */
@@ -45,6 +47,7 @@ export function walkTree(rootEl, signalNames, computedNames, propNames = new Set
45
47
  let eventIdx = 0;
46
48
  let showIdx = 0;
47
49
  let modelIdx = 0;
50
+ let modelPropIdx = 0;
48
51
  let attrIdx = 0;
49
52
  let slotIdx = 0;
50
53
  let childIdx = 0;
@@ -121,7 +124,7 @@ export function walkTree(rootEl, signalNames, computedNames, propNames = new Set
121
124
  const propBindings = [];
122
125
  for (const attr of Array.from(el.attributes)) {
123
126
  // Skip directive attributes (@event, :bind, show, model, etc.)
124
- if (attr.name.startsWith('@') || attr.name.startsWith(':') || attr.name.startsWith('bind:')) continue;
127
+ if (attr.name.startsWith('@') || attr.name.startsWith(':') || attr.name.startsWith('bind:') || attr.name.startsWith('model:')) continue;
125
128
  if (['show', 'model', 'if', 'else-if', 'else', 'each', 'ref'].includes(attr.name)) continue;
126
129
 
127
130
  // Check for {{interpolation}} in attribute value
@@ -245,6 +248,29 @@ export function walkTree(rootEl, signalNames, computedNames, propNames = new Set
245
248
  modelBindings.push({ varName, signal: signalName, prop, event, coerce, radioValue, path: [...pathParts] });
246
249
  el.removeAttribute('model');
247
250
  }
251
+
252
+ // Detect model:propName="signalName" attributes (for custom element binding)
253
+ const modelPropAttrsToRemove = [];
254
+ for (const attr of Array.from(el.attributes)) {
255
+ if (attr.name.startsWith('model:')) {
256
+ const propName = attr.name.slice(6); // after 'model:'
257
+ const signal = attr.value;
258
+ const tag = el.tagName.toLowerCase();
259
+
260
+ // Validate the element is a custom element (tag contains a hyphen)
261
+ if (!tag.includes('-')) {
262
+ const error = new Error(`model:propName is only valid on custom elements (tag must contain a hyphen)`);
263
+ /** @ts-expect-error — custom error code */
264
+ error.code = 'MODEL_PROP_INVALID_TARGET';
265
+ throw error;
266
+ }
267
+
268
+ const varName = `__modelProp${modelPropIdx++}`;
269
+ modelPropBindings.push({ varName, propName, signal, path: [...pathParts] });
270
+ modelPropAttrsToRemove.push(attr.name);
271
+ }
272
+ }
273
+ modelPropAttrsToRemove.forEach((a) => el.removeAttribute(a));
248
274
  }
249
275
 
250
276
  // --- Text node with interpolations ---
@@ -317,7 +343,7 @@ export function walkTree(rootEl, signalNames, computedNames, propNames = new Set
317
343
  }
318
344
 
319
345
  walk(rootEl, []);
320
- return { bindings, events, showBindings, modelBindings, attrBindings, slots, childComponents };
346
+ return { bindings, events, showBindings, modelBindings, modelPropBindings, attrBindings, slots, childComponents };
321
347
  }
322
348
 
323
349
  // ── Conditional chain processing (if / else-if / else) ──────────────
@@ -363,7 +389,7 @@ function isChainPredecessor(el) {
363
389
  * @param {Set<string>} signalNames
364
390
  * @param {Set<string>} computedNames
365
391
  * @param {Set<string>} propNames
366
- * @returns {{ bindings: Binding[], events: EventBinding[], showBindings: ShowBinding[], attrBindings: AttrBinding[], modelBindings: ModelBinding[], slots: SlotBinding[], processedHtml: string }}
392
+ * @returns {{ bindings: Binding[], events: EventBinding[], showBindings: ShowBinding[], attrBindings: AttrBinding[], modelBindings: ModelBinding[], modelPropBindings: ModelPropBinding[], slots: SlotBinding[], processedHtml: string }}
367
393
  */
368
394
  export function walkBranch(html, signalNames, computedNames, propNames) {
369
395
  const { document } = parseHTML(`<div id="__branchRoot">${html}</div>`);
@@ -398,6 +424,7 @@ export function walkBranch(html, signalNames, computedNames, propNames) {
398
424
  stripFirstSegment(result.showBindings);
399
425
  stripFirstSegment(result.attrBindings);
400
426
  stripFirstSegment(result.modelBindings);
427
+ stripFirstSegment(result.modelPropBindings);
401
428
  stripFirstSegment(result.slots);
402
429
  stripFirstSegment(result.childComponents);
403
430
 
@@ -418,6 +445,7 @@ export function walkBranch(html, signalNames, computedNames, propNames) {
418
445
  showBindings: result.showBindings,
419
446
  attrBindings: result.attrBindings,
420
447
  modelBindings: result.modelBindings,
448
+ modelPropBindings: result.modelPropBindings,
421
449
  slots: result.slots,
422
450
  childComponents: result.childComponents,
423
451
  forBlocks,
package/lib/types.js CHANGED
@@ -93,6 +93,7 @@
93
93
  * @property {LifecycleHook[]} onMountHooks — Mount lifecycle hooks (empty array if none)
94
94
  * @property {LifecycleHook[]} onDestroyHooks — Destroy lifecycle hooks (empty array if none)
95
95
  * @property {ModelBinding[]} modelBindings — Model bindings (empty array if none)
96
+ * @property {ModelPropBinding[]} modelPropBindings — Model prop bindings from model:propName directives (empty array if none)
96
97
  * @property {AttrBinding[]} attrBindings — Attribute bindings (empty array if none)
97
98
  * @property {SlotBinding[]} slots — Slot bindings (empty array if no slots)
98
99
  * @property {RefDeclaration[]} refs — templateRef declarations from script (empty array if none)
@@ -168,6 +169,14 @@
168
169
  * @property {string[]} path — DOM path from root to the element
169
170
  */
170
171
 
172
+ /**
173
+ * @typedef {Object} ModelPropBinding
174
+ * @property {string} varName — Internal name: '__modelProp0', '__modelProp1', ...
175
+ * @property {string} propName — The prop name after 'model:' (e.g., 'value')
176
+ * @property {string} signal — Parent signal name (e.g., 'searchText')
177
+ * @property {string[]} path — DOM path to the child element
178
+ */
179
+
171
180
  /**
172
181
  * @typedef {Object} RefDeclaration
173
182
  * @property {string} varName — Variable name from script (e.g., 'canvas')
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@sprlab/wccompiler",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
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": {
7
7
  ".": "./lib/compiler.js",
8
8
  "./integrations/vue": "./integrations/vue.js",
9
9
  "./integrations/react": "./integrations/react.js",
10
- "./integrations/angular": "./integrations/angular.js"
10
+ "./integrations/angular": "./integrations/angular.js",
11
+ "./adapters/vue": "./adapters/vue.js",
12
+ "./adapters/angular": "./adapters/angular.js"
11
13
  },
12
14
  "bin": {
13
15
  "wcc": "./bin/wcc.js"
@@ -17,6 +19,7 @@
17
19
  "lib/*.js",
18
20
  "!lib/*.test.js",
19
21
  "integrations/",
22
+ "adapters/",
20
23
  "types/",
21
24
  "README.md"
22
25
  ],