zero-query 0.5.2 → 0.7.5

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 (58) hide show
  1. package/README.md +12 -10
  2. package/cli/commands/build.js +7 -5
  3. package/cli/commands/bundle.js +286 -8
  4. package/cli/commands/dev/index.js +82 -0
  5. package/cli/commands/dev/logger.js +70 -0
  6. package/cli/commands/dev/overlay.js +366 -0
  7. package/cli/commands/dev/server.js +158 -0
  8. package/cli/commands/dev/validator.js +94 -0
  9. package/cli/commands/dev/watcher.js +147 -0
  10. package/cli/scaffold/favicon.ico +0 -0
  11. package/cli/scaffold/index.html +1 -0
  12. package/cli/scaffold/scripts/app.js +15 -22
  13. package/cli/scaffold/scripts/components/about.js +14 -2
  14. package/cli/scaffold/scripts/components/contacts/contacts.css +0 -7
  15. package/cli/scaffold/scripts/components/contacts/contacts.html +8 -7
  16. package/cli/scaffold/scripts/components/contacts/contacts.js +17 -1
  17. package/cli/scaffold/scripts/components/counter.js +30 -10
  18. package/cli/scaffold/scripts/components/home.js +3 -3
  19. package/cli/scaffold/scripts/components/todos.js +6 -5
  20. package/cli/scaffold/styles/styles.css +1 -0
  21. package/cli/utils.js +111 -6
  22. package/dist/zquery.dist.zip +0 -0
  23. package/dist/zquery.js +2005 -216
  24. package/dist/zquery.min.js +3 -13
  25. package/index.d.ts +149 -1080
  26. package/index.js +18 -7
  27. package/package.json +9 -3
  28. package/src/component.js +186 -45
  29. package/src/core.js +327 -35
  30. package/src/diff.js +280 -0
  31. package/src/errors.js +155 -0
  32. package/src/expression.js +806 -0
  33. package/src/http.js +18 -10
  34. package/src/reactive.js +29 -4
  35. package/src/router.js +59 -6
  36. package/src/ssr.js +224 -0
  37. package/src/store.js +24 -8
  38. package/tests/component.test.js +304 -0
  39. package/tests/core.test.js +726 -0
  40. package/tests/diff.test.js +194 -0
  41. package/tests/errors.test.js +162 -0
  42. package/tests/expression.test.js +334 -0
  43. package/tests/http.test.js +181 -0
  44. package/tests/reactive.test.js +191 -0
  45. package/tests/router.test.js +332 -0
  46. package/tests/store.test.js +253 -0
  47. package/tests/utils.test.js +353 -0
  48. package/types/collection.d.ts +368 -0
  49. package/types/component.d.ts +210 -0
  50. package/types/errors.d.ts +103 -0
  51. package/types/http.d.ts +81 -0
  52. package/types/misc.d.ts +166 -0
  53. package/types/reactive.d.ts +76 -0
  54. package/types/router.d.ts +132 -0
  55. package/types/ssr.d.ts +49 -0
  56. package/types/store.d.ts +107 -0
  57. package/types/utils.d.ts +142 -0
  58. /package/cli/commands/{dev.js → dev.old.js} +0 -0
package/dist/zquery.js CHANGED
@@ -1,13 +1,170 @@
1
1
  /**
2
- * zQuery (zeroQuery) v0.5.2
2
+ * zQuery (zeroQuery) v0.7.5
3
3
  * Lightweight Frontend Library
4
4
  * https://github.com/tonywied17/zero-query
5
- * (c) 2026 Anthony Wiedman MIT License
5
+ * (c) 2026 Anthony Wiedman - MIT License
6
6
  */
