@sprlab/wccompiler 0.7.3 → 0.8.1

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/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
@@ -1200,7 +1257,7 @@ export function generateComponent(parseResult, options = {}) {
1200
1257
  lines.push(' }));');
1201
1258
  } else {
1202
1259
  // kind === 'getter' — transform the getter expression and use it directly
1203
- 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);
1204
1261
  const prevName = `__prev_watch${idx}`;
1205
1262
  lines.push(' this.__disposers.push(__effect(() => {');
1206
1263
  lines.push(` const ${w.newParam} = ${getterExpr};`);
@@ -1222,13 +1279,13 @@ export function generateComponent(parseResult, options = {}) {
1222
1279
 
1223
1280
  // Event listeners (with AbortController signal for cleanup)
1224
1281
  for (const e of events) {
1225
- const handlerExpr = generateEventHandler(e.handler, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames);
1282
+ const handlerExpr = generateEventHandler(e.handler, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, modelVarMap);
1226
1283
  lines.push(` this.${e.varName}.addEventListener('${e.event}', ${handlerExpr}, { signal: this.__ac.signal });`);
1227
1284
  }
1228
1285
 
1229
1286
  // Show effects — one __effect per ShowBinding
1230
1287
  for (const sb of showBindings) {
1231
- 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);
1232
1289
  lines.push(' this.__disposers.push(__effect(() => {');
1233
1290
  lines.push(` this.${sb.varName}.style.display = (${expr}) ? '' : 'none';`);
1234
1291
  lines.push(' }));');
@@ -1268,9 +1325,35 @@ export function generateComponent(parseResult, options = {}) {
1268
1325
  }
1269
1326
  }
1270
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
+
1271
1354
  // Attr binding effects — one __effect per AttrBinding
1272
1355
  for (const ab of attrBindings) {
1273
- 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);
1274
1357
  if (ab.kind === 'attr') {
1275
1358
  lines.push(' this.__disposers.push(__effect(() => {');
1276
1359
  lines.push(` const __v = ${expr};`);
@@ -1322,10 +1405,10 @@ export function generateComponent(parseResult, options = {}) {
1322
1405
  for (let i = 0; i < ifBlock.branches.length; i++) {
1323
1406
  const branch = ifBlock.branches[i];
1324
1407
  if (branch.type === 'if') {
1325
- 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);
1326
1409
  lines.push(` if (${expr}) { __branch = ${i}; }`);
1327
1410
  } else if (branch.type === 'else-if') {
1328
- 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);
1329
1412
  lines.push(` else if (${expr}) { __branch = ${i}; }`);
1330
1413
  } else {
1331
1414
  // else
@@ -1435,7 +1518,7 @@ export function generateComponent(parseResult, options = {}) {
1435
1518
 
1436
1519
  // Lifecycle: onMount hooks (at the very end of connectedCallback)
1437
1520
  for (const hook of onMountHooks) {
1438
- 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);
1439
1522
  if (hook.async) {
1440
1523
  lines.push(' ;(async () => {');
1441
1524
  const bodyLines = body.split('\n');
@@ -1463,7 +1546,7 @@ export function generateComponent(parseResult, options = {}) {
1463
1546
  lines.push(' this.__disposers.forEach(d => d());');
1464
1547
  if (onDestroyHooks.length > 0) {
1465
1548
  for (const hook of onDestroyHooks) {
1466
- 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);
1467
1550
  if (hook.async) {
1468
1551
  lines.push(' ;(async () => {');
1469
1552
  const bodyLines = body.split('\n');
@@ -1484,8 +1567,8 @@ export function generateComponent(parseResult, options = {}) {
1484
1567
  lines.push(' }');
1485
1568
  lines.push('');
1486
1569
 
1487
- // attributeChangedCallback (if props exist)
1488
- if (propDefs.length > 0) {
1570
+ // attributeChangedCallback (if props or model props exist)
1571
+ if (propDefs.length > 0 || modelDefs.length > 0) {
1489
1572
  lines.push(' attributeChangedCallback(name, oldVal, newVal) {');
1490
1573
  for (const p of propDefs) {
1491
1574
  const defaultVal = p.default;
@@ -1507,6 +1590,31 @@ export function generateComponent(parseResult, options = {}) {
1507
1590
 
1508
1591
  lines.push(` if (name === '${p.attrName}') ${updateExpr};`);
1509
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
+
1510
1618
  lines.push(' }');
1511
1619
  lines.push('');
1512
1620
 
@@ -1516,6 +1624,15 @@ export function generateComponent(parseResult, options = {}) {
1516
1624
  lines.push(` set ${p.name}(val) { this._s_${p.name}(val); this.setAttribute('${p.attrName}', String(val)); }`);
1517
1625
  lines.push('');
1518
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
+ }
1519
1636
  }
1520
1637
 
1521
1638
  // _emit method (if emits declared)
@@ -1526,9 +1643,23 @@ export function generateComponent(parseResult, options = {}) {
1526
1643
  lines.push('');
1527
1644
  }
1528
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
+
1529
1660
  // User methods (prefixed with _)
1530
1661
  for (const m of methods) {
1531
- 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);
1532
1663
  lines.push(` _${m.name}(${m.params}) {`);
1533
1664
  const bodyLines = body.split('\n');
1534
1665
  for (const line of bodyLines) {
@@ -1604,7 +1735,7 @@ export function generateComponent(parseResult, options = {}) {
1604
1735
  const propName = b.name.slice(propsObjectName.length + 1);
1605
1736
  ref = `this._s_${propName}()`;
1606
1737
  } else {
1607
- ref = transformExpr(b.name, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames);
1738
+ ref = transformExpr(b.name, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames, modelVarMap);
1608
1739
  }
1609
1740
  lines.push(` __effect(() => { ${b.varName}.textContent = ${ref} ?? ''; });`);
1610
1741
  }
@@ -1612,21 +1743,21 @@ export function generateComponent(parseResult, options = {}) {
1612
1743
 
1613
1744
  // Events: generate addEventListener
1614
1745
  for (const e of branch.events) {
1615
- const handlerExpr = generateEventHandler(e.handler, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames);
1746
+ const handlerExpr = generateEventHandler(e.handler, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, modelVarMap);
1616
1747
  lines.push(` const ${e.varName} = ${pathExpr(e.path, 'node')};`);
1617
1748
  lines.push(` ${e.varName}.addEventListener('${e.event}', ${handlerExpr});`);
1618
1749
  }
1619
1750
 
1620
1751
  // Show bindings: generate effects
1621
1752
  for (const sb of branch.showBindings) {
1622
- 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);
1623
1754
  lines.push(` const ${sb.varName} = ${pathExpr(sb.path, 'node')};`);
1624
1755
  lines.push(` __effect(() => { ${sb.varName}.style.display = (${expr}) ? '' : 'none'; });`);
1625
1756
  }
1626
1757
 
1627
1758
  // Attr bindings: generate effects
1628
1759
  for (const ab of branch.attrBindings) {
1629
- 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);
1630
1761
  lines.push(` const ${ab.varName} = ${pathExpr(ab.path, 'node')};`);
1631
1762
  lines.push(` __effect(() => {`);
1632
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;