webspresso 0.0.112 → 0.0.114

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.
Files changed (42) hide show
  1. package/README.md +26 -0
  2. package/bin/commands/admin-password.js +1 -7
  3. package/bin/commands/api.js +1 -7
  4. package/bin/commands/new.js +1 -7
  5. package/bin/commands/page.js +1 -7
  6. package/bin/commands/seed.js +1 -7
  7. package/bin/commands/service.js +1 -7
  8. package/bin/utils/prompt.js +17 -0
  9. package/core/cache/tagged-lru.js +9 -3
  10. package/core/client/adapter/datastar.js +602 -26
  11. package/core/client/api.js +151 -7
  12. package/core/client/controller.js +172 -11
  13. package/core/client/devtool.js +59 -33
  14. package/core/client/forms.js +479 -29
  15. package/core/client/fragments.js +456 -27
  16. package/core/client/index.js +18 -1
  17. package/core/client/server-fragments.js +125 -40
  18. package/core/client/utils.js +72 -17
  19. package/core/console/commands.js +26 -5
  20. package/core/console/repl.js +7 -1
  21. package/core/http/pipeline.js +82 -1
  22. package/core/openapi/build-from-api-routes.js +5 -5
  23. package/core/orm/json-fields.js +7 -6
  24. package/core/orm/schema-helpers.js +18 -15
  25. package/core/orm/utils/nanoid.js +7 -8
  26. package/core/plugins/plugin-manager.js +155 -1
  27. package/core/services/validator.js +12 -1
  28. package/core/validation/apply.js +3 -0
  29. package/core/validation/index.js +25 -0
  30. package/core/validation/zod-introspect.js +187 -0
  31. package/index.d.ts +113 -1
  32. package/package.json +12 -11
  33. package/plugins/mcp/providers/services.js +2 -2
  34. package/src/api/define-api.js +2 -0
  35. package/src/api/validation-i18n.js +6 -2
  36. package/src/bundle/index.js +140 -6
  37. package/src/dev/api-typegen.js +79 -22
  38. package/src/dev/typegen.js +61 -35
  39. package/src/dev-reload/index.js +44 -2
  40. package/src/file-router.js +24 -6
  41. package/src/routing/mount-discovered-routes.js +24 -6
  42. package/src/server.js +59 -3
@@ -24,7 +24,7 @@
24
24
  * external package is not installed in `node_modules`.
25
25
  */
26
26
 
27
- const { isArray, getByPath, setByPath } = require('../utils');
27
+ const { isArray, getByPath, setByPath, parseDelayMs } = require('../utils');
28
28
 