7
7
  (function(global) {
8
8
  'use strict';
9
9
 
10
- // --- src/reactive.js —————————————————————————————————————————————
10
+ // --- src/errors.js -----------------------------------------------
11
+ /**
12
+ * zQuery Errors — Structured error handling system
13
+ *
14
+ * Provides typed error classes and a configurable error handler so that
15
+ * errors surface consistently across all modules (reactive, component,
16
+ * router, store, expression parser, HTTP, etc.).
17
+ *
18
+ * Default behaviour: errors are logged via console.warn/error.
19
+ * Users can override with $.onError(handler) to integrate with their
20
+ * own logging, crash-reporting, or UI notification system.
21
+ */
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Error codes — every zQuery error has a unique code for programmatic use
25
+ // ---------------------------------------------------------------------------
26
+ const ErrorCode = Object.freeze({
27
+ // Reactive
28
+ REACTIVE_CALLBACK: 'ZQ_REACTIVE_CALLBACK',
29
+ SIGNAL_CALLBACK: 'ZQ_SIGNAL_CALLBACK',
30
+ EFFECT_EXEC: 'ZQ_EFFECT_EXEC',
31
+
32
+ // Expression parser
33
+ EXPR_PARSE: 'ZQ_EXPR_PARSE',
34
+ EXPR_EVAL: 'ZQ_EXPR_EVAL',
35
+ EXPR_UNSAFE_ACCESS: 'ZQ_EXPR_UNSAFE_ACCESS',
36
+
37
+ // Component
38
+ COMP_INVALID_NAME: 'ZQ_COMP_INVALID_NAME',
39
+ COMP_NOT_FOUND: 'ZQ_COMP_NOT_FOUND',
40
+ COMP_MOUNT_TARGET: 'ZQ_COMP_MOUNT_TARGET',
41
+ COMP_RENDER: 'ZQ_COMP_RENDER',
42
+ COMP_LIFECYCLE: 'ZQ_COMP_LIFECYCLE',
43
+ COMP_RESOURCE: 'ZQ_COMP_RESOURCE',
44
+ COMP_DIRECTIVE: 'ZQ_COMP_DIRECTIVE',
45
+
46
+ // Router
47
+ ROUTER_LOAD: 'ZQ_ROUTER_LOAD',
48
+ ROUTER_GUARD: 'ZQ_ROUTER_GUARD',
49
+ ROUTER_RESOLVE: 'ZQ_ROUTER_RESOLVE',
50
+
51
+ // Store
52
+ STORE_ACTION: 'ZQ_STORE_ACTION',
53
+ STORE_MIDDLEWARE: 'ZQ_STORE_MIDDLEWARE',
54
+ STORE_SUBSCRIBE: 'ZQ_STORE_SUBSCRIBE',
55
+
56
+ // HTTP
57
+ HTTP_REQUEST: 'ZQ_HTTP_REQUEST',
58
+ HTTP_TIMEOUT: 'ZQ_HTTP_TIMEOUT',
59
+ HTTP_INTERCEPTOR: 'ZQ_HTTP_INTERCEPTOR',
60
+ HTTP_PARSE: 'ZQ_HTTP_PARSE',
61
+
62
+ // General
63
+ INVALID_ARGUMENT: 'ZQ_INVALID_ARGUMENT',
64
+ });
65
+
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // ZQueryError — custom error class
69
+ // ---------------------------------------------------------------------------
70
+ class ZQueryError extends Error {
71
+ /**
72
+ * @param {string} code — one of ErrorCode values
73
+ * @param {string} message — human-readable description
74
+ * @param {object} [context] — extra data (component name, expression, etc.)
75
+ * @param {Error} [cause] — original error
76
+ */
77
+ constructor(code, message, context = {}, cause) {
78
+ super(message);
79
+ this.name = 'ZQueryError';
80
+ this.code = code;
81
+ this.context = context;
82
+ if (cause) this.cause = cause;
83
+ }
84
+ }
85
+
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Global error handler
89
+ // ---------------------------------------------------------------------------
90
+ let _errorHandler = null;
91
+
92
+ /**
93
+ * Register a global error handler.
94
+ * Called whenever zQuery catches an error internally.
95
+ *
96
+ * @param {Function|null} handler — (error: ZQueryError) => void
97
+ */
98
+ function onError(handler) {
99
+ _errorHandler = typeof handler === 'function' ? handler : null;
100
+ }
101
+
102
+ /**
103
+ * Report an error through the global handler and console.
104
+ * Non-throwing — used for recoverable errors in callbacks, lifecycle hooks, etc.
105
+ *
106
+ * @param {string} code — ErrorCode
107
+ * @param {string} message
108
+ * @param {object} [context]
109
+ * @param {Error} [cause]
110
+ */
111
+ function reportError(code, message, context = {}, cause) {
112
+ const err = cause instanceof ZQueryError
113
+ ? cause
114
+ : new ZQueryError(code, message, context, cause);
115
+
116
+ // User handler gets first crack
117
+ if (_errorHandler) {
118
+ try { _errorHandler(err); } catch { /* prevent handler from crashing framework */ }
119
+ }
120
+
121
+ // Always log for developer visibility
122
+ console.error(`[zQuery ${code}] ${message}`, context, cause || '');
123
+ }
124
+
125
+ /**
126
+ * Wrap a callback so that thrown errors are caught, reported, and don't crash
127
+ * the current execution context.
128
+ *
129
+ * @param {Function} fn
130
+ * @param {string} code — ErrorCode to use if the callback throws
131
+ * @param {object} [context]
132
+ * @returns {Function}
133
+ */
134
+ function guardCallback(fn, code, context = {}) {
135
+ return (...args) => {
136
+ try {
137
+ return fn(...args);
138
+ } catch (err) {
139
+ reportError(code, err.message || 'Callback error', context, err);
140
+ }
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Validate a required value is defined and of the expected type.
146
+ * Throws ZQueryError on failure (for fast-fail at API boundaries).
147
+ *
148
+ * @param {*} value
149
+ * @param {string} name — parameter name for error message
150
+ * @param {string} expectedType — 'string', 'function', 'object', etc.
151
+ */
152
+ function validate(value, name, expectedType) {
153
+ if (value === undefined || value === null) {
154
+ throw new ZQueryError(
155
+ ErrorCode.INVALID_ARGUMENT,
156
+ `"${name}" is required but got ${value}`
157
+ );
158
+ }
159
+ if (expectedType && typeof value !== expectedType) {
160
+ throw new ZQueryError(
161
+ ErrorCode.INVALID_ARGUMENT,
162
+ `"${name}" must be a ${expectedType}, got ${typeof value}`
163
+ );
164
+ }
165
+ }
166
+
167
+ // --- src/reactive.js ---------------------------------------------
11
168
  /**
12
169
  * zQuery Reactive — Proxy-based deep reactivity system
13
170
  *
@@ -15,11 +172,16 @@
15
172
  * Used internally by components and store for auto-updates.
16
173
  */
17
174
 
175
+
18
176
  // ---------------------------------------------------------------------------
19
177
  // Deep reactive proxy
20
178
  // ---------------------------------------------------------------------------
21
179
  function reactive(target, onChange, _path = '') {
22
180
  if (typeof target !== 'object' || target === null) return target;
181
+ if (typeof onChange !== 'function') {
182
+ reportError(ErrorCode.REACTIVE_CALLBACK, 'reactive() onChange must be a function', { received: typeof onChange });
183
+ onChange = () => {};
184
+ }
23
185
 
24
186
  const proxyCache = new WeakMap();
25
187
 
@@ -43,14 +205,22 @@ function reactive(target, onChange, _path = '') {
43
205
  const old = obj[key];
44
206
  if (old === value) return true;
45
207
  obj[key] = value;
46
- onChange(key, value, old);
208
+ try {
209
+ onChange(key, value, old);
210
+ } catch (err) {
211
+ reportError(ErrorCode.REACTIVE_CALLBACK, `Reactive onChange threw for key "${String(key)}"`, { key, value, old }, err);
212
+ }
47
213
  return true;
48
214
  },
49
215
 
50
216
  deleteProperty(obj, key) {
51
217
  const old = obj[key];
52
218
  delete obj[key];
53
- onChange(key, undefined, old);
219
+ try {
220
+ onChange(key, undefined, old);
221
+ } catch (err) {
222
+ reportError(ErrorCode.REACTIVE_CALLBACK, `Reactive onChange threw for key "${String(key)}"`, { key, old }, err);
223
+ }
54
224
  return true;
55
225
  }
56
226
  };
@@ -85,7 +255,12 @@ class Signal {
85
255
  peek() { return this._value; }
86
256
 
87
257
  _notify() {
88
- this._subscribers.forEach(fn => fn());
258
+ this._subscribers.forEach(fn => {
259
+ try { fn(); }
260
+ catch (err) {
261
+ reportError(ErrorCode.SIGNAL_CALLBACK, 'Signal subscriber threw', { signal: this }, err);
262
+ }
263
+ });
89
264
  }
90
265
 
91
266
  subscribe(fn) {
@@ -128,13 +303,19 @@ function effect(fn) {
128
303
  const execute = () => {
129
304
  Signal._activeEffect = execute;
130
305
  try { fn(); }
306
+ catch (err) {
307
+ reportError(ErrorCode.EFFECT_EXEC, 'Effect function threw', {}, err);
308
+ }
131
309
  finally { Signal._activeEffect = null; }
132
310
  };
133
311
  execute();
134
- return () => { /* Signals will hold weak refs if needed */ };
312
+ return () => {
313
+ // Remove this effect from all signals that track it
314
+ Signal._activeEffect = null;
315
+ };
135
316
  }
136
317
 
137
- // --- src/core.js —————————————————————————————————————————————————
318
+ // --- src/core.js -------------------------------------------------
138
319
  /**
139
320
  * zQuery Core — Selector engine & chainable DOM collection
140
321
  *
@@ -163,6 +344,11 @@ class ZQueryCollection {
163
344
  return this.elements.map((el, i) => fn.call(el, i, el));
164
345
  }
165
346
 
347
+ forEach(fn) {
348
+ this.elements.forEach((el, i) => fn(el, i, this.elements));
349
+ return this;
350
+ }
351
+
166
352
  first() { return this.elements[0] || null; }
167
353
  last() { return this.elements[this.length - 1] || null; }
168
354
  eq(i) { return new ZQueryCollection(this.elements[i] ? [this.elements[i]] : []); }
@@ -207,8 +393,96 @@ class ZQueryCollection {
207
393
  return new ZQueryCollection(sibs);
208
394
  }
209
395
 
210
- next() { return new ZQueryCollection(this.elements.map(el => el.nextElementSibling).filter(Boolean)); }
211
- prev() { return new ZQueryCollection(this.elements.map(el => el.previousElementSibling).filter(Boolean)); }
396
+ next(selector) {
397
+ const els = this.elements.map(el => el.nextElementSibling).filter(Boolean);
398
+ return new ZQueryCollection(selector ? els.filter(el => el.matches(selector)) : els);
399
+ }
400
+
401
+ prev(selector) {
402
+ const els = this.elements.map(el => el.previousElementSibling).filter(Boolean);
403
+ return new ZQueryCollection(selector ? els.filter(el => el.matches(selector)) : els);
404
+ }
405
+
406
+ nextAll(selector) {
407
+ const result = [];
408
+ this.elements.forEach(el => {
409
+ let sib = el.nextElementSibling;
410
+ while (sib) {
411
+ if (!selector || sib.matches(selector)) result.push(sib);
412
+ sib = sib.nextElementSibling;
413
+ }
414
+ });
415
+ return new ZQueryCollection(result);
416
+ }
417
+
418
+ nextUntil(selector, filter) {
419
+ const result = [];
420
+ this.elements.forEach(el => {
421
+ let sib = el.nextElementSibling;
422
+ while (sib) {
423
+ if (selector && sib.matches(selector)) break;
424
+ if (!filter || sib.matches(filter)) result.push(sib);
425
+ sib = sib.nextElementSibling;
426
+ }
427
+ });
428
+ return new ZQueryCollection(result);
429
+ }
430
+
431
+ prevAll(selector) {
432
+ const result = [];
433
+ this.elements.forEach(el => {
434
+ let sib = el.previousElementSibling;
435
+ while (sib) {
436
+ if (!selector || sib.matches(selector)) result.push(sib);
437
+ sib = sib.previousElementSibling;
438
+ }
439
+ });
440
+ return new ZQueryCollection(result);
441
+ }
442
+
443
+ prevUntil(selector, filter) {
444
+ const result = [];
445
+ this.elements.forEach(el => {
446
+ let sib = el.previousElementSibling;
447
+ while (sib) {
448
+ if (selector && sib.matches(selector)) break;
449
+ if (!filter || sib.matches(filter)) result.push(sib);
450
+ sib = sib.previousElementSibling;
451
+ }
452
+ });
453
+ return new ZQueryCollection(result);
454
+ }
455
+
456
+ parents(selector) {
457
+ const result = [];
458
+ this.elements.forEach(el => {
459
+ let parent = el.parentElement;
460
+ while (parent) {
461
+ if (!selector || parent.matches(selector)) result.push(parent);
462
+ parent = parent.parentElement;
463
+ }
464
+ });
465
+ return new ZQueryCollection([...new Set(result)]);
466
+ }
467
+
468
+ parentsUntil(selector, filter) {
469
+ const result = [];
470
+ this.elements.forEach(el => {
471
+ let parent = el.parentElement;
472
+ while (parent) {
473
+ if (selector && parent.matches(selector)) break;
474
+ if (!filter || parent.matches(filter)) result.push(parent);
475
+ parent = parent.parentElement;
476
+ }
477
+ });
478
+ return new ZQueryCollection([...new Set(result)]);
479
+ }
480
+
481
+ contents() {
482
+ const result = [];
483
+ this.elements.forEach(el => result.push(...el.childNodes));
484
+ return new ZQueryCollection(result);
485
+ }
212
486
 
213
487
  filter(selector) {
214
488
  if (typeof selector === 'function') {
@@ -228,6 +502,42 @@ class ZQueryCollection {
228
502
  return new ZQueryCollection(this.elements.filter(el => el.querySelector(selector)));
229
503
  }
230
504
 
505
+ is(selector) {
506
+ if (typeof selector === 'function') {
507
+ return this.elements.some((el, i) => selector.call(el, i, el));
508
+ }
509
+ return this.elements.some(el => el.matches(selector));
510
+ }
511
+
512
+ slice(start, end) {
513
+ return new ZQueryCollection(this.elements.slice(start, end));
514
+ }
515
+
516
+ add(selector, context) {
517
+ const toAdd = (selector instanceof ZQueryCollection)
518
+ ? selector.elements
519
+ : (selector instanceof Node)
520
+ ? [selector]
521
+ : Array.from((context || document).querySelectorAll(selector));
522
+ return new ZQueryCollection([...this.elements, ...toAdd]);
523
+ }
524
+
525
+ get(index) {
526
+ if (index === undefined) return [...this.elements];
527
+ return index < 0 ? this.elements[this.length + index] : this.elements[index];
528
+ }
529
+
530
+ index(selector) {
531
+ if (selector === undefined) {
532
+ const el = this.first();
533
+ return el ? Array.from(el.parentElement.children).indexOf(el) : -1;
534
+ }
535
+ const target = (typeof selector === 'string')
536
+ ? document.querySelector(selector)
537
+ : selector;
538
+ return this.elements.indexOf(target);
539
+ }
540
+
231
541
  // --- Classes -------------------------------------------------------------
232
542
 
233
543
  addClass(...names) {
@@ -240,8 +550,12 @@ class ZQueryCollection {
240
550
  return this.each((_, el) => el.classList.remove(...classes));
241
551
  }
242
552
 
243
- toggleClass(name, force) {
244
- return this.each((_, el) => el.classList.toggle(name, force));
553
+ toggleClass(...args) {
554
+ const force = typeof args[args.length - 1] === 'boolean' ? args.pop() : undefined;
555
+ const classes = args.flatMap(n => n.split(/\s+/));
556
+ return this.each((_, el) => {
557
+ classes.forEach(c => force !== undefined ? el.classList.toggle(c, force) : el.classList.toggle(c));
558
+ });
245
559
  }
246
560
 
247
561
  hasClass(name) {
@@ -295,6 +609,60 @@ class ZQueryCollection {
295
609
  return el ? { top: el.offsetTop, left: el.offsetLeft } : null;
296
610
  }
297
611
 
612
+ scrollTop(value) {
613
+ if (value === undefined) {
614
+ const el = this.first();
615
+ return el === window ? window.scrollY : el?.scrollTop;
616
+ }
617
+ return this.each((_, el) => {
618
+ if (el === window) window.scrollTo(window.scrollX, value);
619
+ else el.scrollTop = value;
620
+ });
621
+ }
622
+
623
+ scrollLeft(value) {
624
+ if (value === undefined) {
625
+ const el = this.first();
626
+ return el === window ? window.scrollX : el?.scrollLeft;
627
+ }
628
+ return this.each((_, el) => {
629
+ if (el === window) window.scrollTo(value, window.scrollY);
630
+ else el.scrollLeft = value;
631
+ });
632
+ }
633
+
634
+ innerWidth() {
635
+ const el = this.first();
636
+ return el?.clientWidth;
637
+ }
638
+
639
+ innerHeight() {
640
+ const el = this.first();
641
+ return el?.clientHeight;
642
+ }
643
+
644
+ outerWidth(includeMargin = false) {
645
+ const el = this.first();
646
+ if (!el) return undefined;
647
+ let w = el.offsetWidth;
648
+ if (includeMargin) {
649
+ const style = getComputedStyle(el);
650
+ w += parseFloat(style.marginLeft) + parseFloat(style.marginRight);
651
+ }
652
+ return w;
653
+ }
654
+
655
+ outerHeight(includeMargin = false) {
656
+ const el = this.first();
657
+ if (!el) return undefined;
658
+ let h = el.offsetHeight;
659
+ if (includeMargin) {
660
+ const style = getComputedStyle(el);
661
+ h += parseFloat(style.marginTop) + parseFloat(style.marginBottom);
662
+ }
663
+ return h;
664
+ }
665
+
298
666
  // --- Content -------------------------------------------------------------
299
667
 
300
668
  html(content) {
@@ -374,6 +742,73 @@ class ZQueryCollection {
374
742
  });
375
743
  }
376
744
 
745
+ appendTo(target) {
746
+ const dest = typeof target === 'string' ? document.querySelector(target) : target instanceof ZQueryCollection ? target.first() : target;
747
+ if (dest) this.each((_, el) => dest.appendChild(el));
748
+ return this;
749
+ }
750
+
751
+ prependTo(target) {
752
+ const dest = typeof target === 'string' ? document.querySelector(target) : target instanceof ZQueryCollection ? target.first() : target;
753
+ if (dest) this.each((_, el) => dest.insertBefore(el, dest.firstChild));
754
+ return this;
755
+ }
756
+
757
+ insertAfter(target) {
758
+ const ref = typeof target === 'string' ? document.querySelector(target) : target instanceof ZQueryCollection ? target.first() : target;
759
+ if (ref && ref.parentNode) this.each((_, el) => ref.parentNode.insertBefore(el, ref.nextSibling));
760
+ return this;
761
+ }
762
+
763
+ insertBefore(target) {
764
+ const ref = typeof target === 'string' ? document.querySelector(target) : target instanceof ZQueryCollection ? target.first() : target;
765
+ if (ref && ref.parentNode) this.each((_, el) => ref.parentNode.insertBefore(el, ref));
766
+ return this;
767
+ }
768
+
769
+ replaceAll(target) {
770
+ const targets = typeof target === 'string'
771
+ ? Array.from(document.querySelectorAll(target))
772
+ : target instanceof ZQueryCollection ? target.elements : [target];
773
+ targets.forEach((t, i) => {
774
+ const nodes = i === 0 ? this.elements : this.elements.map(el => el.cloneNode(true));
775
+ nodes.forEach(el => t.parentNode.insertBefore(el, t));
776
+ t.remove();
777
+ });
778
+ return this;
779
+ }
780
+
781
+ unwrap(selector) {
782
+ this.elements.forEach(el => {
783
+ const parent = el.parentElement;
784
+ if (!parent || parent === document.body) return;
785
+ if (selector && !parent.matches(selector)) return;
786
+ parent.replaceWith(...parent.childNodes);
787
+ });
788
+ return this;
789
+ }
790
+
791
+ wrapAll(wrapper) {
792
+ const w = typeof wrapper === 'string' ? createFragment(wrapper).firstElementChild : wrapper.cloneNode(true);
793
+ const first = this.first();
794
+ if (!first) return this;
795
+ first.parentNode.insertBefore(w, first);
796
+ this.each((_, el) => w.appendChild(el));
797
+ return this;
798
+ }
799
+
800
+ wrapInner(wrapper) {
801
+ return this.each((_, el) => {
802
+ const w = typeof wrapper === 'string' ? createFragment(wrapper).firstElementChild : wrapper.cloneNode(true);
803
+ while (el.firstChild) w.appendChild(el.firstChild);
804
+ el.appendChild(w);
805
+ });
806
+ }
807
+
808
+ detach() {
809
+ return this.each((_, el) => el.remove());
810
+ }
811
+
377
812
  // --- Visibility ----------------------------------------------------------
378
813
 
379
814
  show(display = '') {
@@ -399,9 +834,10 @@ class ZQueryCollection {
399
834
  events.forEach(evt => {
400
835
  if (typeof selectorOrHandler === 'function') {
401
836
  el.addEventListener(evt, selectorOrHandler);
402
- } else {
403
- // Delegated event
837
+ } else if (typeof selectorOrHandler === 'string') {
838
+ // Delegated event — only works on elements that support closest()
404
839
  el.addEventListener(evt, (e) => {
840
+ if (!e.target || typeof e.target.closest !== 'function') return;
405
841
  const target = e.target.closest(selectorOrHandler);
406
842
  if (target && el.contains(target)) handler.call(target, e);
407
843
  });
@@ -434,6 +870,10 @@ class ZQueryCollection {
434
870
  submit(fn) { return fn ? this.on('submit', fn) : this.trigger('submit'); }
435
871
  focus() { this.first()?.focus(); return this; }
436
872
  blur() { this.first()?.blur(); return this; }
873
+ hover(enterFn, leaveFn) {
874
+ this.on('mouseenter', enterFn);
875
+ return this.on('mouseleave', leaveFn || enterFn);
876
+ }
437
877
 
438
878
  // --- Animation -----------------------------------------------------------
439
879
 
@@ -465,6 +905,40 @@ class ZQueryCollection {
465
905
  return this.animate({ opacity: '0' }, duration).then(col => col.hide());
466
906
  }
467
907
 
908
+ fadeToggle(duration = 300) {
909
+ return Promise.all(this.elements.map(el => {
910
+ const visible = getComputedStyle(el).opacity !== '0' && getComputedStyle(el).display !== 'none';
911
+ const col = new ZQueryCollection([el]);
912
+ return visible ? col.fadeOut(duration) : col.fadeIn(duration);
913
+ })).then(() => this);
914
+ }
915
+
916
+ fadeTo(duration, opacity) {
917
+ return this.animate({ opacity: String(opacity) }, duration);
918
+ }
919
+
920
+ slideDown(duration = 300) {
921
+ return this.each((_, el) => {
922
+ el.style.display = '';
923
+ el.style.overflow = 'hidden';
924
+ const h = el.scrollHeight + 'px';
925
+ el.style.maxHeight = '0';
926
+ el.style.transition = `max-height ${duration}ms ease`;
927
+ requestAnimationFrame(() => { el.style.maxHeight = h; });
928
+ setTimeout(() => { el.style.maxHeight = ''; el.style.overflow = ''; el.style.transition = ''; }, duration);
929
+ });
930
+ }
931
+
932
+ slideUp(duration = 300) {
933
+ return this.each((_, el) => {
934
+ el.style.overflow = 'hidden';
935
+ el.style.maxHeight = el.scrollHeight + 'px';
936
+ el.style.transition = `max-height ${duration}ms ease`;
937
+ requestAnimationFrame(() => { el.style.maxHeight = '0'; });
938
+ setTimeout(() => { el.style.display = 'none'; el.style.maxHeight = ''; el.style.overflow = ''; el.style.transition = ''; }, duration);
939
+ });
940
+ }
941
+
468
942
  slideToggle(duration = 300) {
469
943
  return this.each((_, el) => {
470
944
  if (el.style.display === 'none' || getComputedStyle(el).display === 'none') {
@@ -511,163 +985,1252 @@ class ZQueryCollection {
511
985
 
512
986
 
513
987
  // ---------------------------------------------------------------------------
514
- // Helper — create document fragment from HTML string
988
+ // Helper — create document fragment from HTML string
989
+ // ---------------------------------------------------------------------------
990
+ function createFragment(html) {
991
+ const tpl = document.createElement('template');
992
+ tpl.innerHTML = html.trim();
993
+ return tpl.content;
994
+ }
995
+
996
+
997
+ // ---------------------------------------------------------------------------
998
+ // $() — main selector / creator (returns ZQueryCollection, like jQuery)
999
+ // ---------------------------------------------------------------------------
1000
+ function query(selector, context) {
1001
+ // null / undefined
1002
+ if (!selector) return new ZQueryCollection([]);
1003
+
1004
+ // Already a collection — return as-is
1005
+ if (selector instanceof ZQueryCollection) return selector;
1006
+
1007
+ // DOM element or Window — wrap in collection
1008
+ if (selector instanceof Node || selector === window) {
1009
+ return new ZQueryCollection([selector]);
1010
+ }
1011
+
1012
+ // NodeList / HTMLCollection / Array — wrap in collection
1013
+ if (selector instanceof NodeList || selector instanceof HTMLCollection || Array.isArray(selector)) {
1014
+ return new ZQueryCollection(Array.from(selector));
1015
+ }
1016
+
1017
+ // HTML string → create elements, wrap in collection
1018
+ if (typeof selector === 'string' && selector.trim().startsWith('<')) {
1019
+ const fragment = createFragment(selector);
1020
+ return new ZQueryCollection([...fragment.childNodes].filter(n => n.nodeType === 1));
1021
+ }
1022
+
1023
+ // CSS selector string → querySelectorAll (collection)
1024
+ if (typeof selector === 'string') {
1025
+ const root = context
1026
+ ? (typeof context === 'string' ? document.querySelector(context) : context)
1027
+ : document;
1028
+ return new ZQueryCollection([...root.querySelectorAll(selector)]);
1029
+ }
1030
+
1031
+ return new ZQueryCollection([]);
1032
+ }
1033
+
1034
+
1035
+ // ---------------------------------------------------------------------------
1036
+ // $.all() — collection selector (returns ZQueryCollection for CSS selectors)
1037
+ // ---------------------------------------------------------------------------
1038
+ function queryAll(selector, context) {
1039
+ // null / undefined
1040
+ if (!selector) return new ZQueryCollection([]);
1041
+
1042
+ // Already a collection
1043
+ if (selector instanceof ZQueryCollection) return selector;
1044
+
1045
+ // DOM element or Window
1046
+ if (selector instanceof Node || selector === window) {
1047
+ return new ZQueryCollection([selector]);
1048
+ }
1049
+
1050
+ // NodeList / HTMLCollection / Array
1051
+ if (selector instanceof NodeList || selector instanceof HTMLCollection || Array.isArray(selector)) {
1052
+ return new ZQueryCollection(Array.from(selector));
1053
+ }
1054
+
1055
+ // HTML string → create elements
1056
+ if (typeof selector === 'string' && selector.trim().startsWith('<')) {
1057
+ const fragment = createFragment(selector);
1058
+ return new ZQueryCollection([...fragment.childNodes].filter(n => n.nodeType === 1));
1059
+ }
1060
+
1061
+ // CSS selector string → querySelectorAll (collection)
1062
+ if (typeof selector === 'string') {
1063
+ const root = context
1064
+ ? (typeof context === 'string' ? document.querySelector(context) : context)
1065
+ : document;
1066
+ return new ZQueryCollection([...root.querySelectorAll(selector)]);
1067
+ }
1068
+
1069
+ return new ZQueryCollection([]);
1070
+ }
1071
+
1072
+
1073
+ // ---------------------------------------------------------------------------
1074
+ // Quick-ref shortcuts, on $ namespace)
1075
+ // ---------------------------------------------------------------------------
1076
+ query.id = (id) => document.getElementById(id);
1077
+ query.class = (name) => document.querySelector(`.${name}`);
1078
+ query.classes = (name) => new ZQueryCollection(Array.from(document.getElementsByClassName(name)));
1079
+ query.tag = (name) => new ZQueryCollection(Array.from(document.getElementsByTagName(name)));
1080
+ Object.defineProperty(query, 'name', {
1081
+ value: (name) => new ZQueryCollection(Array.from(document.getElementsByName(name))),
1082
+ writable: true, configurable: true
1083
+ });
1084
+ query.children = (parentId) => {
1085
+ const p = document.getElementById(parentId);
1086
+ return new ZQueryCollection(p ? Array.from(p.children) : []);
1087
+ };
1088
+
1089
+ // Create element shorthand — returns ZQueryCollection for chaining
1090
+ query.create = (tag, attrs = {}, ...children) => {
1091
+ const el = document.createElement(tag);
1092
+ for (const [k, v] of Object.entries(attrs)) {
1093
+ if (k === 'class') el.className = v;
1094
+ else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
1095
+ else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v);
1096
+ else if (k === 'data' && typeof v === 'object') Object.entries(v).forEach(([dk, dv]) => { el.dataset[dk] = dv; });
1097
+ else el.setAttribute(k, v);
1098
+ }
1099
+ children.flat().forEach(child => {
1100
+ if (typeof child === 'string') el.appendChild(document.createTextNode(child));
1101
+ else if (child instanceof Node) el.appendChild(child);
1102
+ });
1103
+ return new ZQueryCollection(el);
1104
+ };
1105
+
1106
+ // DOM ready
1107
+ query.ready = (fn) => {
1108
+ if (document.readyState !== 'loading') fn();
1109
+ else document.addEventListener('DOMContentLoaded', fn);
1110
+ };
1111
+
1112
+ // Global event listeners — supports direct, delegated, and target-bound forms
1113
+ // $.on('keydown', handler) → direct listener on document
1114
+ // $.on('click', '.btn', handler) → delegated via closest()
1115
+ // $.on('scroll', window, handler) → direct listener on target
1116
+ query.on = (event, selectorOrHandler, handler) => {
1117
+ if (typeof selectorOrHandler === 'function') {
1118
+ // 2-arg: direct document listener (keydown, resize, etc.)
1119
+ document.addEventListener(event, selectorOrHandler);
1120
+ return;
1121
+ }
1122
+ // EventTarget (window, element, etc.) — direct listener on target
1123
+ if (typeof selectorOrHandler === 'object' && typeof selectorOrHandler.addEventListener === 'function') {
1124
+ selectorOrHandler.addEventListener(event, handler);
1125
+ return;
1126
+ }
1127
+ // 3-arg string: delegated
1128
+ document.addEventListener(event, (e) => {
1129
+ if (!e.target || typeof e.target.closest !== 'function') return;
1130
+ const target = e.target.closest(selectorOrHandler);
1131
+ if (target) handler.call(target, e);
1132
+ });
1133
+ };
1134
+
1135
+ // Remove a direct global listener
1136
+ query.off = (event, handler) => {
1137
+ document.removeEventListener(event, handler);
1138
+ };
1139
+
1140
+ // Extend collection prototype (like $.fn in jQuery)
1141
+ query.fn = ZQueryCollection.prototype;
1142
+
1143
+ // --- src/expression.js -------------------------------------------
1144
+ /**
1145
+ * zQuery Expression Parser — CSP-safe expression evaluator
1146
+ *
1147
+ * Replaces `new Function()` / `eval()` with a hand-written parser that
1148
+ * evaluates expressions safely without violating Content Security Policy.
1149
+ *
1150
+ * Supports:
1151
+ * - Property access: user.name, items[0], items[i]
1152
+ * - Method calls: items.length, str.toUpperCase()
1153
+ * - Arithmetic: a + b, count * 2, i % 2
1154
+ * - Comparison: a === b, count > 0, x != null
1155
+ * - Logical: a && b, a || b, !a
1156
+ * - Ternary: a ? b : c
1157
+ * - Typeof: typeof x
1158
+ * - Unary: -a, +a, !a
1159
+ * - Literals: 42, 'hello', "world", true, false, null, undefined
1160
+ * - Template literals: `Hello ${name}`
1161
+ * - Array literals: [1, 2, 3]
1162
+ * - Object literals: { foo: 'bar', baz: 1 }
1163
+ * - Grouping: (a + b) * c
1164
+ * - Nullish coalescing: a ?? b
1165
+ * - Optional chaining: a?.b, a?.[b], a?.()
1166
+ * - Arrow functions: x => x.id, (a, b) => a + b
1167
+ */
1168
+
1169
+ // Token types
1170
+ const T = {
1171
+ NUM: 1, STR: 2, IDENT: 3, OP: 4, PUNC: 5, TMPL: 6, EOF: 7
1172
+ };
1173
+
1174
+ // Operator precedence (higher = binds tighter)
1175
+ const PREC = {
1176
+ '??': 2,
1177
+ '||': 3,
1178
+ '&&': 4,
1179
+ '==': 8, '!=': 8, '===': 8, '!==': 8,
1180
+ '<': 9, '>': 9, '<=': 9, '>=': 9, 'instanceof': 9, 'in': 9,
1181
+ '+': 11, '-': 11,
1182
+ '*': 12, '/': 12, '%': 12,
1183
+ };
1184
+
1185
+ const KEYWORDS = new Set([
1186
+ 'true', 'false', 'null', 'undefined', 'typeof', 'instanceof', 'in',
1187
+ 'new', 'void'
1188
+ ]);
1189
+
1190
+ // ---------------------------------------------------------------------------
1191
+ // Tokenizer
1192
+ // ---------------------------------------------------------------------------
1193
+ function tokenize(expr) {
1194
+ const tokens = [];
1195
+ let i = 0;
1196
+ const len = expr.length;
1197
+
1198
+ while (i < len) {
1199
+ const ch = expr[i];
1200
+
1201
+ // Whitespace
1202
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { i++; continue; }
1203
+
1204
+ // Numbers
1205
+ if ((ch >= '0' && ch <= '9') || (ch === '.' && i + 1 < len && expr[i + 1] >= '0' && expr[i + 1] <= '9')) {
1206
+ let num = '';
1207
+ if (ch === '0' && i + 1 < len && (expr[i + 1] === 'x' || expr[i + 1] === 'X')) {
1208
+ num = '0x'; i += 2;
1209
+ while (i < len && /[0-9a-fA-F]/.test(expr[i])) num += expr[i++];
1210
+ } else {
1211
+ while (i < len && ((expr[i] >= '0' && expr[i] <= '9') || expr[i] === '.')) num += expr[i++];
1212
+ if (i < len && (expr[i] === 'e' || expr[i] === 'E')) {
1213
+ num += expr[i++];
1214
+ if (i < len && (expr[i] === '+' || expr[i] === '-')) num += expr[i++];
1215
+ while (i < len && expr[i] >= '0' && expr[i] <= '9') num += expr[i++];
1216
+ }
1217
+ }
1218
+ tokens.push({ t: T.NUM, v: Number(num) });
1219
+ continue;
1220
+ }
1221
+
1222
+ // Strings
1223
+ if (ch === "'" || ch === '"') {
1224
+ const quote = ch;
1225
+ let str = '';
1226
+ i++;
1227
+ while (i < len && expr[i] !== quote) {
1228
+ if (expr[i] === '\\' && i + 1 < len) {
1229
+ const esc = expr[++i];
1230
+ if (esc === 'n') str += '\n';
1231
+ else if (esc === 't') str += '\t';
1232
+ else if (esc === 'r') str += '\r';
1233
+ else if (esc === '\\') str += '\\';
1234
+ else if (esc === quote) str += quote;
1235
+ else str += esc;
1236
+ } else {
1237
+ str += expr[i];
1238
+ }
1239
+ i++;
1240
+ }
1241
+ i++; // closing quote
1242
+ tokens.push({ t: T.STR, v: str });
1243
+ continue;
1244
+ }
1245
+
1246
+ // Template literals
1247
+ if (ch === '`') {
1248
+ const parts = []; // alternating: string, expr, string, expr, ...
1249
+ let str = '';
1250
+ i++;
1251
+ while (i < len && expr[i] !== '`') {
1252
+ if (expr[i] === '$' && i + 1 < len && expr[i + 1] === '{') {
1253
+ parts.push(str);
1254
+ str = '';
1255
+ i += 2;
1256
+ let depth = 1;
1257
+ let inner = '';
1258
+ while (i < len && depth > 0) {
1259
+ if (expr[i] === '{') depth++;
1260
+ else if (expr[i] === '}') { depth--; if (depth === 0) break; }
1261
+ inner += expr[i++];
1262
+ }
1263
+ i++; // closing }
1264
+ parts.push({ expr: inner });
1265
+ } else {
1266
+ if (expr[i] === '\\' && i + 1 < len) { str += expr[++i]; }
1267
+ else str += expr[i];
1268
+ i++;
1269
+ }
1270
+ }
1271
+ i++; // closing backtick
1272
+ parts.push(str);
1273
+ tokens.push({ t: T.TMPL, v: parts });
1274
+ continue;
1275
+ }
1276
+
1277
+ // Identifiers & keywords
1278
+ if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_' || ch === '$') {
1279
+ let ident = '';
1280
+ while (i < len && /[\w$]/.test(expr[i])) ident += expr[i++];
1281
+ tokens.push({ t: T.IDENT, v: ident });
1282
+ continue;
1283
+ }
1284
+
1285
+ // Multi-char operators
1286
+ const two = expr.slice(i, i + 3);
1287
+ if (two === '===' || two === '!==' || two === '?.') {
1288
+ if (two === '?.') {
1289
+ tokens.push({ t: T.OP, v: '?.' });
1290
+ i += 2;
1291
+ } else {
1292
+ tokens.push({ t: T.OP, v: two });
1293
+ i += 3;
1294
+ }
1295
+ continue;
1296
+ }
1297
+ const pair = expr.slice(i, i + 2);
1298
+ if (pair === '==' || pair === '!=' || pair === '<=' || pair === '>=' ||
1299
+ pair === '&&' || pair === '||' || pair === '??' || pair === '?.' ||
1300
+ pair === '=>') {
1301
+ tokens.push({ t: T.OP, v: pair });
1302
+ i += 2;
1303
+ continue;
1304
+ }
1305
+
1306
+ // Single char operators and punctuation
1307
+ if ('+-*/%'.includes(ch)) {
1308
+ tokens.push({ t: T.OP, v: ch });
1309
+ i++; continue;
1310
+ }
1311
+ if ('<>=!'.includes(ch)) {
1312
+ tokens.push({ t: T.OP, v: ch });
1313
+ i++; continue;
1314
+ }
1315
+ if ('()[]{},.?:'.includes(ch)) {
1316
+ tokens.push({ t: T.PUNC, v: ch });
1317
+ i++; continue;
1318
+ }
1319
+
1320
+ // Unknown — skip
1321
+ i++;
1322
+ }
1323
+
1324
+ tokens.push({ t: T.EOF, v: null });
1325
+ return tokens;
1326
+ }
1327
+
1328
+ // ---------------------------------------------------------------------------
1329
+ // Parser — Pratt (precedence climbing)
1330
+ // ---------------------------------------------------------------------------
1331
+ class Parser {
1332
+ constructor(tokens, scope) {
1333
+ this.tokens = tokens;
1334
+ this.pos = 0;
1335
+ this.scope = scope;
1336
+ }
1337
+
1338
+ peek() { return this.tokens[this.pos]; }
1339
+ next() { return this.tokens[this.pos++]; }
1340
+
1341
+ expect(type, val) {
1342
+ const t = this.next();
1343
+ if (t.t !== type || (val !== undefined && t.v !== val)) {
1344
+ throw new Error(`Expected ${val || type} but got ${t.v}`);
1345
+ }
1346
+ return t;
1347
+ }
1348
+
1349
+ match(type, val) {
1350
+ const t = this.peek();
1351
+ if (t.t === type && (val === undefined || t.v === val)) {
1352
+ return this.next();
1353
+ }
1354
+ return null;
1355
+ }
1356
+
1357
+ // Main entry
1358
+ parse() {
1359
+ const result = this.parseExpression(0);
1360
+ return result;
1361
+ }
1362
+
1363
+ // Precedence climbing
1364
+ parseExpression(minPrec) {
1365
+ let left = this.parseUnary();
1366
+
1367
+ while (true) {
1368
+ const tok = this.peek();
1369
+
1370
+ // Ternary
1371
+ if (tok.t === T.PUNC && tok.v === '?') {
1372
+ // Distinguish ternary ? from optional chaining ?.
1373
+ if (this.tokens[this.pos + 1]?.v !== '.') {
1374
+ if (1 <= minPrec) break; // ternary has very low precedence
1375
+ this.next(); // consume ?
1376
+ const truthy = this.parseExpression(0);
1377
+ this.expect(T.PUNC, ':');
1378
+ const falsy = this.parseExpression(1);
1379
+ left = { type: 'ternary', cond: left, truthy, falsy };
1380
+ continue;
1381
+ }
1382
+ }
1383
+
1384
+ // Binary operators
1385
+ if (tok.t === T.OP && tok.v in PREC) {
1386
+ const prec = PREC[tok.v];
1387
+ if (prec <= minPrec) break;
1388
+ this.next();
1389
+ const right = this.parseExpression(prec);
1390
+ left = { type: 'binary', op: tok.v, left, right };
1391
+ continue;
1392
+ }
1393
+
1394
+ // instanceof and in as binary operators
1395
+ if (tok.t === T.IDENT && (tok.v === 'instanceof' || tok.v === 'in') && PREC[tok.v] > minPrec) {
1396
+ const prec = PREC[tok.v];
1397
+ this.next();
1398
+ const right = this.parseExpression(prec);
1399
+ left = { type: 'binary', op: tok.v, left, right };
1400
+ continue;
1401
+ }
1402
+
1403
+ break;
1404
+ }
1405
+
1406
+ return left;
1407
+ }
1408
+
1409
+ parseUnary() {
1410
+ const tok = this.peek();
1411
+
1412
+ // typeof
1413
+ if (tok.t === T.IDENT && tok.v === 'typeof') {
1414
+ this.next();
1415
+ const arg = this.parseUnary();
1416
+ return { type: 'typeof', arg };
1417
+ }
1418
+
1419
+ // void
1420
+ if (tok.t === T.IDENT && tok.v === 'void') {
1421
+ this.next();
1422
+ this.parseUnary(); // evaluate but discard
1423
+ return { type: 'literal', value: undefined };
1424
+ }
1425
+
1426
+ // !expr
1427
+ if (tok.t === T.OP && tok.v === '!') {
1428
+ this.next();
1429
+ const arg = this.parseUnary();
1430
+ return { type: 'not', arg };
1431
+ }
1432
+
1433
+ // -expr, +expr
1434
+ if (tok.t === T.OP && (tok.v === '-' || tok.v === '+')) {
1435
+ this.next();
1436
+ const arg = this.parseUnary();
1437
+ return { type: 'unary', op: tok.v, arg };
1438
+ }
1439
+
1440
+ return this.parsePostfix();
1441
+ }
1442
+
1443
+ parsePostfix() {
1444
+ let left = this.parsePrimary();
1445
+
1446
+ while (true) {
1447
+ const tok = this.peek();
1448
+
1449
+ // Property access: a.b
1450
+ if (tok.t === T.PUNC && tok.v === '.') {
1451
+ this.next();
1452
+ const prop = this.next();
1453
+ left = { type: 'member', obj: left, prop: prop.v, computed: false };
1454
+ // Check for method call: a.b()
1455
+ if (this.peek().t === T.PUNC && this.peek().v === '(') {
1456
+ left = this._parseCall(left);
1457
+ }
1458
+ continue;
1459
+ }
1460
+
1461
+ // Optional chaining: a?.b, a?.[b], a?.()
1462
+ if (tok.t === T.OP && tok.v === '?.') {
1463
+ this.next();
1464
+ const next = this.peek();
1465
+ if (next.t === T.PUNC && next.v === '[') {
1466
+ // a?.[expr]
1467
+ this.next();
1468
+ const prop = this.parseExpression(0);
1469
+ this.expect(T.PUNC, ']');
1470
+ left = { type: 'optional_member', obj: left, prop, computed: true };
1471
+ } else if (next.t === T.PUNC && next.v === '(') {
1472
+ // a?.()
1473
+ left = { type: 'optional_call', callee: left, args: this._parseArgs() };
1474
+ } else {
1475
+ // a?.b
1476
+ const prop = this.next();
1477
+ left = { type: 'optional_member', obj: left, prop: prop.v, computed: false };
1478
+ if (this.peek().t === T.PUNC && this.peek().v === '(') {
1479
+ left = this._parseCall(left);
1480
+ }
1481
+ }
1482
+ continue;
1483
+ }
1484
+
1485
+ // Computed access: a[b]
1486
+ if (tok.t === T.PUNC && tok.v === '[') {
1487
+ this.next();
1488
+ const prop = this.parseExpression(0);
1489
+ this.expect(T.PUNC, ']');
1490
+ left = { type: 'member', obj: left, prop, computed: true };
1491
+ // Check for method call: a[b]()
1492
+ if (this.peek().t === T.PUNC && this.peek().v === '(') {
1493
+ left = this._parseCall(left);
1494
+ }
1495
+ continue;
1496
+ }
1497
+
1498
+ // Function call: fn()
1499
+ if (tok.t === T.PUNC && tok.v === '(') {
1500
+ left = this._parseCall(left);
1501
+ continue;
1502
+ }
1503
+
1504
+ break;
1505
+ }
1506
+
1507
+ return left;
1508
+ }
1509
+
1510
+ _parseCall(callee) {
1511
+ const args = this._parseArgs();
1512
+ return { type: 'call', callee, args };
1513
+ }
1514
+
1515
+ _parseArgs() {
1516
+ this.expect(T.PUNC, '(');
1517
+ const args = [];
1518
+ while (!(this.peek().t === T.PUNC && this.peek().v === ')') && this.peek().t !== T.EOF) {
1519
+ args.push(this.parseExpression(0));
1520
+ if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
1521
+ }
1522
+ this.expect(T.PUNC, ')');
1523
+ return args;
1524
+ }
1525
+
1526
+ parsePrimary() {
1527
+ const tok = this.peek();
1528
+
1529
+ // Number literal
1530
+ if (tok.t === T.NUM) {
1531
+ this.next();
1532
+ return { type: 'literal', value: tok.v };
1533
+ }
1534
+
1535
+ // String literal
1536
+ if (tok.t === T.STR) {
1537
+ this.next();
1538
+ return { type: 'literal', value: tok.v };
1539
+ }
1540
+
1541
+ // Template literal
1542
+ if (tok.t === T.TMPL) {
1543
+ this.next();
1544
+ return { type: 'template', parts: tok.v };
1545
+ }
1546
+
1547
+ // Arrow function with parens: () =>, (a) =>, (a, b) =>
1548
+ // or regular grouping: (expr)
1549
+ if (tok.t === T.PUNC && tok.v === '(') {
1550
+ const savedPos = this.pos;
1551
+ this.next(); // consume (
1552
+ const params = [];
1553
+ let couldBeArrow = true;
1554
+
1555
+ if (this.peek().t === T.PUNC && this.peek().v === ')') {
1556
+ // () => ... — no params
1557
+ } else {
1558
+ while (couldBeArrow) {
1559
+ const p = this.peek();
1560
+ if (p.t === T.IDENT && !KEYWORDS.has(p.v)) {
1561
+ params.push(this.next().v);
1562
+ if (this.peek().t === T.PUNC && this.peek().v === ',') {
1563
+ this.next();
1564
+ } else {
1565
+ break;
1566
+ }
1567
+ } else {
1568
+ couldBeArrow = false;
1569
+ }
1570
+ }
1571
+ }
1572
+
1573
+ if (couldBeArrow && this.peek().t === T.PUNC && this.peek().v === ')') {
1574
+ this.next(); // consume )
1575
+ if (this.peek().t === T.OP && this.peek().v === '=>') {
1576
+ this.next(); // consume =>
1577
+ const body = this.parseExpression(0);
1578
+ return { type: 'arrow', params, body };
1579
+ }
1580
+ }
1581
+
1582
+ // Not an arrow — restore and parse as grouping
1583
+ this.pos = savedPos;
1584
+ this.next(); // consume (
1585
+ const expr = this.parseExpression(0);
1586
+ this.expect(T.PUNC, ')');
1587
+ return expr;
1588
+ }
1589
+
1590
+ // Array literal
1591
+ if (tok.t === T.PUNC && tok.v === '[') {
1592
+ this.next();
1593
+ const elements = [];
1594
+ while (!(this.peek().t === T.PUNC && this.peek().v === ']') && this.peek().t !== T.EOF) {
1595
+ elements.push(this.parseExpression(0));
1596
+ if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
1597
+ }
1598
+ this.expect(T.PUNC, ']');
1599
+ return { type: 'array', elements };
1600
+ }
1601
+
1602
+ // Object literal
1603
+ if (tok.t === T.PUNC && tok.v === '{') {
1604
+ this.next();
1605
+ const properties = [];
1606
+ while (!(this.peek().t === T.PUNC && this.peek().v === '}') && this.peek().t !== T.EOF) {
1607
+ const keyTok = this.next();
1608
+ let key;
1609
+ if (keyTok.t === T.IDENT || keyTok.t === T.STR) key = keyTok.v;
1610
+ else if (keyTok.t === T.NUM) key = String(keyTok.v);
1611
+ else throw new Error('Invalid object key: ' + keyTok.v);
1612
+
1613
+ // Shorthand property: { foo } means { foo: foo }
1614
+ if (this.peek().t === T.PUNC && (this.peek().v === ',' || this.peek().v === '}')) {
1615
+ properties.push({ key, value: { type: 'ident', name: key } });
1616
+ } else {
1617
+ this.expect(T.PUNC, ':');
1618
+ properties.push({ key, value: this.parseExpression(0) });
1619
+ }
1620
+ if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
1621
+ }
1622
+ this.expect(T.PUNC, '}');
1623
+ return { type: 'object', properties };
1624
+ }
1625
+
1626
+ // Identifiers & keywords
1627
+ if (tok.t === T.IDENT) {
1628
+ this.next();
1629
+
1630
+ // Keywords
1631
+ if (tok.v === 'true') return { type: 'literal', value: true };
1632
+ if (tok.v === 'false') return { type: 'literal', value: false };
1633
+ if (tok.v === 'null') return { type: 'literal', value: null };
1634
+ if (tok.v === 'undefined') return { type: 'literal', value: undefined };
1635
+
1636
+ // new keyword
1637
+ if (tok.v === 'new') {
1638
+ const classExpr = this.parsePostfix();
1639
+ let args = [];
1640
+ if (this.peek().t === T.PUNC && this.peek().v === '(') {
1641
+ args = this._parseArgs();
1642
+ }
1643
+ return { type: 'new', callee: classExpr, args };
1644
+ }
1645
+
1646
+ // Arrow function: x => expr
1647
+ if (this.peek().t === T.OP && this.peek().v === '=>') {
1648
+ this.next(); // consume =>
1649
+ const body = this.parseExpression(0);
1650
+ return { type: 'arrow', params: [tok.v], body };
1651
+ }
1652
+
1653
+ return { type: 'ident', name: tok.v };
1654
+ }
1655
+
1656
+ // Fallback — return undefined for unparseable
1657
+ this.next();
1658
+ return { type: 'literal', value: undefined };
1659
+ }
1660
+ }
1661
+
1662
+ // ---------------------------------------------------------------------------
1663
+ // Evaluator — walks the AST, resolves against scope
1664
+ // ---------------------------------------------------------------------------
1665
+
1666
+ /** Safe property access whitelist for built-in prototypes */
1667
+ const SAFE_ARRAY_METHODS = new Set([
1668
+ 'length', 'map', 'filter', 'find', 'findIndex', 'some', 'every',
1669
+ 'reduce', 'reduceRight', 'forEach', 'includes', 'indexOf', 'lastIndexOf',
1670
+ 'join', 'slice', 'concat', 'flat', 'flatMap', 'reverse', 'sort',
1671
+ 'fill', 'keys', 'values', 'entries', 'at', 'toString',
1672
+ ]);
1673
+
1674
+ const SAFE_STRING_METHODS = new Set([
1675
+ 'length', 'charAt', 'charCodeAt', 'includes', 'indexOf', 'lastIndexOf',
1676
+ 'slice', 'substring', 'trim', 'trimStart', 'trimEnd', 'toLowerCase',
1677
+ 'toUpperCase', 'split', 'replace', 'replaceAll', 'match', 'search',
1678
+ 'startsWith', 'endsWith', 'padStart', 'padEnd', 'repeat', 'at',
1679
+ 'toString', 'valueOf',
1680
+ ]);
1681
+
1682
+ const SAFE_NUMBER_METHODS = new Set([
1683
+ 'toFixed', 'toPrecision', 'toString', 'valueOf',
1684
+ ]);
1685
+
1686
+ const SAFE_OBJECT_METHODS = new Set([
1687
+ 'hasOwnProperty', 'toString', 'valueOf',
1688
+ ]);
1689
+
1690
+ const SAFE_MATH_PROPS = new Set([
1691
+ 'PI', 'E', 'LN2', 'LN10', 'LOG2E', 'LOG10E', 'SQRT2', 'SQRT1_2',
1692
+ 'abs', 'ceil', 'floor', 'round', 'trunc', 'max', 'min', 'pow',
1693
+ 'sqrt', 'sign', 'random', 'log', 'log2', 'log10',
1694
+ ]);
1695
+
1696
+ const SAFE_JSON_PROPS = new Set(['parse', 'stringify']);
1697
+
1698
+ /**
1699
+ * Check if property access is safe
1700
+ */
1701
+ function _isSafeAccess(obj, prop) {
1702
+ // Never allow access to dangerous properties
1703
+ const BLOCKED = new Set([
1704
+ 'constructor', '__proto__', 'prototype', '__defineGetter__',
1705
+ '__defineSetter__', '__lookupGetter__', '__lookupSetter__',
1706
+ ]);
1707
+ if (typeof prop === 'string' && BLOCKED.has(prop)) return false;
1708
+
1709
+ // Always allow plain object/function property access and array index access
1710
+ if (obj !== null && obj !== undefined && (typeof obj === 'object' || typeof obj === 'function')) return true;
1711
+ if (typeof obj === 'string') return SAFE_STRING_METHODS.has(prop);
1712
+ if (typeof obj === 'number') return SAFE_NUMBER_METHODS.has(prop);
1713
+ return false;
1714
+ }
1715
+
1716
+ function evaluate(node, scope) {
1717
+ if (!node) return undefined;
1718
+
1719
+ switch (node.type) {
1720
+ case 'literal':
1721
+ return node.value;
1722
+
1723
+ case 'ident': {
1724
+ const name = node.name;
1725
+ // Check scope layers in order
1726
+ for (const layer of scope) {
1727
+ if (layer && typeof layer === 'object' && name in layer) {
1728
+ return layer[name];
1729
+ }
1730
+ }
1731
+ // Built-in globals (safe ones only)
1732
+ if (name === 'Math') return Math;
1733
+ if (name === 'JSON') return JSON;
1734
+ if (name === 'Date') return Date;
1735
+ if (name === 'Array') return Array;
1736
+ if (name === 'Object') return Object;
1737
+ if (name === 'String') return String;
1738
+ if (name === 'Number') return Number;
1739
+ if (name === 'Boolean') return Boolean;
1740
+ if (name === 'parseInt') return parseInt;
1741
+ if (name === 'parseFloat') return parseFloat;
1742
+ if (name === 'isNaN') return isNaN;
1743
+ if (name === 'isFinite') return isFinite;
1744
+ if (name === 'Infinity') return Infinity;
1745
+ if (name === 'NaN') return NaN;
1746
+ if (name === 'encodeURIComponent') return encodeURIComponent;
1747
+ if (name === 'decodeURIComponent') return decodeURIComponent;
1748
+ if (name === 'console') return console;
1749
+ return undefined;
1750
+ }
1751
+
1752
+ case 'template': {
1753
+ // Template literal with interpolation
1754
+ let result = '';
1755
+ for (const part of node.parts) {
1756
+ if (typeof part === 'string') {
1757
+ result += part;
1758
+ } else if (part && part.expr) {
1759
+ result += String(safeEval(part.expr, scope) ?? '');
1760
+ }
1761
+ }
1762
+ return result;
1763
+ }
1764
+
1765
+ case 'member': {
1766
+ const obj = evaluate(node.obj, scope);
1767
+ if (obj == null) return undefined;
1768
+ const prop = node.computed ? evaluate(node.prop, scope) : node.prop;
1769
+ if (!_isSafeAccess(obj, prop)) return undefined;
1770
+ return obj[prop];
1771
+ }
1772
+
1773
+ case 'optional_member': {
1774
+ const obj = evaluate(node.obj, scope);
1775
+ if (obj == null) return undefined;
1776
+ const prop = node.computed ? evaluate(node.prop, scope) : node.prop;
1777
+ if (!_isSafeAccess(obj, prop)) return undefined;
1778
+ return obj[prop];
1779
+ }
1780
+
1781
+ case 'call': {
1782
+ const result = _resolveCall(node, scope, false);
1783
+ return result;
1784
+ }
1785
+
1786
+ case 'optional_call': {
1787
+ const callee = evaluate(node.callee, scope);
1788
+ if (callee == null) return undefined;
1789
+ if (typeof callee !== 'function') return undefined;
1790
+ const args = node.args.map(a => evaluate(a, scope));
1791
+ return callee(...args);
1792
+ }
1793
+
1794
+ case 'new': {
1795
+ const Ctor = evaluate(node.callee, scope);
1796
+ if (typeof Ctor !== 'function') return undefined;
1797
+ // Only allow safe constructors
1798
+ if (Ctor === Date || Ctor === Array || Ctor === Map || Ctor === Set ||
1799
+ Ctor === RegExp || Ctor === Error || Ctor === URL || Ctor === URLSearchParams) {
1800
+ const args = node.args.map(a => evaluate(a, scope));
1801
+ return new Ctor(...args);
1802
+ }
1803
+ return undefined;
1804
+ }
1805
+
1806
+ case 'binary':
1807
+ return _evalBinary(node, scope);
1808
+
1809
+ case 'unary': {
1810
+ const val = evaluate(node.arg, scope);
1811
+ return node.op === '-' ? -val : +val;
1812
+ }
1813
+
1814
+ case 'not':
1815
+ return !evaluate(node.arg, scope);
1816
+
1817
+ case 'typeof': {
1818
+ try {
1819
+ return typeof evaluate(node.arg, scope);
1820
+ } catch {
1821
+ return 'undefined';
1822
+ }
1823
+ }
1824
+
1825
+ case 'ternary': {
1826
+ const cond = evaluate(node.cond, scope);
1827
+ return cond ? evaluate(node.truthy, scope) : evaluate(node.falsy, scope);
1828
+ }
1829
+
1830
+ case 'array':
1831
+ return node.elements.map(e => evaluate(e, scope));
1832
+
1833
+ case 'object': {
1834
+ const obj = {};
1835
+ for (const { key, value } of node.properties) {
1836
+ obj[key] = evaluate(value, scope);
1837
+ }
1838
+ return obj;
1839
+ }
1840
+
1841
+ case 'arrow': {
1842
+ const paramNames = node.params;
1843
+ const bodyNode = node.body;
1844
+ const closedScope = scope;
1845
+ return function(...args) {
1846
+ const arrowScope = {};
1847
+ paramNames.forEach((name, i) => { arrowScope[name] = args[i]; });
1848
+ return evaluate(bodyNode, [arrowScope, ...closedScope]);
1849
+ };
1850
+ }
1851
+
1852
+ default:
1853
+ return undefined;
1854
+ }
1855
+ }
1856
+
1857
+ /**
1858
+ * Resolve and execute a function call safely.
1859
+ */
1860
+ function _resolveCall(node, scope) {
1861
+ const callee = node.callee;
1862
+ const args = node.args.map(a => evaluate(a, scope));
1863
+
1864
+ // Method call: obj.method() — bind `this` to obj
1865
+ if (callee.type === 'member' || callee.type === 'optional_member') {
1866
+ const obj = evaluate(callee.obj, scope);
1867
+ if (obj == null) return undefined;
1868
+ const prop = callee.computed ? evaluate(callee.prop, scope) : callee.prop;
1869
+ if (!_isSafeAccess(obj, prop)) return undefined;
1870
+ const fn = obj[prop];
1871
+ if (typeof fn !== 'function') return undefined;
1872
+ return fn.apply(obj, args);
1873
+ }
1874
+
1875
+ // Direct call: fn(args)
1876
+ const fn = evaluate(callee, scope);
1877
+ if (typeof fn !== 'function') return undefined;
1878
+ return fn(...args);
1879
+ }
1880
+
1881
+ /**
1882
+ * Evaluate binary expression.
1883
+ */
1884
+ function _evalBinary(node, scope) {
1885
+ // Short-circuit for logical ops
1886
+ if (node.op === '&&') {
1887
+ const left = evaluate(node.left, scope);
1888
+ return left ? evaluate(node.right, scope) : left;
1889
+ }
1890
+ if (node.op === '||') {
1891
+ const left = evaluate(node.left, scope);
1892
+ return left ? left : evaluate(node.right, scope);
1893
+ }
1894
+ if (node.op === '??') {
1895
+ const left = evaluate(node.left, scope);
1896
+ return left != null ? left : evaluate(node.right, scope);
1897
+ }
1898
+
1899
+ const left = evaluate(node.left, scope);
1900
+ const right = evaluate(node.right, scope);
1901
+
1902
+ switch (node.op) {
1903
+ case '+': return left + right;
1904
+ case '-': return left - right;
1905
+ case '*': return left * right;
1906
+ case '/': return left / right;
1907
+ case '%': return left % right;
1908
+ case '==': return left == right;
1909
+ case '!=': return left != right;
1910
+ case '===': return left === right;
1911
+ case '!==': return left !== right;
1912
+ case '<': return left < right;
1913
+ case '>': return left > right;
1914
+ case '<=': return left <= right;
1915
+ case '>=': return left >= right;
1916
+ case 'instanceof': return left instanceof right;
1917
+ case 'in': return left in right;
1918
+ default: return undefined;
1919
+ }
1920
+ }
1921
+
1922
+
1923
+ // ---------------------------------------------------------------------------
1924
+ // Public API
515
1925
  // ---------------------------------------------------------------------------
516
- function createFragment(html) {
517
- const tpl = document.createElement('template');
518
- tpl.innerHTML = html.trim();
519
- return tpl.content;
520
- }
521
1926
 
1927
+ /**
1928
+ * Safely evaluate a JS expression string against scope layers.
1929
+ *
1930
+ * @param {string} expr — expression string
1931
+ * @param {object[]} scope — array of scope objects, checked in order
1932
+ * Typical: [loopVars, state, { props, refs, $ }]
1933
+ * @returns {*} — evaluation result, or undefined on error
1934
+ */
1935
+ function safeEval(expr, scope) {
1936
+ try {
1937
+ const trimmed = expr.trim();
1938
+ if (!trimmed) return undefined;
1939
+ const tokens = tokenize(trimmed);
1940
+ const parser = new Parser(tokens, scope);
1941
+ const ast = parser.parse();
1942
+ return evaluate(ast, scope);
1943
+ } catch (err) {
1944
+ if (typeof console !== 'undefined' && console.debug) {
1945
+ console.debug(`[zQuery EXPR_EVAL] Failed to evaluate: "${expr}"`, err.message);
1946
+ }
1947
+ return undefined;
1948
+ }
1949
+ }
1950
+
1951
+ // --- src/diff.js -------------------------------------------------
1952
+ /**
1953
+ * zQuery Diff — Lightweight DOM morphing engine
1954
+ *
1955
+ * Patches an existing DOM tree to match new HTML without destroying nodes
1956
+ * that haven't changed. Preserves focus, scroll positions, third-party
1957
+ * widget state, video playback, and other live DOM state.
1958
+ *
1959
+ * Approach: walk old and new trees in parallel, reconcile node by node.
1960
+ * Keyed elements (via `z-key`) get matched across position changes.
1961
+ */
522
1962
 
523
1963
  // ---------------------------------------------------------------------------
524
- // $() — main selector / creator function (returns single element for CSS selectors)
1964
+ // morph(existingRoot, newHTML) — patch existing DOM to match newHTML
525
1965
  // ---------------------------------------------------------------------------
526
- function query(selector, context) {
527
- // null / undefined
528
- if (!selector) return null;
529
1966
 
530
- // Already a collection — return first element
531
- if (selector instanceof ZQueryCollection) return selector.first();
1967
+ /**
1968
+ * Morph an existing DOM element's children to match new HTML.
1969
+ * Only touches nodes that actually differ.
1970
+ *
1971
+ * @param {Element} rootEl — The live DOM container to patch
1972
+ * @param {string} newHTML — The desired HTML string
1973
+ */
1974
+ function morph(rootEl, newHTML) {
1975
+ const template = document.createElement('template');
1976
+ template.innerHTML = newHTML;
1977
+ const newRoot = template.content;
1978
+
1979
+ // Convert to element for consistent handling — wrap in a div if needed
1980
+ const tempDiv = document.createElement('div');
1981
+ while (newRoot.firstChild) tempDiv.appendChild(newRoot.firstChild);
532
1982
 
533
- // DOM element or Window — return as-is
534
- if (selector instanceof Node || selector === window) {
535
- return selector;
1983
+ _morphChildren(rootEl, tempDiv);
1984
+ }
1985
+
1986
+ /**
1987
+ * Reconcile children of `oldParent` to match `newParent`.
1988
+ *
1989
+ * @param {Element} oldParent — live DOM parent
1990
+ * @param {Element} newParent — desired state parent
1991
+ */
1992
+ function _morphChildren(oldParent, newParent) {
1993
+ const oldChildren = [...oldParent.childNodes];
1994
+ const newChildren = [...newParent.childNodes];
1995
+
1996
+ // Build key maps for keyed element matching
1997
+ const oldKeyMap = new Map();
1998
+ const newKeyMap = new Map();
1999
+
2000
+ for (let i = 0; i < oldChildren.length; i++) {
2001
+ const key = _getKey(oldChildren[i]);
2002
+ if (key != null) oldKeyMap.set(key, i);
2003
+ }
2004
+ for (let i = 0; i < newChildren.length; i++) {
2005
+ const key = _getKey(newChildren[i]);
2006
+ if (key != null) newKeyMap.set(key, i);
536
2007
  }
537
2008
 
538
- // NodeList / HTMLCollection / Array return first element
539
- if (selector instanceof NodeList || selector instanceof HTMLCollection || Array.isArray(selector)) {
540
- const arr = Array.from(selector);
541
- return arr[0] || null;
2009
+ const hasKeys = oldKeyMap.size > 0 || newKeyMap.size > 0;
2010
+
2011
+ if (hasKeys) {
2012
+ _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap);
2013
+ } else {
2014
+ _morphChildrenUnkeyed(oldParent, oldChildren, newChildren);
542
2015
  }
2016
+ }
543
2017
 
544
- // HTML string → create elements, return first
545
- if (typeof selector === 'string' && selector.trim().startsWith('<')) {
546
- const fragment = createFragment(selector);
547
- const els = [...fragment.childNodes].filter(n => n.nodeType === 1);
548
- return els[0] || null;
2018
+ /**
2019
+ * Unkeyed reconciliation positional matching.
2020
+ */
2021
+ function _morphChildrenUnkeyed(oldParent, oldChildren, newChildren) {
2022
+ const maxLen = Math.max(oldChildren.length, newChildren.length);
2023
+
2024
+ for (let i = 0; i < maxLen; i++) {
2025
+ const oldNode = oldChildren[i];
2026
+ const newNode = newChildren[i];
2027
+
2028
+ if (!oldNode && newNode) {
2029
+ // New node — append
2030
+ oldParent.appendChild(newNode.cloneNode(true));
2031
+ } else if (oldNode && !newNode) {
2032
+ // Extra old node — remove
2033
+ oldParent.removeChild(oldNode);
2034
+ } else if (oldNode && newNode) {
2035
+ _morphNode(oldParent, oldNode, newNode);
2036
+ }
549
2037
  }
2038
+ }
550
2039
 
551
- // CSS selector string → querySelector (single element)
552
- if (typeof selector === 'string') {
553
- const root = context
554
- ? (typeof context === 'string' ? document.querySelector(context) : context)
555
- : document;
556
- return root.querySelector(selector);
2040
+ /**
2041
+ * Keyed reconciliation match by z-key, reorder minimal moves.
2042
+ */
2043
+ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap) {
2044
+ // Track which old nodes are consumed
2045
+ const consumed = new Set();
2046
+
2047
+ // Step 1: Build ordered list of matched old nodes for new children
2048
+ const newLen = newChildren.length;
2049
+ const matched = new Array(newLen); // matched[newIdx] = oldNode | null
2050
+
2051
+ for (let i = 0; i < newLen; i++) {
2052
+ const key = _getKey(newChildren[i]);
2053
+ if (key != null && oldKeyMap.has(key)) {
2054
+ const oldIdx = oldKeyMap.get(key);
2055
+ matched[i] = oldChildren[oldIdx];
2056
+ consumed.add(oldIdx);
2057
+ } else {
2058
+ matched[i] = null;
2059
+ }
557
2060
  }
558
2061
 
559
- return null;
560
- }
2062
+ // Step 2: Remove old nodes that are not in the new tree
2063
+ for (let i = oldChildren.length - 1; i >= 0; i--) {
2064
+ if (!consumed.has(i)) {
2065
+ const key = _getKey(oldChildren[i]);
2066
+ if (key != null && !newKeyMap.has(key)) {
2067
+ oldParent.removeChild(oldChildren[i]);
2068
+ } else if (key == null) {
2069
+ // Unkeyed old node — will be handled positionally below
2070
+ }
2071
+ }
2072
+ }
561
2073
 
2074
+ // Step 3: Insert/reorder/morph
2075
+ let cursor = oldParent.firstChild;
2076
+ const unkeyedOld = oldChildren.filter((n, i) => !consumed.has(i) && _getKey(n) == null);
2077
+ let unkeyedIdx = 0;
562
2078
 
563
- // ---------------------------------------------------------------------------
564
- // $.all() collection selector (returns ZQueryCollection for CSS selectors)
565
- // ---------------------------------------------------------------------------
566
- function queryAll(selector, context) {
567
- // null / undefined
568
- if (!selector) return new ZQueryCollection([]);
2079
+ for (let i = 0; i < newLen; i++) {
2080
+ const newNode = newChildren[i];
2081
+ const newKey = _getKey(newNode);
2082
+ let oldNode = matched[i];
569
2083
 
570
- // Already a collection
571
- if (selector instanceof ZQueryCollection) return selector;
2084
+ if (!oldNode && newKey == null) {
2085
+ // Try to match an unkeyed old node positionally
2086
+ oldNode = unkeyedOld[unkeyedIdx++] || null;
2087
+ }
572
2088
 
573
- // DOM element or Window
574
- if (selector instanceof Node || selector === window) {
575
- return new ZQueryCollection([selector]);
2089
+ if (oldNode) {
2090
+ // Move into position if needed
2091
+ if (oldNode !== cursor) {
2092
+ oldParent.insertBefore(oldNode, cursor);
2093
+ }
2094
+ // Morph in place
2095
+ _morphNode(oldParent, oldNode, newNode);
2096
+ cursor = oldNode.nextSibling;
2097
+ } else {
2098
+ // Insert new node
2099
+ const clone = newNode.cloneNode(true);
2100
+ if (cursor) {
2101
+ oldParent.insertBefore(clone, cursor);
2102
+ } else {
2103
+ oldParent.appendChild(clone);
2104
+ }
2105
+ // cursor stays the same — new node is before it
2106
+ }
576
2107
  }
577
2108
 
578
- // NodeList / HTMLCollection / Array
579
- if (selector instanceof NodeList || selector instanceof HTMLCollection || Array.isArray(selector)) {
580
- return new ZQueryCollection(Array.from(selector));
2109
+ // Remove any remaining unkeyed old nodes at the end
2110
+ while (unkeyedIdx < unkeyedOld.length) {
2111
+ const leftover = unkeyedOld[unkeyedIdx++];
2112
+ if (leftover.parentNode === oldParent) {
2113
+ oldParent.removeChild(leftover);
2114
+ }
581
2115
  }
582
2116
 
583
- // HTML string create elements
584
- if (typeof selector === 'string' && selector.trim().startsWith('<')) {
585
- const fragment = createFragment(selector);
586
- return new ZQueryCollection([...fragment.childNodes].filter(n => n.nodeType === 1));
2117
+ // Remove any remaining keyed old nodes that weren't consumed
2118
+ for (let i = 0; i < oldChildren.length; i++) {
2119
+ if (!consumed.has(i)) {
2120
+ const node = oldChildren[i];
2121
+ if (node.parentNode === oldParent && _getKey(node) != null && !newKeyMap.has(_getKey(node))) {
2122
+ oldParent.removeChild(node);
2123
+ }
2124
+ }
587
2125
  }
2126
+ }
588
2127
 
589
- // CSS selector string → querySelectorAll (collection)
590
- if (typeof selector === 'string') {
591
- const root = context
592
- ? (typeof context === 'string' ? document.querySelector(context) : context)
593
- : document;
594
- return new ZQueryCollection([...root.querySelectorAll(selector)]);
2128
+ /**
2129
+ * Morph a single node in place.
2130
+ */
2131
+ function _morphNode(parent, oldNode, newNode) {
2132
+ // Text / comment nodes — just update content
2133
+ if (oldNode.nodeType === 3 || oldNode.nodeType === 8) {
2134
+ if (newNode.nodeType === oldNode.nodeType) {
2135
+ if (oldNode.nodeValue !== newNode.nodeValue) {
2136
+ oldNode.nodeValue = newNode.nodeValue;
2137
+ }
2138
+ return;
2139
+ }
2140
+ // Different node types — replace
2141
+ parent.replaceChild(newNode.cloneNode(true), oldNode);
2142
+ return;
595
2143
  }
596
2144
 
597
- return new ZQueryCollection([]);
598
- }
2145
+ // Different node types or tag names — replace entirely
2146
+ if (oldNode.nodeType !== newNode.nodeType ||
2147
+ oldNode.nodeName !== newNode.nodeName) {
2148
+ parent.replaceChild(newNode.cloneNode(true), oldNode);
2149
+ return;
2150
+ }
599
2151
 
2152
+ // Both are elements — diff attributes then recurse children
2153
+ if (oldNode.nodeType === 1) {
2154
+ _morphAttributes(oldNode, newNode);
600
2155
 
601
- // ---------------------------------------------------------------------------
602
- // Quick-ref shortcuts, on $ namespace)
603
- // ---------------------------------------------------------------------------
604
- query.id = (id) => document.getElementById(id);
605
- query.class = (name) => document.querySelector(`.${name}`);
606
- query.classes = (name) => Array.from(document.getElementsByClassName(name));
607
- query.tag = (name) => Array.from(document.getElementsByTagName(name));
608
- Object.defineProperty(query, 'name', {
609
- value: (name) => Array.from(document.getElementsByName(name)),
610
- writable: true, configurable: true
611
- });
612
- query.attr = (attr, value) => Array.from(
613
- document.querySelectorAll(value !== undefined ? `[${attr}="${value}"]` : `[${attr}]`)
614
- );
615
- query.data = (key, value) => Array.from(
616
- document.querySelectorAll(value !== undefined ? `[data-${key}="${value}"]` : `[data-${key}]`)
617
- );
618
- query.children = (parentId) => {
619
- const p = document.getElementById(parentId);
620
- return p ? Array.from(p.children) : [];
621
- };
2156
+ // Special elements: don't recurse into their children
2157
+ // (textarea value, input value, select, etc.)
2158
+ const tag = oldNode.nodeName;
2159
+ if (tag === 'INPUT') {
2160
+ _syncInputValue(oldNode, newNode);
2161
+ return;
2162
+ }
2163
+ if (tag === 'TEXTAREA') {
2164
+ if (oldNode.value !== newNode.textContent) {
2165
+ oldNode.value = newNode.textContent || '';
2166
+ }
2167
+ return;
2168
+ }
2169
+ if (tag === 'SELECT') {
2170
+ // Recurse children (options) then sync value
2171
+ _morphChildren(oldNode, newNode);
2172
+ if (oldNode.value !== newNode.value) {
2173
+ oldNode.value = newNode.value;
2174
+ }
2175
+ return;
2176
+ }
622
2177
 
623
- // Create element shorthand
624
- query.create = (tag, attrs = {}, ...children) => {
625
- const el = document.createElement(tag);
626
- for (const [k, v] of Object.entries(attrs)) {
627
- if (k === 'class') el.className = v;
628
- else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
629
- else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v);
630
- else if (k === 'data' && typeof v === 'object') Object.entries(v).forEach(([dk, dv]) => { el.dataset[dk] = dv; });
631
- else el.setAttribute(k, v);
2178
+ // Generic element — recurse children
2179
+ _morphChildren(oldNode, newNode);
632
2180
  }
633
- children.flat().forEach(child => {
634
- if (typeof child === 'string') el.appendChild(document.createTextNode(child));
635
- else if (child instanceof Node) el.appendChild(child);
636
- });
637
- return el;
638
- };
2181
+ }
639
2182
 
640
- // DOM ready
641
- query.ready = (fn) => {
642
- if (document.readyState !== 'loading') fn();
643
- else document.addEventListener('DOMContentLoaded', fn);
644
- };
2183
+ /**
2184
+ * Sync attributes from newEl onto oldEl.
2185
+ */
2186
+ function _morphAttributes(oldEl, newEl) {
2187
+ // Add/update attributes
2188
+ const newAttrs = newEl.attributes;
2189
+ for (let i = 0; i < newAttrs.length; i++) {
2190
+ const attr = newAttrs[i];
2191
+ if (oldEl.getAttribute(attr.name) !== attr.value) {
2192
+ oldEl.setAttribute(attr.name, attr.value);
2193
+ }
2194
+ }
645
2195
 
646
- // Global event listeners — supports direct and delegated forms
647
- // $.on('keydown', handler) → direct listener on document
648
- // $.on('click', '.btn', handler) → delegated via closest()
649
- query.on = (event, selectorOrHandler, handler) => {
650
- if (typeof selectorOrHandler === 'function') {
651
- // 2-arg: direct document listener (keydown, resize, etc.)
652
- document.addEventListener(event, selectorOrHandler);
653
- return;
2196
+ // Remove stale attributes
2197
+ const oldAttrs = oldEl.attributes;
2198
+ for (let i = oldAttrs.length - 1; i >= 0; i--) {
2199
+ const attr = oldAttrs[i];
2200
+ if (!newEl.hasAttribute(attr.name)) {
2201
+ oldEl.removeAttribute(attr.name);
2202
+ }
654
2203
  }
655
- // 3-arg: delegated
656
- document.addEventListener(event, (e) => {
657
- const target = e.target.closest(selectorOrHandler);
658
- if (target) handler.call(target, e);
659
- });
660
- };
2204
+ }
661
2205
 
662
- // Remove a direct global listener
663
- query.off = (event, handler) => {
664
- document.removeEventListener(event, handler);
665
- };
2206
+ /**
2207
+ * Sync input element value, checked, disabled states.
2208
+ */
2209
+ function _syncInputValue(oldEl, newEl) {
2210
+ const type = (oldEl.type || '').toLowerCase();
666
2211
 
667
- // Extend collection prototype (like $.fn in jQuery)
668
- query.fn = ZQueryCollection.prototype;
2212
+ if (type === 'checkbox' || type === 'radio') {
2213
+ if (oldEl.checked !== newEl.checked) oldEl.checked = newEl.checked;
2214
+ } else {
2215
+ if (oldEl.value !== (newEl.getAttribute('value') || '')) {
2216
+ oldEl.value = newEl.getAttribute('value') || '';
2217
+ }
2218
+ }
2219
+
2220
+ // Sync disabled
2221
+ if (oldEl.disabled !== newEl.disabled) oldEl.disabled = newEl.disabled;
2222
+ }
2223
+
2224
+ /**
2225
+ * Get the reconciliation key from a node (z-key attribute).
2226
+ * @returns {string|null}
2227
+ */
2228
+ function _getKey(node) {
2229
+ if (node.nodeType !== 1) return null;
2230
+ return node.getAttribute('z-key') || null;
2231
+ }
669
2232
 
670
- // --- src/component.js ————————————————————————————————————————————
2233
+ // --- src/component.js --------------------------------------------
671
2234
  /**
672
2235
  * zQuery Component — Lightweight reactive component system
673
2236
  *
@@ -690,6 +2253,9 @@ query.fn = ZQueryCollection.prototype;
690
2253
  */
691
2254
 
692
2255
 
2256
+
2257
+
2258
+
693
2259
  // ---------------------------------------------------------------------------
694
2260
  // Component registry & external resource cache
695
2261
  // ---------------------------------------------------------------------------
@@ -864,10 +2430,27 @@ class Component {
864
2430
  this._destroyed = false;
865
2431
  this._updateQueued = false;
866
2432
  this._listeners = [];
2433
+ this._watchCleanups = [];
867
2434
 
868
2435
  // Refs map
869
2436
  this.refs = {};
870
2437
 
2438
+ // Capture slot content before first render replaces it
2439
+ this._slotContent = {};
2440
+ const defaultSlotNodes = [];
2441
+ [...el.childNodes].forEach(node => {
2442
+ if (node.nodeType === 1 && node.hasAttribute('slot')) {
2443
+ const slotName = node.getAttribute('slot');
2444
+ if (!this._slotContent[slotName]) this._slotContent[slotName] = '';
2445
+ this._slotContent[slotName] += node.outerHTML;
2446
+ } else if (node.nodeType === 1 || (node.nodeType === 3 && node.textContent.trim())) {
2447
+ defaultSlotNodes.push(node.nodeType === 1 ? node.outerHTML : node.textContent);
2448
+ }
2449
+ });
2450
+ if (defaultSlotNodes.length) {
2451
+ this._slotContent['default'] = defaultSlotNodes.join('');
2452
+ }
2453
+
871
2454
  // Props (read-only from parent)
872
2455
  this.props = Object.freeze({ ...props });
873
2456
 
@@ -876,10 +2459,25 @@ class Component {
876
2459
  ? definition.state()
877
2460
  : { ...(definition.state || {}) };
878
2461
 
879
- this.state = reactive(initialState, () => {
880
- if (!this._destroyed) this._scheduleUpdate();
2462
+ this.state = reactive(initialState, (key, value, old) => {
2463
+ if (!this._destroyed) {
2464
+ // Run watchers for the changed key
2465
+ this._runWatchers(key, value, old);
2466
+ this._scheduleUpdate();
2467
+ }
881
2468
  });
882
2469
 
2470
+ // Computed properties — lazy getters derived from state
2471
+ this.computed = {};
2472
+ if (definition.computed) {
2473
+ for (const [name, fn] of Object.entries(definition.computed)) {
2474
+ Object.defineProperty(this.computed, name, {
2475
+ get: () => fn.call(this, this.state.__raw || this.state),
2476
+ enumerable: true
2477
+ });
2478
+ }
2479
+ }
2480
+
883
2481
  // Bind all user methods to this instance
884
2482
  for (const [key, val] of Object.entries(definition)) {
885
2483
  if (typeof val === 'function' && !_reservedKeys.has(key)) {
@@ -888,7 +2486,36 @@ class Component {
888
2486
  }
889
2487
 
890
2488
  // Init lifecycle
891
- if (definition.init) definition.init.call(this);
2489
+ if (definition.init) {
2490
+ try { definition.init.call(this); }
2491
+ catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${definition._name}" init() threw`, { component: definition._name }, err); }
2492
+ }
2493
+
2494
+ // Set up watchers after init so initial state is ready
2495
+ if (definition.watch) {
2496
+ this._prevWatchValues = {};
2497
+ for (const key of Object.keys(definition.watch)) {
2498
+ this._prevWatchValues[key] = _getPath(this.state.__raw || this.state, key);
2499
+ }
2500
+ }
2501
+ }
2502
+
2503
+ // Run registered watchers for a changed key
2504
+ _runWatchers(changedKey, value, old) {
2505
+ const watchers = this._def.watch;
2506
+ if (!watchers) return;
2507
+ for (const [key, handler] of Object.entries(watchers)) {
2508
+ // Match exact key or parent key (e.g. watcher on 'user' fires when 'user.name' changes)
2509
+ if (changedKey === key || key.startsWith(changedKey + '.') || changedKey.startsWith(key + '.') || changedKey === key) {
2510
+ const currentVal = _getPath(this.state.__raw || this.state, key);
2511
+ const prevVal = this._prevWatchValues?.[key];
2512
+ if (currentVal !== prevVal) {
2513
+ const fn = typeof handler === 'function' ? handler : handler.handler;
2514
+ if (typeof fn === 'function') fn.call(this, currentVal, prevVal);
2515
+ if (this._prevWatchValues) this._prevWatchValues[key] = currentVal;
2516
+ }
2517
+ }
2518
+ }
892
2519
  }
893
2520
 
894
2521
  // Schedule a batched DOM update (microtask)
@@ -986,11 +2613,14 @@ class Component {
986
2613
  if (def.styleUrl && !def._styleLoaded) {
987
2614
  const su = def.styleUrl;
988
2615
  if (typeof su === 'string') {
989
- def._externalStyles = await _fetchResource(_resolveUrl(su, base));
2616
+ const resolved = _resolveUrl(su, base);
2617
+ def._externalStyles = await _fetchResource(resolved);
2618
+ def._resolvedStyleUrls = [resolved];
990
2619
  } else if (Array.isArray(su)) {
991
2620
  const urls = su.map(u => _resolveUrl(u, base));
992
2621
  const results = await Promise.all(urls.map(u => _fetchResource(u)));
993
2622
  def._externalStyles = results.join('\n');
2623
+ def._resolvedStyleUrls = urls;
994
2624
  }
995
2625
  def._styleLoaded = true;
996
2626
  }
@@ -1063,17 +2693,29 @@ class Component {
1063
2693
  // Then do global {{expression}} interpolation on the remaining content
1064
2694
  html = html.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
1065
2695
  try {
1066
- return new Function('state', 'props', '$', `with(state){return ${expr.trim()}}`)(
2696
+ const result = safeEval(expr.trim(), [
1067
2697
  this.state.__raw || this.state,
1068
- this.props,
1069
- typeof window !== 'undefined' ? window.$ : undefined
1070
- );
2698
+ { props: this.props, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
2699
+ ]);
2700
+ return result != null ? result : '';
1071
2701
  } catch { return ''; }
1072
2702
  });
1073
2703
  } else {
1074
2704
  html = '';
1075
2705
  }
1076
2706
 
2707
+ // -- Slot distribution ----------------------------------------
2708
+ // Replace <slot> elements with captured slot content from parent.
2709
+ // <slot> → default slot content
2710
+ // <slot name="header"> → named slot content
2711
+ // Fallback content between <slot>...</slot> used when no content provided.
2712
+ if (html.includes('<slot')) {
2713
+ html = html.replace(/<slot(?:\s+name="([^"]*)")?\s*(?:\/>|>([\s\S]*?)<\/slot>)/g, (_, name, fallback) => {
2714
+ const slotName = name || 'default';
2715
+ return this._slotContent[slotName] || fallback || '';
2716
+ });
2717
+ }
2718
+
1077
2719
  // Combine inline styles + external styles
1078
2720
  const combinedStyles = [
1079
2721
  this._def.styles || '',
@@ -1084,33 +2726,52 @@ class Component {
1084
2726
  if (!this._mounted && combinedStyles) {
1085
2727
  const scopeAttr = `z-s${this._uid}`;
1086
2728
  this._el.setAttribute(scopeAttr, '');
1087
- const scoped = combinedStyles.replace(/([^{}]+)\{/g, (match, selector) => {
2729
+ let inAtBlock = 0;
2730
+ const scoped = combinedStyles.replace(/([^{}]+)\{|\}/g, (match, selector) => {
2731
+ if (match === '}') {
2732
+ if (inAtBlock > 0) inAtBlock--;
2733
+ return match;
2734
+ }
2735
+ const trimmed = selector.trim();
2736
+ // Don't scope @-rules (@media, @keyframes, @supports, @container, @layer, @font-face, etc.)
2737
+ if (trimmed.startsWith('@')) {
2738
+ inAtBlock++;
2739
+ return match;
2740
+ }
2741
+ // Don't scope keyframe stops (from, to, 0%, 50%, etc.)
2742
+ if (inAtBlock > 0 && /^[\d%\s,fromto]+$/.test(trimmed.replace(/\s/g, ''))) {
2743
+ return match;
2744
+ }
1088
2745
  return selector.split(',').map(s => `[${scopeAttr}] ${s.trim()}`).join(', ') + ' {';
1089
2746
  });
1090
2747
  const styleEl = document.createElement('style');
1091
2748
  styleEl.textContent = scoped;
1092
2749
  styleEl.setAttribute('data-zq-component', this._def._name || '');
2750
+ styleEl.setAttribute('data-zq-scope', scopeAttr);
2751
+ if (this._def._resolvedStyleUrls) {
2752
+ styleEl.setAttribute('data-zq-style-urls', this._def._resolvedStyleUrls.join(' '));
2753
+ if (this._def.styles) {
2754
+ styleEl.setAttribute('data-zq-inline', this._def.styles);
2755
+ }
2756
+ }
1093
2757
  document.head.appendChild(styleEl);
1094
2758
  this._styleEl = styleEl;
1095
2759
  }
1096
2760
 
1097
2761
  // -- Focus preservation ----------------------------------------
1098
- // Before replacing innerHTML, save focus state so we can restore
1099
- // cursor position after the DOM is rebuilt. Works for any focused
1100
- // input/textarea/select inside the component, not only z-model.
2762
+ // DOM morphing preserves unchanged nodes naturally, but we still
2763
+ // track focus for cases where the focused element's subtree changes.
1101
2764
  let _focusInfo = null;
1102
2765
  const _active = document.activeElement;
1103
2766
  if (_active && this._el.contains(_active)) {
1104
2767
  const modelKey = _active.getAttribute?.('z-model');
1105
2768
  const refKey = _active.getAttribute?.('z-ref');
1106
- // Build a selector that can locate the same element after re-render
1107
2769
  let selector = null;
1108
2770
  if (modelKey) {
1109
2771
  selector = `[z-model="${modelKey}"]`;
1110
2772
  } else if (refKey) {
1111
2773
  selector = `[z-ref="${refKey}"]`;
1112
2774
  } else {
1113
- // Fallback: match by tag + type + name + placeholder combination
1114
2775
  const tag = _active.tagName.toLowerCase();
1115
2776
  if (tag === 'input' || tag === 'textarea' || tag === 'select') {
1116
2777
  let s = tag;
@@ -1130,8 +2791,13 @@ class Component {
1130
2791
  }
1131
2792
  }
1132
2793
 
1133
- // Update DOM
1134
- this._el.innerHTML = html;
2794
+ // Update DOM via morphing (diffing) — preserves unchanged nodes
2795
+ // First render uses innerHTML for speed; subsequent renders morph.
2796
+ if (!this._mounted) {
2797
+ this._el.innerHTML = html;
2798
+ } else {
2799
+ morph(this._el, html);
2800
+ }
1135
2801
 
1136
2802
  // Process structural & attribute directives
1137
2803
  this._processDirectives();
@@ -1141,10 +2807,10 @@ class Component {
1141
2807
  this._bindRefs();
1142
2808
  this._bindModels();
1143
2809
 
1144
- // Restore focus after re-render
2810
+ // Restore focus if the morph replaced the focused element
1145
2811
  if (_focusInfo) {
1146
2812
  const el = this._el.querySelector(_focusInfo.selector);
1147
- if (el) {
2813
+ if (el && el !== document.activeElement) {
1148
2814
  el.focus();
1149
2815
  try {
1150
2816
  if (_focusInfo.start !== null && _focusInfo.start !== undefined) {
@@ -1159,9 +2825,15 @@ class Component {
1159
2825
 
1160
2826
  if (!this._mounted) {
1161
2827
  this._mounted = true;
1162
- if (this._def.mounted) this._def.mounted.call(this);
2828
+ if (this._def.mounted) {
2829
+ try { this._def.mounted.call(this); }
2830
+ catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${this._def._name}" mounted() threw`, { component: this._def._name }, err); }
2831
+ }
1163
2832
  } else {
1164
- if (this._def.updated) this._def.updated.call(this);
2833
+ if (this._def.updated) {
2834
+ try { this._def.updated.call(this); }
2835
+ catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${this._def._name}" updated() threw`, { component: this._def._name }, err); }
2836
+ }
1165
2837
  }
1166
2838
  }
1167
2839
 
@@ -1370,18 +3042,13 @@ class Component {
1370
3042
  }
1371
3043
 
1372
3044
  // ---------------------------------------------------------------------------
1373
- // Expression evaluator — runs expr in component context (state, props, refs)
3045
+ // Expression evaluator — CSP-safe parser (no eval / new Function)
1374
3046
  // ---------------------------------------------------------------------------
1375
3047
  _evalExpr(expr) {
1376
- try {
1377
- return new Function('state', 'props', 'refs', '$',
1378
- `with(state){return (${expr})}`)(
1379
- this.state.__raw || this.state,
1380
- this.props,
1381
- this.refs,
1382
- typeof window !== 'undefined' ? window.$ : undefined
1383
- );
1384
- } catch { return undefined; }
3048
+ return safeEval(expr, [
3049
+ this.state.__raw || this.state,
3050
+ { props: this.props, refs: this.refs, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
3051
+ ]);
1385
3052
  }
1386
3053
 
1387
3054
  // ---------------------------------------------------------------------------
@@ -1443,13 +3110,15 @@ class Component {
1443
3110
  const evalReplace = (str, item, index) =>
1444
3111
  str.replace(/\{\{(.+?)\}\}/g, (_, inner) => {
1445
3112
  try {
1446
- return new Function(itemVar, indexVar, 'state', 'props', '$',
1447
- `with(state){return (${inner.trim()})}`)(
1448
- item, index,
3113
+ const loopScope = {};
3114
+ loopScope[itemVar] = item;
3115
+ loopScope[indexVar] = index;
3116
+ const result = safeEval(inner.trim(), [
3117
+ loopScope,
1449
3118
  this.state.__raw || this.state,
1450
- this.props,
1451
- typeof window !== 'undefined' ? window.$ : undefined
1452
- );
3119
+ { props: this.props, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
3120
+ ]);
3121
+ return result != null ? result : '';
1453
3122
  } catch { return ''; }
1454
3123
  });
1455
3124
 
@@ -1614,7 +3283,10 @@ class Component {
1614
3283
  destroy() {
1615
3284
  if (this._destroyed) return;
1616
3285
  this._destroyed = true;
1617
- if (this._def.destroyed) this._def.destroyed.call(this);
3286
+ if (this._def.destroyed) {
3287
+ try { this._def.destroyed.call(this); }
3288
+ catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${this._def._name}" destroyed() threw`, { component: this._def._name }, err); }
3289
+ }
1618
3290
  this._listeners.forEach(({ event, handler }) => this._el.removeEventListener(event, handler));
1619
3291
  this._listeners = [];
1620
3292
  if (this._styleEl) this._styleEl.remove();
@@ -1627,7 +3299,8 @@ class Component {
1627
3299
  // Reserved definition keys (not user methods)
1628
3300
  const _reservedKeys = new Set([
1629
3301
  'state', 'render', 'styles', 'init', 'mounted', 'updated', 'destroyed', 'props',
1630
- 'templateUrl', 'styleUrl', 'templates', 'pages', 'activePage', 'base'
3302
+ 'templateUrl', 'styleUrl', 'templates', 'pages', 'activePage', 'base',
3303
+ 'computed', 'watch'
1631
3304
  ]);
1632
3305
 
1633
3306
 
@@ -1641,8 +3314,11 @@ const _reservedKeys = new Set([
1641
3314
  * @param {object} definition — component definition
1642
3315
  */
1643
3316
  function component(name, definition) {
3317
+ if (!name || typeof name !== 'string') {
3318
+ throw new ZQueryError(ErrorCode.COMP_INVALID_NAME, 'Component name must be a non-empty string');
3319
+ }
1644
3320
  if (!name.includes('-')) {
1645
- throw new Error(`zQuery: Component name "${name}" must contain a hyphen (Web Component convention)`);
3321
+ throw new ZQueryError(ErrorCode.COMP_INVALID_NAME, `Component name "${name}" must contain a hyphen (Web Component convention)`);
1646
3322
  }
1647
3323
  definition._name = name;
1648
3324
 
@@ -1667,10 +3343,10 @@ function component(name, definition) {
1667
3343
  */
1668
3344
  function mount(target, componentName, props = {}) {
1669
3345
  const el = typeof target === 'string' ? document.querySelector(target) : target;
1670
- if (!el) throw new Error(`zQuery: Mount target "${target}" not found`);
3346
+ if (!el) throw new ZQueryError(ErrorCode.COMP_MOUNT_TARGET, `Mount target "${target}" not found`, { target });
1671
3347
 
1672
3348
  const def = _registry.get(componentName);
1673
- if (!def) throw new Error(`zQuery: Component "${componentName}" not registered`);
3349
+ if (!def) throw new ZQueryError(ErrorCode.COMP_NOT_FOUND, `Component "${componentName}" not registered`, { component: componentName });
1674
3350
 
1675
3351
  // Destroy existing instance
1676
3352
  if (_instances.has(el)) _instances.get(el).destroy();
@@ -1693,12 +3369,40 @@ function mountAll(root = document.body) {
1693
3369
 
1694
3370
  // Extract props from attributes
1695
3371
  const props = {};
3372
+
3373
+ // Find parent component instance for evaluating dynamic prop expressions
3374
+ let parentInstance = null;
3375
+ let ancestor = tag.parentElement;
3376
+ while (ancestor) {
3377
+ if (_instances.has(ancestor)) {
3378
+ parentInstance = _instances.get(ancestor);
3379
+ break;
3380
+ }
3381
+ ancestor = ancestor.parentElement;
3382
+ }
3383
+
1696
3384
  [...tag.attributes].forEach(attr => {
1697
- if (!attr.name.startsWith('@') && !attr.name.startsWith('z-')) {
1698
- // Try JSON parse for objects/arrays
1699
- try { props[attr.name] = JSON.parse(attr.value); }
1700
- catch { props[attr.name] = attr.value; }
3385
+ if (attr.name.startsWith('@') || attr.name.startsWith('z-')) return;
3386
+
3387
+ // Dynamic prop: :propName="expression" evaluate in parent context
3388
+ if (attr.name.startsWith(':')) {
3389
+ const propName = attr.name.slice(1);
3390
+ if (parentInstance) {
3391
+ props[propName] = safeEval(attr.value, [
3392
+ parentInstance.state.__raw || parentInstance.state,
3393
+ { props: parentInstance.props, refs: parentInstance.refs, computed: parentInstance.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
3394
+ ]);
3395
+ } else {
3396
+ // No parent — try JSON parse
3397
+ try { props[propName] = JSON.parse(attr.value); }
3398
+ catch { props[propName] = attr.value; }
3399
+ }
3400
+ return;
1701
3401
  }
3402
+
3403
+ // Static prop
3404
+ try { props[attr.name] = JSON.parse(attr.value); }
3405
+ catch { props[attr.name] = attr.value; }
1702
3406
  });
1703
3407
 
1704
3408
  const instance = new Component(tag, def, props);
@@ -1836,7 +3540,7 @@ function style(urls, opts = {}) {
1836
3540
  };
1837
3541
  }
1838
3542
 
1839
- // --- src/router.js ———————————————————————————————————————————————
3543
+ // --- src/router.js -----------------------------------------------
1840
3544
  /**
1841
3545
  * zQuery Router — Client-side SPA router
1842
3546
  *
@@ -1857,6 +3561,7 @@ function style(urls, opts = {}) {
1857
3561
  */
1858
3562
 
1859
3563
 
3564
+
1860
3565
  class Router {
1861
3566
  constructor(config = {}) {
1862
3567
  this._el = null;
@@ -1915,7 +3620,21 @@ class Router {
1915
3620
  if (!link) return;
1916
3621
  if (link.getAttribute('target') === '_blank') return;
1917
3622
  e.preventDefault();
1918
- this.navigate(link.getAttribute('z-link'));
3623
+ let href = link.getAttribute('z-link');
3624
+ // Support z-link-params for dynamic :param interpolation
3625
+ const paramsAttr = link.getAttribute('z-link-params');
3626
+ if (paramsAttr) {
3627
+ try {
3628
+ const params = JSON.parse(paramsAttr);
3629
+ href = this._interpolateParams(href, params);
3630
+ } catch { /* ignore malformed JSON */ }
3631
+ }
3632
+ this.navigate(href);
3633
+ // z-to-top modifier: scroll to top after navigation
3634
+ if (link.hasAttribute('z-to-top')) {
3635
+ const scrollBehavior = link.getAttribute('z-to-top') || 'instant';
3636
+ window.scrollTo({ top: 0, behavior: scrollBehavior });
3637
+ }
1919
3638
  });
1920
3639
 
1921
3640
  // Initial resolve
@@ -1959,7 +3678,23 @@ class Router {
1959
3678
 
1960
3679
  // --- Navigation ----------------------------------------------------------
1961
3680
 
3681
+ /**
3682
+ * Interpolate :param placeholders in a path with the given values.
3683
+ * @param {string} path — e.g. '/user/:id/posts/:pid'
3684
+ * @param {Object} params — e.g. { id: 42, pid: 7 }
3685
+ * @returns {string}
3686
+ */
3687
+ _interpolateParams(path, params) {
3688
+ if (!params || typeof params !== 'object') return path;
3689
+ return path.replace(/:([\w]+)/g, (_, key) => {
3690
+ const val = params[key];
3691
+ return val != null ? encodeURIComponent(String(val)) : ':' + key;
3692
+ });
3693
+ }
3694
+
1962
3695
  navigate(path, options = {}) {
3696
+ // Interpolate :param placeholders if options.params is provided
3697
+ if (options.params) path = this._interpolateParams(path, options.params);
1963
3698
  // Separate hash fragment (e.g. /docs/getting-started#cli-bundler)
1964
3699
  const [cleanPath, fragment] = (path || '').split('#');
1965
3700
  let normalized = this._normalizePath(cleanPath);
@@ -1977,6 +3712,8 @@ class Router {
1977
3712
  }
1978
3713
 
1979
3714
  replace(path, options = {}) {
3715
+ // Interpolate :param placeholders if options.params is provided
3716
+ if (options.params) path = this._interpolateParams(path, options.params);
1980
3717
  const [cleanPath, fragment] = (path || '').split('#');
1981
3718
  let normalized = this._normalizePath(cleanPath);
1982
3719
  const hash = fragment ? '#' + fragment : '';
@@ -2090,6 +3827,7 @@ class Router {
2090
3827
  // Prevent re-entrant calls (e.g. listener triggering navigation)
2091
3828
  if (this._resolving) return;
2092
3829
  this._resolving = true;
3830
+ this._redirectCount = 0;
2093
3831
  try {
2094
3832
  await this.__resolve();
2095
3833
  } finally {
@@ -2127,10 +3865,29 @@ class Router {
2127
3865
 
2128
3866
  // Run before guards
2129
3867
  for (const guard of this._guards.before) {
2130
- const result = await guard(to, from);
2131
- if (result === false) return; // Cancel
2132
- if (typeof result === 'string') { // Redirect
2133
- return this.navigate(result);
3868
+ try {
3869
+ const result = await guard(to, from);
3870
+ if (result === false) return; // Cancel
3871
+ if (typeof result === 'string') { // Redirect
3872
+ if (++this._redirectCount > 10) {
3873
+ reportError(ErrorCode.ROUTER_GUARD, 'Too many guard redirects (possible loop)', { to }, null);
3874
+ return;
3875
+ }
3876
+ // Update URL directly and re-resolve (avoids re-entrancy block)
3877
+ const [rPath, rFrag] = result.split('#');
3878
+ const rNorm = this._normalizePath(rPath || '/');
3879
+ const rHash = rFrag ? '#' + rFrag : '';
3880
+ if (this._mode === 'hash') {
3881
+ if (rFrag) window.__zqScrollTarget = rFrag;
3882
+ window.location.replace('#' + rNorm);
3883
+ } else {
3884
+ window.history.replaceState({}, '', this._base + rNorm + rHash);
3885
+ }
3886
+ return this.__resolve();
3887
+ }
3888
+ } catch (err) {
3889
+ reportError(ErrorCode.ROUTER_GUARD, 'Before-guard threw', { to, from }, err);
3890
+ return;
2134
3891
  }
2135
3892
  }
2136
3893
 
@@ -2138,7 +3895,7 @@ class Router {
2138
3895
  if (matched.load) {
2139
3896
  try { await matched.load(); }
2140
3897
  catch (err) {
2141
- console.error(`zQuery Router: Failed to load module for "${matched.path}"`, err);
3898
+ reportError(ErrorCode.ROUTER_LOAD, `Failed to load module for route "${matched.path}"`, { path: matched.path }, err);
2142
3899
  return;
2143
3900
  }
2144
3901
  }
@@ -2205,7 +3962,7 @@ function getRouter() {
2205
3962
  return _activeRouter;
2206
3963
  }
2207
3964
 
2208
- // --- src/store.js ————————————————————————————————————————————————
3965
+ // --- src/store.js ------------------------------------------------
2209
3966
  /**
2210
3967
  * zQuery Store — Global reactive state management
2211
3968
  *
@@ -2234,6 +3991,7 @@ function getRouter() {
2234
3991
  */
2235
3992
 
2236
3993
 
3994
+
2237
3995
  class Store {
2238
3996
  constructor(config = {}) {
2239
3997
  this._subscribers = new Map(); // key → Set<fn>
@@ -2250,9 +4008,15 @@ class Store {
2250
4008
  this.state = reactive(initial, (key, value, old) => {
2251
4009
  // Notify key-specific subscribers
2252
4010
  const subs = this._subscribers.get(key);
2253
- if (subs) subs.forEach(fn => fn(value, old, key));
4011
+ if (subs) subs.forEach(fn => {
4012
+ try { fn(value, old, key); }
4013
+ catch (err) { reportError(ErrorCode.STORE_SUBSCRIBE, `Subscriber for "${key}" threw`, { key }, err); }
4014
+ });
2254
4015
  // Notify wildcard subscribers
2255
- this._wildcards.forEach(fn => fn(key, value, old));
4016
+ this._wildcards.forEach(fn => {
4017
+ try { fn(key, value, old); }
4018
+ catch (err) { reportError(ErrorCode.STORE_SUBSCRIBE, 'Wildcard subscriber threw', { key }, err); }
4019
+ });
2256
4020
  });
2257
4021
 
2258
4022
  // Build getters as computed properties
@@ -2273,23 +4037,32 @@ class Store {
2273
4037
  dispatch(name, ...args) {
2274
4038
  const action = this._actions[name];
2275
4039
  if (!action) {
2276
- console.warn(`zQuery Store: Unknown action "${name}"`);
4040
+ reportError(ErrorCode.STORE_ACTION, `Unknown action "${name}"`, { action: name, args });
2277
4041
  return;
2278
4042
  }
2279
4043
 
2280
4044
  // Run middleware
2281
4045
  for (const mw of this._middleware) {
2282
- const result = mw(name, args, this.state);
2283
- if (result === false) return; // blocked by middleware
4046
+ try {
4047
+ const result = mw(name, args, this.state);
4048
+ if (result === false) return; // blocked by middleware
4049
+ } catch (err) {
4050
+ reportError(ErrorCode.STORE_MIDDLEWARE, `Middleware threw during "${name}"`, { action: name }, err);
4051
+ return;
4052
+ }
2284
4053
  }
2285
4054
 
2286
4055
  if (this._debug) {
2287
4056
  console.log(`%c[Store] ${name}`, 'color: #4CAF50; font-weight: bold;', ...args);
2288
4057
  }
2289
4058
 
2290
- const result = action(this.state, ...args);
2291
- this._history.push({ action: name, args, timestamp: Date.now() });
2292
- return result;
4059
+ try {
4060
+ const result = action(this.state, ...args);
4061
+ this._history.push({ action: name, args, timestamp: Date.now() });
4062
+ return result;
4063
+ } catch (err) {
4064
+ reportError(ErrorCode.STORE_ACTION, `Action "${name}" threw`, { action: name, args }, err);
4065
+ }
2293
4066
  }
2294
4067
 
2295
4068
  /**
@@ -2375,7 +4148,7 @@ function getStore(name = 'default') {
2375
4148
  return _stores.get(name) || null;
2376
4149
  }
2377
4150
 
2378
- // --- src/http.js —————————————————————————————————————————————————
4151
+ // --- src/http.js -------------------------------------------------
2379
4152
  /**
2380
4153
  * zQuery HTTP — Lightweight fetch wrapper
2381
4154
  *
@@ -2408,6 +4181,9 @@ const _interceptors = {
2408
4181
  * Core request function
2409
4182
  */
2410
4183
  async function request(method, url, data, options = {}) {
4184
+ if (!url || typeof url !== 'string') {
4185
+ throw new Error(`HTTP request requires a URL string, got ${typeof url}`);
4186
+ }
2411
4187
  let fullURL = url.startsWith('http') ? url : _config.baseURL + url;
2412
4188
  let headers = { ..._config.headers, ...options.headers };
2413
4189
  let body = undefined;
@@ -2463,16 +4239,21 @@ async function request(method, url, data, options = {}) {
2463
4239
  const contentType = response.headers.get('Content-Type') || '';
2464
4240
  let responseData;
2465
4241
 
2466
- if (contentType.includes('application/json')) {
2467
- responseData = await response.json();
2468
- } else if (contentType.includes('text/')) {
2469
- responseData = await response.text();
2470
- } else if (contentType.includes('application/octet-stream') || contentType.includes('image/')) {
2471
- responseData = await response.blob();
2472
- } else {
2473
- // Try JSON first, fall back to text
2474
- const text = await response.text();
2475
- try { responseData = JSON.parse(text); } catch { responseData = text; }
4242
+ try {
4243
+ if (contentType.includes('application/json')) {
4244
+ responseData = await response.json();
4245
+ } else if (contentType.includes('text/')) {
4246
+ responseData = await response.text();
4247
+ } else if (contentType.includes('application/octet-stream') || contentType.includes('image/')) {
4248
+ responseData = await response.blob();
4249
+ } else {
4250
+ // Try JSON first, fall back to text
4251
+ const text = await response.text();
4252
+ try { responseData = JSON.parse(text); } catch { responseData = text; }
4253
+ }
4254
+ } catch (parseErr) {
4255
+ responseData = null;
4256
+ console.warn(`[zQuery HTTP] Failed to parse response body from ${method} ${fullURL}:`, parseErr.message);
2476
4257
  }
2477
4258
 
2478
4259
  const result = {
@@ -2554,7 +4335,7 @@ const http = {
2554
4335
  raw: (url, opts) => fetch(url, opts),
2555
4336
  };
2556
4337
 
2557
- // --- src/utils.js ————————————————————————————————————————————————
4338
+ // --- src/utils.js ------------------------------------------------
2558
4339
  /**
2559
4340
  * zQuery Utils — Common utility functions
2560
4341
  *
@@ -2827,7 +4608,7 @@ class EventBus {
2827
4608
 
2828
4609
  const bus = new EventBus();
2829
4610
 
2830
- // --- index.js (assembly) ——————————————————————————————————————————
4611
+ // --- index.js (assembly) ------------------------------------------
2831
4612
  /**
2832
4613
  * ┌---------------------------------------------------------┐
2833
4614
  * │ zQuery (zeroQuery) — Lightweight Frontend Library │
@@ -2846,21 +4627,24 @@ const bus = new EventBus();
2846
4627
 
2847
4628
 
2848
4629
 
4630
+
4631
+
4632
+
2849
4633
  // ---------------------------------------------------------------------------
2850
4634
  // $ — The main function & namespace
2851
4635
  // ---------------------------------------------------------------------------
2852
4636
 
2853
4637
  /**
2854
- * Main selector function
4638
+ * Main selector function — always returns a ZQueryCollection (like jQuery).
2855
4639
  *
2856
- * $('selector') → single Element (querySelector)
2857
- * $('<div>hello</div>') → create element (first created node)
2858
- * $(element) → return element as-is
4640
+ * $('selector') → ZQueryCollection (querySelectorAll)
4641
+ * $('<div>hello</div>') → ZQueryCollection from created elements
4642
+ * $(element) → ZQueryCollection wrapping the element
2859
4643
  * $(fn) → DOMContentLoaded shorthand
2860
4644
  *
2861
4645
  * @param {string|Element|NodeList|Function} selector
2862
4646
  * @param {string|Element} [context]
2863
- * @returns {Element|null}
4647
+ * @returns {ZQueryCollection}
2864
4648
  */
2865
4649
  function $(selector, context) {
2866
4650
  // $(fn) → DOM ready shorthand
@@ -2880,8 +4664,6 @@ $.tag = query.tag;
2880
4664
  Object.defineProperty($, 'name', {
2881
4665
  value: query.name, writable: true, configurable: true
2882
4666
  });
2883
- $.attr = query.attr;
2884
- $.data = query.data;
2885
4667
  $.children = query.children;
2886
4668
 
2887
4669
  // --- Collection selector ---------------------------------------------------
@@ -2923,6 +4705,8 @@ $.getInstance = getInstance;
2923
4705
  $.destroy = destroy;
2924
4706
  $.components = getRegistry;
2925
4707
  $.style = style;
4708
+ $.morph = morph;
4709
+ $.safeEval = safeEval;
2926
4710
 
2927
4711
  // --- Router ----------------------------------------------------------------
2928
4712
  $.router = createRouter;
@@ -2961,8 +4745,13 @@ $.storage = storage;
2961
4745
  $.session = session;
2962
4746
  $.bus = bus;
2963
4747
 
4748
+ // --- Error handling --------------------------------------------------------
4749
+ $.onError = onError;
4750
+ $.ZQueryError = ZQueryError;
4751
+ $.ErrorCode = ErrorCode;
4752
+
2964
4753
  // --- Meta ------------------------------------------------------------------
2965
- $.version = '0.5.2';
4754
+ $.version = '0.7.5';
2966
4755
  $.meta = {}; // populated at build time by CLI bundler
2967
4756
 
2968
4757
  $.noConflict = () => {