29
29
  const PluginType = {
30
30
  Attribute: 1,
@@ -703,20 +703,122 @@ const CLIENT_OWNED_TEXT_ATTR = 'data-w-text';
703
703
  const CLIENT_OWNED_SHOW_ATTR = 'data-w-show';
704
704
  const CLIENT_OWNED_DISABLED_ATTR = 'data-w-disabled';
705
705
  const CLIENT_OWNED_MODEL_ATTR = 'data-w-model';
706
+ const CLIENT_OWNED_CLASS_ATTR = 'data-w-class';
707
+
708
+ function hasClassBindingAttr(el) {
709
+ if (!el || !el.attributes) return false;
710
+ if (el.hasAttribute && el.hasAttribute(CLIENT_OWNED_CLASS_ATTR)) return true;
711
+ for (let i = 0; i < el.attributes.length; i++) {
712
+ if (el.attributes[i].name.startsWith('data-w-class:')) return true;
713
+ }
714
+ return false;
715
+ }
706
716
 
707
717
  /**
708
718
  * Determine whether a node has client bindings owning specific DOM properties.
709
719
  */
710
720
  function getClientOwnership(el) {
711
721
  if (!el || typeof el.hasAttribute !== 'function') {
712
- return { text: false, show: false, disabled: false, model: false };
722
+ return { text: false, show: false, disabled: false, model: false, classBinding: false };
713
723
  }
714
724
  return {
715
725
  text: el.hasAttribute(CLIENT_OWNED_TEXT_ATTR),
716
726
  show: el.hasAttribute(CLIENT_OWNED_SHOW_ATTR),
717
727
  disabled: el.hasAttribute(CLIENT_OWNED_DISABLED_ATTR),
718
728
  model: el.hasAttribute(CLIENT_OWNED_MODEL_ATTR),
729
+ classBinding: hasClassBindingAttr(el),
730
+ };
731
+ }
732
+
733
+ /**
734
+ * Determine ignore mode for third-party widget DOM ownership (`data-w-ignore`).
735
+ * - `'children'` (default `data-w-ignore` or `data-w-ignore="children"`): sync root attributes, never morph inner DOM.
736
+ * - `'self'` (`data-w-ignore="self"`): never sync root attributes, morph inner DOM.
737
+ * - `'all'` (`data-w-ignore="all"`): never sync root attributes AND never morph inner DOM.
738
+ * Note: Parent removal/key-change STILL removes the node and triggers `onBeforeNodeRemove` cleanup.
739
+ */
740
+ function getIgnoreMode(el) {
741
+ if (!el || typeof el.hasAttribute !== 'function' || !el.hasAttribute('data-w-ignore')) {
742
+ return null;
743
+ }
744
+ const raw = (el.getAttribute('data-w-ignore') || '').trim().toLowerCase();
745
+ if (raw === 'self') return 'self';
746
+ if (raw === 'all' || raw === 'both') return 'all';
747
+ return 'children';
748
+ }
749
+
750
+ function emitOwnershipConflict(debug, el, property, token, message) {
751
+ const isDev = typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production';
752
+ if (!debug && !isDev) return;
753
+ const tag = el && el.tagName ? el.tagName.toLowerCase() : 'element';
754
+ const idPart = el && el.id ? `#${el.id}` : '';
755
+ const payload = {
756
+ element: `${tag}${idPart}`,
757
+ property,
758
+ token: token || null,
759
+ message,
719
760
  };
761
+ if (debug && typeof debug.recordEvent === 'function') {
762
+ debug.recordEvent('ownership:conflict', payload);
763
+ } else if (isDev && typeof console !== 'undefined' && typeof console.warn === 'function') {
764
+ console.warn(`[webspresso:ownership] ${message}`, payload);
765
+ }
766
+ }
767
+
768
+ /**
769
+ * Sync `class` attribute from `fromEl` onto `toEl`, preserving client-owned class tokens
770
+ * managed by `data-w-class`, `data-w-class:*`, and `data-w-loading-class`.
771
+ */
772
+ function syncClassAttribute(toEl, fromEl, debug) {
773
+ const ownedClassState = toEl.__wOwnedClassState;
774
+ const fromRaw = fromEl.getAttribute('class');
775
+
776
+ if (!ownedClassState || ownedClassState.size === 0) {
777
+ if (fromRaw === null) {
778
+ if (toEl.hasAttribute('class')) toEl.removeAttribute('class');
779
+ } else if (toEl.getAttribute('class') !== fromRaw) {
780
+ toEl.setAttribute('class', fromRaw);
781
+ }
782
+ return;
783
+ }
784
+
785
+ const serverTokens = (fromRaw || '').trim().split(/\s+/).filter(Boolean);
786
+ const merged = [];
787
+ const seen = new Set();
788
+
789
+ for (let i = 0; i < serverTokens.length; i++) {
790
+ const tok = serverTokens[i];
791
+ if (ownedClassState.has(tok)) {
792
+ emitOwnershipConflict(
793
+ debug,
794
+ toEl,
795
+ 'class',
796
+ tok,
797
+ `Ownership conflict on <${(toEl.tagName || 'el').toLowerCase()}>: both server class attribute and client data-w-class manage token "${tok}".`
798
+ );
799
+ continue;
800
+ }
801
+ if (!seen.has(tok)) {
802
+ seen.add(tok);
803
+ merged.push(tok);
804
+ }
805
+ }
806
+
807
+ for (const [tok, active] of ownedClassState.entries()) {
808
+ if (active && !seen.has(tok)) {
809
+ seen.add(tok);
810
+ merged.push(tok);
811
+ }
812
+ }
813
+
814
+ const nextClass = merged.join(' ');
815
+ if (nextClass) {
816
+ if (toEl.getAttribute('class') !== nextClass) {
817
+ toEl.setAttribute('class', nextClass);
818
+ }
819
+ } else if (toEl.hasAttribute('class')) {
820
+ toEl.removeAttribute('class');
821
+ }
720
822
  }
721
823
 
722
824
  /**
@@ -775,16 +877,62 @@ function getMorphNodeKey(node) {
775
877
  /**
776
878
  * Sync attributes from `fromEl` onto `toEl` while respecting client-owned properties
777
879
  * and preserving active focus / form draft states.
880
+ * When a client binding attribute (`data-w-show`, `data-w-disabled`, `data-w-class`, etc.)
881
+ * is removed in `fromEl`, ownership is immediately released so server HTML can reclaim it.
778
882
  */
779
- function syncElementAttributes(toEl, fromEl, ownership, insideDirtyForm = false) {
883
+ function syncElementAttributes(toEl, fromEl, ownership, insideDirtyForm = false, debug = null) {
780
884
  const fromAttrs = fromEl.attributes;
781
885
  const toAttrs = Array.from(toEl.attributes);
782
886
 
887
+ const effectiveOwnership = {
888
+ text: ownership.text && fromEl.hasAttribute(CLIENT_OWNED_TEXT_ATTR),
889
+ show: ownership.show && fromEl.hasAttribute(CLIENT_OWNED_SHOW_ATTR),
890
+ disabled: ownership.disabled && fromEl.hasAttribute(CLIENT_OWNED_DISABLED_ATTR),
891
+ model: ownership.model && fromEl.hasAttribute(CLIENT_OWNED_MODEL_ATTR),
892
+ classBinding: ownership.classBinding && hasClassBindingAttr(fromEl),
893
+ };
894
+
895
+ if (ownership.classBinding && !effectiveOwnership.classBinding && toEl.__wOwnedClassState) {
896
+ toEl.__wOwnedClassState.clear();
897
+ delete toEl.__wOwnedClassState;
898
+ }
899
+
900
+ const wasHiddenByShow = effectiveOwnership.show && toEl.hasAttribute('hidden');
901
+
902
+ if (effectiveOwnership.show && fromEl.hasAttribute('hidden')) {
903
+ emitOwnershipConflict(
904
+ debug,
905
+ toEl,
906
+ 'hidden',
907
+ null,
908
+ `Ownership conflict on <${(toEl.tagName || 'el').toLowerCase()}>: both server "hidden" attribute and client data-w-show manage visibility.`
909
+ );
910
+ }
911
+ if (effectiveOwnership.disabled && fromEl.hasAttribute('disabled')) {
912
+ emitOwnershipConflict(
913
+ debug,
914
+ toEl,
915
+ 'disabled',
916
+ null,
917
+ `Ownership conflict on <${(toEl.tagName || 'el').toLowerCase()}>: both server "disabled" attribute and client data-w-disabled manage disabled state.`
918
+ );
919
+ }
920
+ if (effectiveOwnership.model && fromEl.hasAttribute('value') && fromEl.getAttribute('value') !== '') {
921
+ emitOwnershipConflict(
922
+ debug,
923
+ toEl,
924
+ 'value',
925
+ null,
926
+ `Ownership conflict on <${(toEl.tagName || 'el').toLowerCase()}>: both server "value" attribute and client data-w-model manage input value.`
927
+ );
928
+ }
929
+
783
930
  // Remove attributes no longer present on server HTML, except client-managed runtime state
784
931
  for (let i = 0; i < toAttrs.length; i++) {
785
932
  const attrName = toAttrs[i].name;
786
- if (ownership.disabled && attrName === 'disabled') continue;
787
- if (ownership.show && attrName === 'hidden') continue;
933
+ if (attrName === 'class') continue; // Handled by syncClassAttribute
934
+ if (effectiveOwnership.disabled && (attrName === 'disabled' || attrName === 'aria-disabled')) continue;
935
+ if (effectiveOwnership.show && attrName === 'hidden') continue;
788
936
  if (attrName.startsWith('data-w-bound-')) continue;
789
937
  if (
790
938
  attrName === 'data-w-fragment-input' ||
@@ -798,11 +946,17 @@ function syncElementAttributes(toEl, fromEl, ownership, insideDirtyForm = false)
798
946
  }
799
947
  }
800
948
 
949
+ // If `data-w-show` was removed and server does not set `style`, clear inline `display: none`
950
+ if (ownership.show && !effectiveOwnership.show && !fromEl.hasAttribute('style') && toEl.style && toEl.style.display === 'none') {
951
+ toEl.style.display = '';
952
+ }
953
+
801
954
  // Set updated attributes from server HTML
802
955
  for (let i = 0; i < fromAttrs.length; i++) {
803
956
  const attr = fromAttrs[i];
804
- if (ownership.disabled && attr.name === 'disabled') continue;
805
- if (ownership.show && attr.name === 'hidden') continue;
957
+ if (attr.name === 'class') continue; // Handled by syncClassAttribute
958
+ if (effectiveOwnership.disabled && (attr.name === 'disabled' || attr.name === 'aria-disabled')) continue;
959
+ if (effectiveOwnership.show && attr.name === 'hidden') continue;
806
960
  if (attr.name === 'action' && toEl.tagName === 'FORM' && (toEl.__wCustomAction || insideDirtyForm)) {
807
961
  continue;
808
962
  }
@@ -810,6 +964,16 @@ function syncElementAttributes(toEl, fromEl, ownership, insideDirtyForm = false)
810
964
  toEl.setAttribute(attr.name, attr.value);
811
965
  }
812
966
  }
967
+
968
+ // Sync `class` preserving client-owned class tokens
969
+ syncClassAttribute(toEl, fromEl, debug);
970
+
971
+ // Restore `style.display = 'none'` if server `style` overwrote it while `data-w-show` hides the element
972
+ if (wasHiddenByShow && toEl.style && toEl.style.display !== 'none') {
973
+ toEl.style.display = 'none';
974
+ }
975
+
976
+ return effectiveOwnership;
813
977
  }
814
978
 
815
979
  /**
@@ -862,11 +1026,34 @@ function syncFormControlValue(toEl, fromEl, ownership, preserveDraft) {
862
1026
  function morphDomTrees(toEl, fromEl, ctx = {}) {
863
1027
  if (!toEl || !fromEl) return toEl;
864
1028
 
865
- const ownership = getClientOwnership(toEl);
1029
+ // Preserve an already-loaded lazy child fragment if a parent refresh sends a lazy placeholder
1030
+ if (
1031
+ toEl.hasAttribute &&
1032
+ fromEl.hasAttribute &&
1033
+ toEl.hasAttribute('data-w-fragment') &&
1034
+ fromEl.hasAttribute('data-w-fragment') &&
1035
+ !toEl.hasAttribute('data-w-lazy')
1036
+ ) {
1037
+ const incomingLazy = fromEl.getAttribute('data-w-lazy');
1038
+ if (incomingLazy && incomingLazy !== 'false' && incomingLazy !== 'loaded') {
1039
+ toEl.__wPreservedLazyChild = true;
1040
+ return toEl;
1041
+ }
1042
+ }
1043
+
1044
+ const rawOwnership = getClientOwnership(toEl);
1045
+ const ignoreMode = getIgnoreMode(toEl);
866
1046
  const isForm = toEl.tagName === 'FORM';
867
1047
  const insideDirtyForm = Boolean(ctx.insideDirtyForm || (isForm && isFormDirtyOrPreserved(toEl)));
868
1048
 
869
- syncElementAttributes(toEl, fromEl, ownership, insideDirtyForm);
1049
+ const ownership =
1050
+ ignoreMode === 'self' || ignoreMode === 'all'
1051
+ ? rawOwnership
1052
+ : syncElementAttributes(toEl, fromEl, rawOwnership, insideDirtyForm, ctx.debug);
1053
+
1054
+ if (ignoreMode === 'children' || ignoreMode === 'all') {
1055
+ return toEl;
1056
+ }
870
1057
 
871
1058
  if (toEl.tagName === 'INPUT' || toEl.tagName === 'TEXTAREA' || toEl.tagName === 'SELECT') {
872
1059
  syncFormControlValue(toEl, fromEl, ownership, insideDirtyForm);
@@ -1171,6 +1358,167 @@ function createDatastarAdapter(options = {}) {
1171
1358
  }
1172
1359
  }
1173
1360
 
1361
+ /**
1362
+ * Parse `{ 'is-active': expr1, isOpen: expr2 }` into `Array<{ classes: string[], expr: string }>`.
1363
+ */
1364
+ function parseClassMapExpression(rawExpr) {
1365
+ const trimmed = (rawExpr || '').trim();
1366
+ if (!trimmed) return [];
1367
+ const inner = trimmed.startsWith('{') && trimmed.endsWith('}')
1368
+ ? trimmed.slice(1, -1).trim()
1369
+ : trimmed;
1370
+ if (!inner) return [];
1371
+
1372
+ const entries = [];
1373
+ let current = '';
1374
+ let parenDepth = 0;
1375
+ let bracketDepth = 0;
1376
+ let inQuote = null;
1377
+
1378
+ for (let i = 0; i < inner.length; i++) {
1379
+ const ch = inner[i];
1380
+ if (inQuote) {
1381
+ current += ch;
1382
+ if (ch === inQuote && inner[i - 1] !== '\\') inQuote = null;
1383
+ continue;
1384
+ }
1385
+ if (ch === '"' || ch === "'") {
1386
+ inQuote = ch;
1387
+ current += ch;
1388
+ continue;
1389
+ }
1390
+ if (ch === '(') parenDepth++;
1391
+ else if (ch === ')' && parenDepth > 0) parenDepth--;
1392
+ else if (ch === '[') bracketDepth++;
1393
+ else if (ch === ']' && bracketDepth > 0) bracketDepth--;
1394
+
1395
+ if (ch === ',' && parenDepth === 0 && bracketDepth === 0) {
1396
+ if (current.trim()) entries.push(current.trim());
1397
+ current = '';
1398
+ } else {
1399
+ current += ch;
1400
+ }
1401
+ }
1402
+ if (current.trim()) entries.push(current.trim());
1403
+
1404
+ const result = [];
1405
+ for (const entry of entries) {
1406
+ let colonIdx = -1;
1407
+ let q = null;
1408
+ for (let i = 0; i < entry.length; i++) {
1409
+ const ch = entry[i];
1410
+ if (q) {
1411
+ if (ch === q && entry[i - 1] !== '\\') q = null;
1412
+ continue;
1413
+ }
1414
+ if (ch === '"' || ch === "'") {
1415
+ q = ch;
1416
+ continue;
1417
+ }
1418
+ if (ch === ':') {
1419
+ colonIdx = i;
1420
+ break;
1421
+ }
1422
+ }
1423
+ if (colonIdx <= 0) continue;
1424
+ let rawKey = entry.slice(0, colonIdx).trim();
1425
+ const expr = entry.slice(colonIdx + 1).trim();
1426
+ if ((rawKey.startsWith("'") && rawKey.endsWith("'")) || (rawKey.startsWith('"') && rawKey.endsWith('"'))) {
1427
+ rawKey = rawKey.slice(1, -1);
1428
+ }
1429
+ const classes = rawKey.split(/\s+/).filter(Boolean);
1430
+ if (classes.length > 0 && expr) {
1431
+ result.push({ classes, expr });
1432
+ }
1433
+ }
1434
+ return result;
1435
+ }
1436
+
1437
+ function parseEventScheduleModifiers(modifiers) {
1438
+ let mode = null;
1439
+ let waitMs = 250;
1440
+ let leading = null;
1441
+ let trailing = null;
1442
+
1443
+ const list = Array.from(modifiers);
1444
+ for (let i = 0; i < list.length; i++) {
1445
+ const mod = list[i];
1446
+ if (mod === 'debounce' || mod === 'throttle') {
1447
+ mode = mod;
1448
+ const next = list[i + 1];
1449
+ if (next && /^\d+(?:ms|s)?$/i.test(next)) {
1450
+ waitMs = parseDelayMs(next, 250);
1451
+ i++;
1452
+ }
1453
+ } else if (mod.startsWith('debounce-')) {
1454
+ mode = 'debounce';
1455
+ waitMs = parseDelayMs(mod.slice('debounce-'.length), 250);
1456
+ } else if (mod.startsWith('throttle-')) {
1457
+ mode = 'throttle';
1458
+ waitMs = parseDelayMs(mod.slice('throttle-'.length), 250);
1459
+ } else if (mod === 'leading') {
1460
+ leading = true;
1461
+ } else if (mod === 'trailing') {
1462
+ trailing = true;
1463
+ }
1464
+ }
1465
+
1466
+ if (!mode) return null;
1467
+ if (mode === 'debounce') {
1468
+ return {
1469
+ mode: 'debounce',
1470
+ waitMs,
1471
+ leading: leading === true,
1472
+ trailing: trailing !== false,
1473
+ };
1474
+ }
1475
+ return {
1476
+ mode: 'throttle',
1477
+ waitMs,
1478
+ leading: leading !== false,
1479
+ trailing: trailing !== false,
1480
+ };
1481
+ }
1482
+
1483
+ function getElementBindingSignature(el) {
1484
+ if (!el || !el.attributes) return '';
1485
+ const parts = [];
1486
+ for (let i = 0; i < el.attributes.length; i++) {
1487
+ const n = el.attributes[i].name;
1488
+ if (
1489
+ n === 'data-w-text' ||
1490
+ n === 'data-w-show' ||
1491
+ n === 'data-w-disabled' ||
1492
+ n === 'data-w-model' ||
1493
+ n === 'data-w-class' ||
1494
+ n.startsWith('data-w-class:') ||
1495
+ n === 'data-w-loading' ||
1496
+ n === 'data-w-loading-hide' ||
1497
+ n === 'data-w-loading-delay' ||
1498
+ n === 'data-w-loading-class' ||
1499
+ n === 'data-w-on' ||
1500
+ n.startsWith('data-w-on:') ||
1501
+ n === 'data-w-click'
1502
+ ) {
1503
+ parts.push(`${n}=${el.attributes[i].value}`);
1504
+ }
1505
+ }
1506
+ parts.sort();
1507
+ return parts.join('\n');
1508
+ }
1509
+
1510
+ function setOwnedClassToken(el, token, active) {
1511
+ if (!el) return;
1512
+ if (!el.__wOwnedClassState) {
1513
+ el.__wOwnedClassState = new Map();
1514
+ }
1515
+ el.__wOwnedClassState.set(token, Boolean(active));
1516
+ if (el.classList) {
1517
+ if (active) el.classList.add(token);
1518
+ else el.classList.remove(token);
1519
+ }
1520
+ }
1521
+
1174
1522
  /**
1175
1523
  * Bind V1 declarative attributes inside `rootEl` for a controller instance.
1176
1524
  */
@@ -1207,7 +1555,24 @@ function createDatastarAdapter(options = {}) {
1207
1555
 
1208
1556
  for (let i = 0; i < elements.length; i++) {
1209
1557
  const el = elements[i];
1210
- if (elementDisposersMap.has(el)) continue;
1558
+ const sig = getElementBindingSignature(el);
1559
+ if (elementDisposersMap.has(el)) {
1560
+ if (el.__wBindingSig === sig) continue;
1561
+ // Bindings on this preserved element changed (e.g. server added/removed data-w-show/class):
1562
+ // dispose old bindings and re-bind cleanly.
1563
+ const oldDisposers = elementDisposersMap.get(el);
1564
+ for (let d = 0; d < oldDisposers.length; d++) {
1565
+ try {
1566
+ oldDisposers[d]();
1567
+ } catch (_e) {}
1568
+ }
1569
+ elementDisposersMap.delete(el);
1570
+ if (el.__wOwnedClassState) {
1571
+ el.__wOwnedClassState.clear();
1572
+ delete el.__wOwnedClassState;
1573
+ }
1574
+ }
1575
+ el.__wBindingSig = sig;
1211
1576
  const elDisposers = [];
1212
1577
  elementDisposersMap.set(el, elDisposers);
1213
1578
 
@@ -1236,30 +1601,106 @@ function createDatastarAdapter(options = {}) {
1236
1601
  elDisposers.push(stop);
1237
1602
  }
1238
1603
 
1239
- // 2b. data-w-loading & data-w-loading-hide (declarative action/task busy indicators)
1604
+ // 2a. data-w-class & data-w-class:<token>
1605
+ if (el.hasAttribute('data-w-class')) {
1606
+ const rawClassExpr = el.getAttribute('data-w-class') || '';
1607
+ const classEntries = parseClassMapExpression(rawClassExpr);
1608
+ for (let c = 0; c < classEntries.length; c++) {
1609
+ const entry = classEntries[c];
1610
+ const stop = engine.effect(() => {
1611
+ if (!isConnected() || el.isConnected === false) return;
1612
+ const active = Boolean(evaluateStateExpression(state, entry.expr));
1613
+ for (let k = 0; k < entry.classes.length; k++) {
1614
+ setOwnedClassToken(el, entry.classes[k], active);
1615
+ }
1616
+ });
1617
+ elDisposers.push(stop);
1618
+ }
1619
+ }
1620
+ if (el.attributes) {
1621
+ for (let a = 0; a < el.attributes.length; a++) {
1622
+ const attr = el.attributes[a];
1623
+ if (attr.name.startsWith('data-w-class:')) {
1624
+ const token = attr.name.slice('data-w-class:'.length).trim();
1625
+ const expr = (attr.value || '').trim();
1626
+ if (token && expr) {
1627
+ const stop = engine.effect(() => {
1628
+ if (!isConnected() || el.isConnected === false) return;
1629
+ setOwnedClassToken(el, token, Boolean(evaluateStateExpression(state, expr)));
1630
+ });
1631
+ elDisposers.push(stop);
1632
+ }
1633
+ }
1634
+ }
1635
+ }
1636
+
1637
+ // 2b. data-w-loading & data-w-loading-hide (with optional data-w-loading-delay)
1240
1638
  if (el.hasAttribute('data-w-loading') || el.hasAttribute('data-w-loading-hide')) {
1241
1639
  const isHide = el.hasAttribute('data-w-loading-hide');
1242
1640
  const rawSpec = el.getAttribute(isHide ? 'data-w-loading-hide' : 'data-w-loading') || '';
1243
1641
  const initialDisplay = el.style && el.style.display && el.style.display !== 'none' ? el.style.display : '';
1642
+ const delayMs = parseDelayMs(el.getAttribute('data-w-loading-delay'), 0);
1244
1643
  const loadingClasses = (el.getAttribute('data-w-loading-class') || '')
1245
1644
  .trim()
1246
1645
  .split(/\s+/)
1247
1646
  .filter(Boolean);
1647
+
1648
+ let delayTimer = null;
1649
+ let generation = 0;
1650
+ let visuallyActive = false;
1651
+
1652
+ const applyVisualLoading = (active) => {
1653
+ visuallyActive = active;
1654
+ toggleElementVisibility(el, isHide ? !active : active, initialDisplay);
1655
+ if (loadingClasses.length > 0 && el.classList) {
1656
+ for (let c = 0; c < loadingClasses.length; c++) {
1657
+ setOwnedClassToken(el, loadingClasses[c], active);
1658
+ }
1659
+ }
1660
+ };
1661
+
1662
+ // Initialize non-loading state synchronously when delay > 0
1663
+ if (delayMs > 0) {
1664
+ applyVisualLoading(false);
1665
+ }
1666
+
1248
1667
  const stop = engine.effect(() => {
1249
1668
  if (!isConnected() || el.isConnected === false) return;
1250
1669
  const active =
1251
1670
  typeof isLoadingTargetActive === 'function'
1252
1671
  ? Boolean(isLoadingTargetActive(rawSpec))
1253
1672
  : Boolean(evaluateStateExpression(state, rawSpec || '$busy'));
1254
- toggleElementVisibility(el, isHide ? !active : active, initialDisplay);
1255
- if (loadingClasses.length > 0 && el.classList) {
1256
- for (let c = 0; c < loadingClasses.length; c++) {
1257
- if (active) el.classList.add(loadingClasses[c]);
1258
- else el.classList.remove(loadingClasses[c]);
1673
+
1674
+ if (delayMs <= 0) {
1675
+ applyVisualLoading(active);
1676
+ return;
1677
+ }
1678
+
1679
+ if (active) {
1680
+ if (visuallyActive || delayTimer) return;
1681
+ const gen = ++generation;
1682
+ delayTimer = setTimeout(() => {
1683
+ delayTimer = null;
1684
+ if (gen !== generation || !isConnected() || el.isConnected === false) return;
1685
+ applyVisualLoading(true);
1686
+ }, delayMs);
1687
+ } else {
1688
+ generation++;
1689
+ if (delayTimer) {
1690
+ clearTimeout(delayTimer);
1691
+ delayTimer = null;
1259
1692
  }
1693
+ applyVisualLoading(false);
1260
1694
  }
1261
1695
  });
1262
- elDisposers.push(stop);
1696
+ elDisposers.push(() => {
1697
+ generation++;
1698
+ if (delayTimer) {
1699
+ clearTimeout(delayTimer);
1700
+ delayTimer = null;
1701
+ }
1702
+ stop();
1703
+ });
1263
1704
  }
1264
1705
 
1265
1706
  // 3. data-w-disabled
@@ -1401,15 +1842,149 @@ function createDatastarAdapter(options = {}) {
1401
1842
 
1402
1843
  // 5. Event bindings (data-w-on:<event>, data-w-on, data-w-click)
1403
1844
  const eventBindings = extractEventBindings(el);
1404
- for (const binding of eventBindings) {
1405
- const listener = (event) => {
1406
- if (!isConnected() || el.isConnected === false) return;
1407
- if (binding.modifiers.has('prevent')) event.preventDefault();
1408
- if (binding.modifiers.has('stop')) event.stopPropagation();
1409
- invokeAction(binding.actionName, event, el);
1410
- };
1411
- el.addEventListener(binding.eventName, listener);
1412
- elDisposers.push(() => el.removeEventListener(binding.eventName, listener));
1845
+ if (eventBindings.length > 0) {
1846
+ let isEventComposing = false;
1847
+ const hasScheduled = eventBindings.some((b) => parseEventScheduleModifiers(b.modifiers) !== null);
1848
+ if (hasScheduled) {
1849
+ const onCompStart = () => {
1850
+ isEventComposing = true;
1851
+ };
1852
+ const onCompEnd = () => {
1853
+ isEventComposing = false;
1854
+ };
1855
+ el.addEventListener('compositionstart', onCompStart);
1856
+ el.addEventListener('compositionend', onCompEnd);
1857
+ elDisposers.push(() => {
1858
+ try {
1859
+ el.removeEventListener('compositionstart', onCompStart);
1860
+ el.removeEventListener('compositionend', onCompEnd);
1861
+ } catch (_e) {}
1862
+ });
1863
+ }
1864
+
1865
+ const parentForm = typeof el.closest === 'function' ? el.closest('form') : el.form || null;
1866
+
1867
+ for (const binding of eventBindings) {
1868
+ const schedule = parseEventScheduleModifiers(binding.modifiers);
1869
+ if (!schedule) {
1870
+ const listener = (event) => {
1871
+ if (!isConnected() || el.isConnected === false) return;
1872
+ if (binding.modifiers.has('prevent')) event.preventDefault();
1873
+ if (binding.modifiers.has('stop')) event.stopPropagation();
1874
+ invokeAction(binding.actionName, event, el);
1875
+ };
1876
+ el.addEventListener(binding.eventName, listener);
1877
+ elDisposers.push(() => el.removeEventListener(binding.eventName, listener));
1878
+ continue;
1879
+ }
1880
+
1881
+ let timer = null;
1882
+ let lastExecTime = 0;
1883
+ let pendingEvent = null;
1884
+ let generation = 0;
1885
+
1886
+ const cancelPending = () => {
1887
+ generation++;
1888
+ if (timer) {
1889
+ clearTimeout(timer);
1890
+ timer = null;
1891
+ }
1892
+ pendingEvent = null;
1893
+ };
1894
+
1895
+ const fireScheduled = (capturedEvent) => {
1896
+ if (!isConnected() || el.isConnected === false) return;
1897
+ invokeAction(binding.actionName, capturedEvent, el);
1898
+ };
1899
+
1900
+ const listener = (event) => {
1901
+ if (!isConnected() || el.isConnected === false) return;
1902
+ if (binding.modifiers.has('prevent')) event.preventDefault();
1903
+ if (binding.modifiers.has('stop')) event.stopPropagation();
1904
+ if (
1905
+ (event.isComposing || isEventComposing) &&
1906
+ (binding.eventName === 'input' || binding.eventName === 'keydown' || binding.eventName === 'keyup')
1907
+ ) {
1908
+ return;
1909
+ }
1910
+
1911
+ // Capture a safe event reference (never rely on event.currentTarget after async delay)
1912
+ const capturedEvent = event;
1913
+ const gen = generation;
1914
+
1915
+ if (schedule.mode === 'debounce') {
1916
+ const callNow = schedule.leading && !timer;
1917
+ if (timer) clearTimeout(timer);
1918
+ pendingEvent = capturedEvent;
1919
+ timer = setTimeout(() => {
1920
+ timer = null;
1921
+ if (gen !== generation) return;
1922
+ const evtToPass = pendingEvent;
1923
+ pendingEvent = null;
1924
+ if (schedule.trailing && evtToPass) {
1925
+ fireScheduled(evtToPass);
1926
+ }
1927
+ }, schedule.waitMs);
1928
+ if (callNow) {
1929
+ pendingEvent = null;
1930
+ fireScheduled(capturedEvent);
1931
+ }
1932
+ } else {
1933
+ // Throttle: default leading + trailing
1934
+ const now = Date.now();
1935
+ const remaining = schedule.waitMs - (now - lastExecTime);
1936
+ if (remaining <= 0 || remaining > schedule.waitMs) {
1937
+ if (timer) {
1938
+ clearTimeout(timer);
1939
+ timer = null;
1940
+ }
1941
+ pendingEvent = null;
1942
+ if (schedule.leading) {
1943
+ lastExecTime = now;
1944
+ fireScheduled(capturedEvent);
1945
+ } else if (schedule.trailing) {
1946
+ pendingEvent = capturedEvent;
1947
+ timer = setTimeout(() => {
1948
+ timer = null;
1949
+ if (gen !== generation) return;
1950
+ lastExecTime = Date.now();
1951
+ const evtToPass = pendingEvent;
1952
+ pendingEvent = null;
1953
+ if (evtToPass) fireScheduled(evtToPass);
1954
+ }, schedule.waitMs);
1955
+ }
1956
+ } else if (schedule.trailing) {
1957
+ pendingEvent = capturedEvent;
1958
+ if (!timer) {
1959
+ timer = setTimeout(() => {
1960
+ timer = null;
1961
+ if (gen !== generation) return;
1962
+ lastExecTime = schedule.leading ? Date.now() : 0;
1963
+ const evtToPass = pendingEvent;
1964
+ pendingEvent = null;
1965
+ if (evtToPass) fireScheduled(evtToPass);
1966
+ }, remaining);
1967
+ }
1968
+ }
1969
+ }
1970
+ };
1971
+
1972
+ el.addEventListener(binding.eventName, listener);
1973
+ elDisposers.push(() => {
1974
+ cancelPending();
1975
+ el.removeEventListener(binding.eventName, listener);
1976
+ });
1977
+
1978
+ if (parentForm && typeof parentForm.addEventListener === 'function') {
1979
+ const onFormReset = () => cancelPending();
1980
+ parentForm.addEventListener('reset', onFormReset);
1981
+ elDisposers.push(() => {
1982
+ try {
1983
+ parentForm.removeEventListener('reset', onFormReset);
1984
+ } catch (_e) {}
1985
+ });
1986
+ }
1987
+ }
1413
1988
  }
1414
1989
  }
1415
1990
 
@@ -1460,6 +2035,7 @@ function createDatastarAdapter(options = {}) {
1460
2035
  const morphed = morphDomTrees(targetEl, parsed, {
1461
2036
  onBeforeNodeRemove: lifecycleCallbacks.onBeforeNodeRemove,
1462
2037
  onNodeAdded: lifecycleCallbacks.onNodeAdded,
2038
+ debug: options.debug || null,
1463
2039
  });
1464
2040
 
1465
2041
  // Restore focus & selection range if the focused element was preserved inside targetEl