zero-query 0.9.7 → 0.9.9

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 (72) hide show
  1. package/README.md +55 -4
  2. package/cli/commands/build.js +50 -3
  3. package/cli/commands/create.js +58 -11
  4. package/cli/help.js +4 -0
  5. package/cli/scaffold/default/app/app.js +211 -0
  6. package/cli/scaffold/default/app/components/about.js +201 -0
  7. package/cli/scaffold/default/app/components/api-demo.js +143 -0
  8. package/cli/scaffold/default/app/components/contact-card.js +231 -0
  9. package/cli/scaffold/default/app/components/contacts/contacts.css +706 -0
  10. package/cli/scaffold/default/app/components/contacts/contacts.html +200 -0
  11. package/cli/scaffold/default/app/components/contacts/contacts.js +196 -0
  12. package/cli/scaffold/default/app/components/counter.js +127 -0
  13. package/cli/scaffold/default/app/components/home.js +249 -0
  14. package/cli/scaffold/{app → default/app}/components/not-found.js +2 -2
  15. package/cli/scaffold/default/app/components/playground/playground.css +116 -0
  16. package/cli/scaffold/default/app/components/playground/playground.html +162 -0
  17. package/cli/scaffold/default/app/components/playground/playground.js +117 -0
  18. package/cli/scaffold/default/app/components/todos.js +225 -0
  19. package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -0
  20. package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -0
  21. package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -0
  22. package/cli/scaffold/default/app/routes.js +15 -0
  23. package/cli/scaffold/{app → default/app}/store.js +15 -10
  24. package/cli/scaffold/{global.css → default/global.css} +238 -252
  25. package/cli/scaffold/{index.html → default/index.html} +35 -0
  26. package/cli/scaffold/{app → minimal/app}/app.js +37 -39
  27. package/cli/scaffold/minimal/app/components/about.js +68 -0
  28. package/cli/scaffold/minimal/app/components/counter.js +122 -0
  29. package/cli/scaffold/minimal/app/components/home.js +68 -0
  30. package/cli/scaffold/minimal/app/components/not-found.js +16 -0
  31. package/cli/scaffold/minimal/app/routes.js +9 -0
  32. package/cli/scaffold/minimal/app/store.js +36 -0
  33. package/cli/scaffold/minimal/assets/.gitkeep +0 -0
  34. package/cli/scaffold/minimal/global.css +291 -0
  35. package/cli/scaffold/minimal/index.html +44 -0
  36. package/cli/scaffold/ssr/app/app.js +30 -0
  37. package/cli/scaffold/ssr/app/components/about.js +28 -0
  38. package/cli/scaffold/ssr/app/components/home.js +37 -0
  39. package/cli/scaffold/ssr/app/components/not-found.js +15 -0
  40. package/cli/scaffold/ssr/app/routes.js +6 -0
  41. package/cli/scaffold/ssr/global.css +113 -0
  42. package/cli/scaffold/ssr/index.html +31 -0
  43. package/cli/scaffold/ssr/package.json +8 -0
  44. package/cli/scaffold/ssr/server/index.js +118 -0
  45. package/dist/zquery.dist.zip +0 -0
  46. package/dist/zquery.js +2006 -1933
  47. package/dist/zquery.min.js +2 -2
  48. package/index.d.ts +20 -1
  49. package/index.js +8 -5
  50. package/package.json +8 -2
  51. package/src/component.js +6 -3
  52. package/src/diff.js +15 -2
  53. package/src/errors.js +59 -5
  54. package/src/package.json +1 -0
  55. package/src/ssr.js +116 -22
  56. package/tests/cli.test.js +304 -0
  57. package/tests/errors.test.js +423 -145
  58. package/tests/ssr.test.js +435 -3
  59. package/types/errors.d.ts +34 -2
  60. package/types/ssr.d.ts +21 -1
  61. package/cli/scaffold/app/components/about.js +0 -131
  62. package/cli/scaffold/app/components/api-demo.js +0 -103
  63. package/cli/scaffold/app/components/contacts/contacts.css +0 -246
  64. package/cli/scaffold/app/components/contacts/contacts.html +0 -140
  65. package/cli/scaffold/app/components/contacts/contacts.js +0 -153
  66. package/cli/scaffold/app/components/counter.js +0 -85
  67. package/cli/scaffold/app/components/home.js +0 -137
  68. package/cli/scaffold/app/components/todos.js +0 -131
  69. package/cli/scaffold/app/routes.js +0 -13
  70. /package/cli/scaffold/{LICENSE → default/LICENSE} +0 -0
  71. /package/cli/scaffold/{assets → default/assets}/.gitkeep +0 -0
  72. /package/cli/scaffold/{favicon.ico → default/favicon.ico} +0 -0
package/dist/zquery.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * zQuery (zeroQuery) v0.9.7
2
+ * zQuery (zeroQuery) v0.9.9
3
3
  * Lightweight Frontend Library
4
4
  * https://github.com/tonywied17/zero-query
5
5
  * (c) 2026 Anthony Wiedman - MIT License
@@ -59,6 +59,12 @@ const ErrorCode = Object.freeze({
59
59
  HTTP_INTERCEPTOR: 'ZQ_HTTP_INTERCEPTOR',
60
60
  HTTP_PARSE: 'ZQ_HTTP_PARSE',
61
61
 
62
+ // SSR
63
+ SSR_RENDER: 'ZQ_SSR_RENDER',
64
+ SSR_COMPONENT: 'ZQ_SSR_COMPONENT',
65
+ SSR_HYDRATION: 'ZQ_SSR_HYDRATION',
66
+ SSR_PAGE: 'ZQ_SSR_PAGE',
67
+
62
68
  // General
63
69
  INVALID_ARGUMENT: 'ZQ_INVALID_ARGUMENT',
64
70
  });
@@ -87,16 +93,28 @@ class ZQueryError extends Error {
87
93
  // ---------------------------------------------------------------------------
88
94
  // Global error handler
89
95
  // ---------------------------------------------------------------------------
90
- let _errorHandler = null;
96
+ let _errorHandlers = [];
91
97
 
92
98
  /**
93
99
  * Register a global error handler.
94
100
  * Called whenever zQuery catches an error internally.
101
+ * Multiple handlers are supported — each receives the error.
102
+ * Pass `null` to clear all handlers.
95
103
  *
96
104
  * @param {Function|null} handler — (error: ZQueryError) => void
105
+ * @returns {Function} unsubscribe function to remove this handler
97
106
  */
98
107
  function onError(handler) {
99
- _errorHandler = typeof handler === 'function' ? handler : null;
108
+ if (handler === null) {
109
+ _errorHandlers = [];
110
+ return () => {};
111
+ }
112
+ if (typeof handler !== 'function') return () => {};
113
+ _errorHandlers.push(handler);
114
+ return () => {
115
+ const idx = _errorHandlers.indexOf(handler);
116
+ if (idx !== -1) _errorHandlers.splice(idx, 1);
117
+ };
100
118
  }
101
119
 
102
120
  /**
@@ -113,9 +131,9 @@ function reportError(code, message, context = {}, cause) {
113
131
  ? cause
114
132
  : new ZQueryError(code, message, context, cause);
115
133
 
116
- // User handler gets first crack
117
- if (_errorHandler) {
118
- try { _errorHandler(err); } catch { /* prevent handler from crashing framework */ }
134
+ // Notify all registered handlers
135
+ for (const handler of _errorHandlers) {
136
+ try { handler(err); } catch { /* prevent handler from crashing framework */ }
119
137
  }
120
138
 
121
139
  // Always log for developer visibility
@@ -162,6 +180,42 @@ function validate(value, name, expectedType) {
162
180
  `"${name}" must be a ${expectedType}, got ${typeof value}`
163
181
  );
164
182
  }
183
+ }
184
+
185
+ /**
186
+ * Format a ZQueryError into a structured object suitable for overlays/logging.
187
+ * @param {ZQueryError|Error} err
188
+ * @returns {{ code: string, type: string, message: string, context: object, stack: string }}
189
+ */
190
+ function formatError(err) {
191
+ const isZQ = err instanceof ZQueryError;
192
+ return {
193
+ code: isZQ ? err.code : '',
194
+ type: isZQ ? 'ZQueryError' : (err.name || 'Error'),
195
+ message: err.message || 'Unknown error',
196
+ context: isZQ ? err.context : {},
197
+ stack: err.stack || '',
198
+ cause: err.cause ? formatError(err.cause) : null,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Async version of guardCallback — wraps an async function so that
204
+ * rejections are caught, reported, and don't crash execution.
205
+ *
206
+ * @param {Function} fn — async function
207
+ * @param {string} code — ErrorCode to use
208
+ * @param {object} [context]
209
+ * @returns {Function}
210
+ */
211
+ function guardAsync(fn, code, context = {}) {
212
+ return async (...args) => {
213
+ try {
214
+ return await fn(...args);
215
+ } catch (err) {
216
+ reportError(code, err.message || 'Async callback error', context, err);
217
+ }
218
+ };
165
219
  }
166
220
 
167
221
  // --- src/reactive.js ---------------------------------------------
@@ -353,2338 +407,2351 @@ function effect(fn) {
353
407
  };
354
408
  }
355
409
 
356
- // --- src/core.js -------------------------------------------------
410
+ // --- src/diff.js -------------------------------------------------
357
411
  /**
358
- * zQuery CoreSelector engine & chainable DOM collection
359
- *
360
- * Extends the quick-ref pattern (Id, Class, Classes, Children)
361
- * into a full jQuery-like chainable wrapper with modern APIs.
412
+ * zQuery DiffLightweight DOM morphing engine
413
+ *
414
+ * Patches an existing DOM tree to match new HTML without destroying nodes
415
+ * that haven't changed. Preserves focus, scroll positions, third-party
416
+ * widget state, video playback, and other live DOM state.
417
+ *
418
+ * Approach: walk old and new trees in parallel, reconcile node by node.
419
+ * Keyed elements (via `z-key`) get matched across position changes.
420
+ *
421
+ * Performance advantages over virtual DOM (React/Angular):
422
+ * - No virtual tree allocation or diffing — works directly on real DOM
423
+ * - Skips unchanged subtrees via fast isEqualNode() check
424
+ * - z-skip attribute to opt out of diffing entire subtrees
425
+ * - Reuses a single template element for HTML parsing (zero GC pressure)
426
+ * - Keyed reconciliation uses LIS (Longest Increasing Subsequence) to
427
+ * minimize DOM moves — same algorithm as Vue 3 / ivi
428
+ * - Minimal attribute diffing with early bail-out
362
429
  */
363
430
 
364
-
365
431
  // ---------------------------------------------------------------------------
366
- // ZQueryCollection wraps an array of elements with chainable methods
432
+ // Reusable template element avoids per-call allocation
367
433
  // ---------------------------------------------------------------------------
368
- class ZQueryCollection {
369
- constructor(elements) {
370
- this.elements = Array.isArray(elements) ? elements : (elements ? [elements] : []);
371
- this.length = this.elements.length;
372
- this.elements.forEach((el, i) => { this[i] = el; });
373
- }
434
+ let _tpl = null;
374
435
 
375
- // --- Iteration -----------------------------------------------------------
436
+ function _getTemplate() {
437
+ if (!_tpl) _tpl = document.createElement('template');
438
+ return _tpl;
439
+ }
376
440
 
377
- each(fn) {
378
- this.elements.forEach((el, i) => fn.call(el, i, el));
379
- return this;
380
- }
441
+ // ---------------------------------------------------------------------------
442
+ // morph(existingRoot, newHTML) patch existing DOM to match newHTML
443
+ // ---------------------------------------------------------------------------
381
444
 
382
- map(fn) {
383
- return this.elements.map((el, i) => fn.call(el, i, el));
384
- }
445
+ /**
446
+ * Morph an existing DOM element's children to match new HTML.
447
+ * Only touches nodes that actually differ.
448
+ *
449
+ * @param {Element} rootEl — The live DOM container to patch
450
+ * @param {string} newHTML — The desired HTML string
451
+ */
452
+ function morph(rootEl, newHTML) {
453
+ const start = typeof window !== 'undefined' && window.__zqMorphHook ? performance.now() : 0;
454
+ const tpl = _getTemplate();
455
+ tpl.innerHTML = newHTML;
456
+ const newRoot = tpl.content;
385
457
 
386
- forEach(fn) {
387
- this.elements.forEach((el, i) => fn(el, i, this.elements));
388
- return this;
389
- }
458
+ // Move children into a wrapper for consistent handling.
459
+ // We move (not clone) from the template — cheaper than cloning.
460
+ const tempDiv = document.createElement('div');
461
+ while (newRoot.firstChild) tempDiv.appendChild(newRoot.firstChild);
390
462
 
391
- first() { return this.elements[0] || null; }
392
- last() { return this.elements[this.length - 1] || null; }
393
- eq(i) { return new ZQueryCollection(this.elements[i] ? [this.elements[i]] : []); }
394
- toArray(){ return [...this.elements]; }
463
+ _morphChildren(rootEl, tempDiv);
395
464
 
396
- [Symbol.iterator]() { return this.elements[Symbol.iterator](); }
465
+ if (start) window.__zqMorphHook(rootEl, performance.now() - start);
466
+ }
397
467
 
398
- // --- Traversal -----------------------------------------------------------
468
+ /**
469
+ * Morph a single element in place — diffs attributes and children
470
+ * without replacing the node reference. Useful for replaceWith-style
471
+ * updates where you want to keep the element identity when the tag
472
+ * name matches.
473
+ *
474
+ * If the new HTML produces a different tag, falls back to native replace.
475
+ *
476
+ * @param {Element} oldEl — The live DOM element to patch
477
+ * @param {string} newHTML — HTML string for the replacement element
478
+ * @returns {Element} — The resulting element (same ref if morphed, new if replaced)
479
+ */
480
+ function morphElement(oldEl, newHTML) {
481
+ const start = typeof window !== 'undefined' && window.__zqMorphHook ? performance.now() : 0;
482
+ const tpl = _getTemplate();
483
+ tpl.innerHTML = newHTML;
484
+ const newEl = tpl.content.firstElementChild;
485
+ if (!newEl) return oldEl;
399
486
 
400
- find(selector) {
401
- const found = [];
402
- this.elements.forEach(el => found.push(...el.querySelectorAll(selector)));
403
- return new ZQueryCollection(found);
487
+ // Same tag — morph in place (preserves identity, event listeners, refs)
488
+ if (oldEl.nodeName === newEl.nodeName) {
489
+ _morphAttributes(oldEl, newEl);
490
+ _morphChildren(oldEl, newEl);
491
+ if (start) window.__zqMorphHook(oldEl, performance.now() - start);
492
+ return oldEl;
404
493
  }
405
494
 
406
- parent() {
407
- const parents = [...new Set(this.elements.map(el => el.parentElement).filter(Boolean))];
408
- return new ZQueryCollection(parents);
409
- }
495
+ // Different tag — must replace (can't morph <div> into <span>)
496
+ const clone = newEl.cloneNode(true);
497
+ oldEl.parentNode.replaceChild(clone, oldEl);
498
+ if (start) window.__zqMorphHook(clone, performance.now() - start);
499
+ return clone;
500
+ }
410
501
 
411
- closest(selector) {
412
- return new ZQueryCollection(
413
- this.elements.map(el => el.closest(selector)).filter(Boolean)
414
- );
415
- }
502
+ // Aliases for the concat build — core.js imports these as _morph / _morphElement,
503
+ // but the build strips `import … as` lines, so the aliases must exist at runtime.
504
+ const _morph = morph;
505
+ const _morphElement = morphElement;
416
506
 
417
- children(selector) {
418
- const kids = [];
419
- this.elements.forEach(el => {
420
- kids.push(...(selector
421
- ? el.querySelectorAll(`:scope > ${selector}`)
422
- : el.children));
423
- });
424
- return new ZQueryCollection([...kids]);
425
- }
507
+ /**
508
+ * Reconcile children of `oldParent` to match `newParent`.
509
+ *
510
+ * @param {Element} oldParent — live DOM parent
511
+ * @param {Element} newParent — desired state parent
512
+ */
513
+ function _morphChildren(oldParent, newParent) {
514
+ // Snapshot live NodeLists into arrays — childNodes is live and
515
+ // mutates during insertBefore/removeChild. Using a for loop to push
516
+ // avoids spread operator overhead for large child lists.
517
+ const oldCN = oldParent.childNodes;
518
+ const newCN = newParent.childNodes;
519
+ const oldLen = oldCN.length;
520
+ const newLen = newCN.length;
521
+ const oldChildren = new Array(oldLen);
522
+ const newChildren = new Array(newLen);
523
+ for (let i = 0; i < oldLen; i++) oldChildren[i] = oldCN[i];
524
+ for (let i = 0; i < newLen; i++) newChildren[i] = newCN[i];
426
525
 
427
- siblings(selector) {
428
- const sibs = [];
429
- this.elements.forEach(el => {
430
- if (!el.parentElement) return;
431
- const all = [...el.parentElement.children].filter(c => c !== el);
432
- sibs.push(...(selector ? all.filter(c => c.matches(selector)) : all));
433
- });
434
- return new ZQueryCollection(sibs);
435
- }
526
+ // Scan for keyed elements — only build maps if keys exist
527
+ let hasKeys = false;
528
+ let oldKeyMap, newKeyMap;
436
529
 
437
- next(selector) {
438
- const els = this.elements.map(el => el.nextElementSibling).filter(Boolean);
439
- return new ZQueryCollection(selector ? els.filter(el => el.matches(selector)) : els);
530
+ for (let i = 0; i < oldLen; i++) {
531
+ if (_getKey(oldChildren[i]) != null) { hasKeys = true; break; }
440
532
  }
441
-
442
- prev(selector) {
443
- const els = this.elements.map(el => el.previousElementSibling).filter(Boolean);
444
- return new ZQueryCollection(selector ? els.filter(el => el.matches(selector)) : els);
533
+ if (!hasKeys) {
534
+ for (let i = 0; i < newLen; i++) {
535
+ if (_getKey(newChildren[i]) != null) { hasKeys = true; break; }
536
+ }
445
537
  }
446
538
 
447
- nextAll(selector) {
448
- const result = [];
449
- this.elements.forEach(el => {
450
- let sib = el.nextElementSibling;
451
- while (sib) {
452
- if (!selector || sib.matches(selector)) result.push(sib);
453
- sib = sib.nextElementSibling;
454
- }
455
- });
456
- return new ZQueryCollection(result);
539
+ if (hasKeys) {
540
+ oldKeyMap = new Map();
541
+ newKeyMap = new Map();
542
+ for (let i = 0; i < oldLen; i++) {
543
+ const key = _getKey(oldChildren[i]);
544
+ if (key != null) oldKeyMap.set(key, i);
545
+ }
546
+ for (let i = 0; i < newLen; i++) {
547
+ const key = _getKey(newChildren[i]);
548
+ if (key != null) newKeyMap.set(key, i);
549
+ }
550
+ _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap);
551
+ } else {
552
+ _morphChildrenUnkeyed(oldParent, oldChildren, newChildren);
457
553
  }
554
+ }
458
555
 
459
- nextUntil(selector, filter) {
460
- const result = [];
461
- this.elements.forEach(el => {
462
- let sib = el.nextElementSibling;
463
- while (sib) {
464
- if (selector && sib.matches(selector)) break;
465
- if (!filter || sib.matches(filter)) result.push(sib);
466
- sib = sib.nextElementSibling;
467
- }
468
- });
469
- return new ZQueryCollection(result);
556
+ /**
557
+ * Unkeyed reconciliation — positional matching.
558
+ */
559
+ function _morphChildrenUnkeyed(oldParent, oldChildren, newChildren) {
560
+ const oldLen = oldChildren.length;
561
+ const newLen = newChildren.length;
562
+ const minLen = oldLen < newLen ? oldLen : newLen;
563
+
564
+ // Morph overlapping range
565
+ for (let i = 0; i < minLen; i++) {
566
+ _morphNode(oldParent, oldChildren[i], newChildren[i]);
470
567
  }
471
568
 
472
- prevAll(selector) {
473
- const result = [];
474
- this.elements.forEach(el => {
475
- let sib = el.previousElementSibling;
476
- while (sib) {
477
- if (!selector || sib.matches(selector)) result.push(sib);
478
- sib = sib.previousElementSibling;
479
- }
480
- });
481
- return new ZQueryCollection(result);
569
+ // Append new nodes
570
+ if (newLen > oldLen) {
571
+ for (let i = oldLen; i < newLen; i++) {
572
+ oldParent.appendChild(newChildren[i].cloneNode(true));
573
+ }
482
574
  }
483
575
 
484
- prevUntil(selector, filter) {
485
- const result = [];
486
- this.elements.forEach(el => {
487
- let sib = el.previousElementSibling;
488
- while (sib) {
489
- if (selector && sib.matches(selector)) break;
490
- if (!filter || sib.matches(filter)) result.push(sib);
491
- sib = sib.previousElementSibling;
492
- }
493
- });
494
- return new ZQueryCollection(result);
495
- }
496
-
497
- parents(selector) {
498
- const result = [];
499
- this.elements.forEach(el => {
500
- let parent = el.parentElement;
501
- while (parent) {
502
- if (!selector || parent.matches(selector)) result.push(parent);
503
- parent = parent.parentElement;
504
- }
505
- });
506
- return new ZQueryCollection([...new Set(result)]);
507
- }
508
-
509
- parentsUntil(selector, filter) {
510
- const result = [];
511
- this.elements.forEach(el => {
512
- let parent = el.parentElement;
513
- while (parent) {
514
- if (selector && parent.matches(selector)) break;
515
- if (!filter || parent.matches(filter)) result.push(parent);
516
- parent = parent.parentElement;
517
- }
518
- });
519
- return new ZQueryCollection([...new Set(result)]);
576
+ // Remove excess old nodes (iterate backwards to avoid index shifting)
577
+ if (oldLen > newLen) {
578
+ for (let i = oldLen - 1; i >= newLen; i--) {
579
+ oldParent.removeChild(oldChildren[i]);
580
+ }
520
581
  }
582
+ }
521
583
 
522
- contents() {
523
- const result = [];
524
- this.elements.forEach(el => result.push(...el.childNodes));
525
- return new ZQueryCollection(result);
526
- }
584
+ /**
585
+ * Keyed reconciliation — match by z-key, reorder with minimal moves
586
+ * using Longest Increasing Subsequence (LIS) to find the maximum set
587
+ * of nodes that are already in the correct relative order, then only
588
+ * move the remaining nodes.
589
+ */
590
+ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap) {
591
+ const consumed = new Set();
592
+ const newLen = newChildren.length;
593
+ const matched = new Array(newLen);
527
594
 
528
- filter(selector) {
529
- if (typeof selector === 'function') {
530
- return new ZQueryCollection(this.elements.filter(selector));
595
+ // Step 1: Match new children to old children by key
596
+ for (let i = 0; i < newLen; i++) {
597
+ const key = _getKey(newChildren[i]);
598
+ if (key != null && oldKeyMap.has(key)) {
599
+ const oldIdx = oldKeyMap.get(key);
600
+ matched[i] = oldChildren[oldIdx];
601
+ consumed.add(oldIdx);
602
+ } else {
603
+ matched[i] = null;
531
604
  }
532
- return new ZQueryCollection(this.elements.filter(el => el.matches(selector)));
533
605
  }
534
606
 
535
- not(selector) {
536
- if (typeof selector === 'function') {
537
- return new ZQueryCollection(this.elements.filter((el, i) => !selector.call(el, i, el)));
607
+ // Step 2: Remove old keyed nodes not in the new tree
608
+ for (let i = oldChildren.length - 1; i >= 0; i--) {
609
+ if (!consumed.has(i)) {
610
+ const key = _getKey(oldChildren[i]);
611
+ if (key != null && !newKeyMap.has(key)) {
612
+ oldParent.removeChild(oldChildren[i]);
613
+ }
538
614
  }
539
- return new ZQueryCollection(this.elements.filter(el => !el.matches(selector)));
540
615
  }
541
616
 
542
- has(selector) {
543
- return new ZQueryCollection(this.elements.filter(el => el.querySelector(selector)));
544
- }
545
-
546
- is(selector) {
547
- if (typeof selector === 'function') {
548
- return this.elements.some((el, i) => selector.call(el, i, el));
617
+ // Step 3: Build index array for LIS of matched old indices.
618
+ // This finds the largest set of keyed nodes already in order,
619
+ // so we only need to move the rest — O(n log n) instead of O(n²).
620
+ const oldIndices = []; // Maps new-position → old-position (or -1)
621
+ for (let i = 0; i < newLen; i++) {
622
+ if (matched[i]) {
623
+ const key = _getKey(newChildren[i]);
624
+ oldIndices.push(oldKeyMap.get(key));
625
+ } else {
626
+ oldIndices.push(-1);
549
627
  }
550
- return this.elements.some(el => el.matches(selector));
551
628
  }
552
629
 
553
- slice(start, end) {
554
- return new ZQueryCollection(this.elements.slice(start, end));
555
- }
630
+ const lisSet = _lis(oldIndices);
556
631
 
557
- add(selector, context) {
558
- const toAdd = (selector instanceof ZQueryCollection)
559
- ? selector.elements
560
- : (selector instanceof Node)
561
- ? [selector]
562
- : Array.from((context || document).querySelectorAll(selector));
563
- return new ZQueryCollection([...this.elements, ...toAdd]);
632
+ // Step 4: Insert / reorder / morph — walk new children forward,
633
+ // using LIS to decide which nodes stay in place.
634
+ let cursor = oldParent.firstChild;
635
+ const unkeyedOld = [];
636
+ for (let i = 0; i < oldChildren.length; i++) {
637
+ if (!consumed.has(i) && _getKey(oldChildren[i]) == null) {
638
+ unkeyedOld.push(oldChildren[i]);
639
+ }
564
640
  }
641
+ let unkeyedIdx = 0;
565
642
 
566
- get(index) {
567
- if (index === undefined) return [...this.elements];
568
- return index < 0 ? this.elements[this.length + index] : this.elements[index];
569
- }
643
+ for (let i = 0; i < newLen; i++) {
644
+ const newNode = newChildren[i];
645
+ const newKey = _getKey(newNode);
646
+ let oldNode = matched[i];
570
647
 
571
- index(selector) {
572
- if (selector === undefined) {
573
- const el = this.first();
574
- if (!el || !el.parentElement) return -1;
575
- return Array.from(el.parentElement.children).indexOf(el);
648
+ if (!oldNode && newKey == null) {
649
+ oldNode = unkeyedOld[unkeyedIdx++] || null;
576
650
  }
577
- const target = (typeof selector === 'string')
578
- ? document.querySelector(selector)
579
- : selector;
580
- return this.elements.indexOf(target);
581
- }
582
-
583
- // --- Classes -------------------------------------------------------------
584
651
 
585
- addClass(...names) {
586
- // Fast path: single class, no spaces avoids flatMap + regex split allocation
587
- if (names.length === 1 && names[0].indexOf(' ') === -1) {
588
- const c = names[0];
589
- for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.add(c);
590
- return this;
652
+ if (oldNode) {
653
+ // If this node is NOT part of the LIS, it needs to be moved
654
+ if (!lisSet.has(i)) {
655
+ oldParent.insertBefore(oldNode, cursor);
656
+ }
657
+ // Capture next sibling BEFORE _morphNode — if _morphNode calls
658
+ // replaceChild, oldNode is removed and nextSibling becomes stale.
659
+ const nextSib = oldNode.nextSibling;
660
+ _morphNode(oldParent, oldNode, newNode);
661
+ cursor = nextSib;
662
+ } else {
663
+ // Insert new node
664
+ const clone = newNode.cloneNode(true);
665
+ if (cursor) {
666
+ oldParent.insertBefore(clone, cursor);
667
+ } else {
668
+ oldParent.appendChild(clone);
669
+ }
591
670
  }
592
- const classes = names.flatMap(n => n.split(/\s+/));
593
- for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.add(...classes);
594
- return this;
595
671
  }
596
672
 
597
- removeClass(...names) {
598
- if (names.length === 1 && names[0].indexOf(' ') === -1) {
599
- const c = names[0];
600
- for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.remove(c);
601
- return this;
673
+ // Remove remaining unkeyed old nodes
674
+ while (unkeyedIdx < unkeyedOld.length) {
675
+ const leftover = unkeyedOld[unkeyedIdx++];
676
+ if (leftover.parentNode === oldParent) {
677
+ oldParent.removeChild(leftover);
602
678
  }
603
- const classes = names.flatMap(n => n.split(/\s+/));
604
- for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.remove(...classes);
605
- return this;
606
679
  }
607
680
 
608
- toggleClass(...args) {
609
- const force = typeof args[args.length - 1] === 'boolean' ? args.pop() : undefined;
610
- // Fast path: single class, no spaces
611
- if (args.length === 1 && args[0].indexOf(' ') === -1) {
612
- const c = args[0];
613
- for (let i = 0; i < this.elements.length; i++) {
614
- force !== undefined ? this.elements[i].classList.toggle(c, force) : this.elements[i].classList.toggle(c);
615
- }
616
- return this;
617
- }
618
- const classes = args.flatMap(n => n.split(/\s+/));
619
- for (let i = 0; i < this.elements.length; i++) {
620
- const el = this.elements[i];
621
- for (let j = 0; j < classes.length; j++) {
622
- force !== undefined ? el.classList.toggle(classes[j], force) : el.classList.toggle(classes[j]);
681
+ // Remove any remaining keyed old nodes that weren't consumed
682
+ for (let i = 0; i < oldChildren.length; i++) {
683
+ if (!consumed.has(i)) {
684
+ const node = oldChildren[i];
685
+ if (node.parentNode === oldParent && _getKey(node) != null && !newKeyMap.has(_getKey(node))) {
686
+ oldParent.removeChild(node);
623
687
  }
624
688
  }
625
- return this;
626
689
  }
690
+ }
627
691
 
628
- hasClass(name) {
629
- return this.first()?.classList.contains(name) || false;
630
- }
692
+ /**
693
+ * Compute the Longest Increasing Subsequence of an index array.
694
+ * Returns a Set of positions (in the input) that form the LIS.
695
+ * Entries with value -1 (unmatched) are excluded.
696
+ *
697
+ * O(n log n) — same algorithm used by Vue 3 and ivi.
698
+ *
699
+ * @param {number[]} arr — array of old-tree indices (-1 = unmatched)
700
+ * @returns {Set<number>} — positions in arr belonging to the LIS
701
+ */
702
+ function _lis(arr) {
703
+ const len = arr.length;
704
+ const result = new Set();
705
+ if (len === 0) return result;
631
706
 
632
- // --- Attributes ----------------------------------------------------------
707
+ // tails[i] = index in arr of the smallest tail element for LIS of length i+1
708
+ const tails = [];
709
+ // prev[i] = predecessor index in arr for the LIS ending at arr[i]
710
+ const prev = new Array(len).fill(-1);
711
+ const tailIndices = []; // parallel to tails: actual positions
633
712
 
634
- attr(name, value) {
635
- if (typeof name === 'object' && name !== null) {
636
- return this.each((_, el) => {
637
- for (const [k, v] of Object.entries(name)) el.setAttribute(k, v);
638
- });
713
+ for (let i = 0; i < len; i++) {
714
+ if (arr[i] === -1) continue;
715
+ const val = arr[i];
716
+
717
+ // Binary search for insertion point in tails
718
+ let lo = 0, hi = tails.length;
719
+ while (lo < hi) {
720
+ const mid = (lo + hi) >> 1;
721
+ if (tails[mid] < val) lo = mid + 1;
722
+ else hi = mid;
639
723
  }
640
- if (value === undefined) return this.first()?.getAttribute(name);
641
- return this.each((_, el) => el.setAttribute(name, value));
724
+
725
+ tails[lo] = val;
726
+ tailIndices[lo] = i;
727
+ prev[i] = lo > 0 ? tailIndices[lo - 1] : -1;
642
728
  }
643
729
 
644
- removeAttr(name) {
645
- return this.each((_, el) => el.removeAttribute(name));
730
+ // Reconstruct: walk backwards from the last element of LIS
731
+ let k = tailIndices[tails.length - 1];
732
+ for (let i = tails.length - 1; i >= 0; i--) {
733
+ result.add(k);
734
+ k = prev[k];
646
735
  }
647
736
 
648
- prop(name, value) {
649
- if (value === undefined) return this.first()?.[name];
650
- return this.each((_, el) => { el[name] = value; });
651
- }
737
+ return result;
738
+ }
652
739
 
653
- data(key, value) {
654
- if (value === undefined) {
655
- if (key === undefined) return this.first()?.dataset;
656
- const raw = this.first()?.dataset[key];
657
- try { return JSON.parse(raw); } catch { return raw; }
740
+ /**
741
+ * Morph a single node in place.
742
+ */
743
+ function _morphNode(parent, oldNode, newNode) {
744
+ // Text / comment nodes just update content
745
+ if (oldNode.nodeType === 3 || oldNode.nodeType === 8) {
746
+ if (newNode.nodeType === oldNode.nodeType) {
747
+ if (oldNode.nodeValue !== newNode.nodeValue) {
748
+ oldNode.nodeValue = newNode.nodeValue;
749
+ }
750
+ return;
658
751
  }
659
- return this.each((_, el) => { el.dataset[key] = typeof value === 'object' ? JSON.stringify(value) : value; });
752
+ // Different node types replace
753
+ parent.replaceChild(newNode.cloneNode(true), oldNode);
754
+ return;
660
755
  }
661
756
 
662
- // --- CSS / Dimensions ----------------------------------------------------
757
+ // Different node types or tag names — replace entirely
758
+ if (oldNode.nodeType !== newNode.nodeType ||
759
+ oldNode.nodeName !== newNode.nodeName) {
760
+ parent.replaceChild(newNode.cloneNode(true), oldNode);
761
+ return;
762
+ }
663
763
 
664
- css(props, value) {
665
- if (typeof props === 'string' && value !== undefined) {
666
- return this.each((_, el) => { el.style[props] = value; });
764
+ // Both are elements — diff attributes then recurse children
765
+ if (oldNode.nodeType === 1) {
766
+ // z-skip: developer opt-out skip diffing this subtree entirely.
767
+ // Useful for third-party widgets, canvas, video, or large static content.
768
+ if (oldNode.hasAttribute('z-skip')) return;
769
+
770
+ // Fast bail-out: if the elements are identical, skip everything.
771
+ // isEqualNode() is a native C++ comparison — much faster than walking
772
+ // attributes + children in JS when trees haven't changed.
773
+ if (oldNode.isEqualNode(newNode)) return;
774
+
775
+ _morphAttributes(oldNode, newNode);
776
+
777
+ // Special elements: don't recurse into their children
778
+ const tag = oldNode.nodeName;
779
+ if (tag === 'INPUT') {
780
+ _syncInputValue(oldNode, newNode);
781
+ return;
667
782
  }
668
- if (typeof props === 'string') {
669
- const el = this.first();
670
- return el ? getComputedStyle(el)[props] : undefined;
783
+ if (tag === 'TEXTAREA') {
784
+ if (oldNode.value !== newNode.textContent) {
785
+ oldNode.value = newNode.textContent || '';
786
+ }
787
+ return;
788
+ }
789
+ if (tag === 'SELECT') {
790
+ _morphChildren(oldNode, newNode);
791
+ if (oldNode.value !== newNode.value) {
792
+ oldNode.value = newNode.value;
793
+ }
794
+ return;
671
795
  }
672
- return this.each((_, el) => Object.assign(el.style, props));
673
- }
674
-
675
- width() { return this.first()?.getBoundingClientRect().width; }
676
- height() { return this.first()?.getBoundingClientRect().height; }
677
796
 
678
- offset() {
679
- const r = this.first()?.getBoundingClientRect();
680
- return r ? { top: r.top + window.scrollY, left: r.left + window.scrollX, width: r.width, height: r.height } : null;
797
+ // Generic element — recurse children
798
+ _morphChildren(oldNode, newNode);
681
799
  }
800
+ }
682
801
 
683
- position() {
684
- const el = this.first();
685
- return el ? { top: el.offsetTop, left: el.offsetLeft } : null;
686
- }
802
+ /**
803
+ * Sync attributes from newEl onto oldEl.
804
+ * Uses a single pass: build a set of new attribute names, iterate
805
+ * old attrs for removals, then apply new attrs.
806
+ */
807
+ function _morphAttributes(oldEl, newEl) {
808
+ const newAttrs = newEl.attributes;
809
+ const oldAttrs = oldEl.attributes;
810
+ const newLen = newAttrs.length;
811
+ const oldLen = oldAttrs.length;
687
812
 
688
- scrollTop(value) {
689
- if (value === undefined) {
690
- const el = this.first();
691
- return el === window ? window.scrollY : el?.scrollTop;
813
+ // Fast path: if both have same number of attributes, check if they're identical
814
+ if (newLen === oldLen) {
815
+ let same = true;
816
+ for (let i = 0; i < newLen; i++) {
817
+ const na = newAttrs[i];
818
+ if (oldEl.getAttribute(na.name) !== na.value) { same = false; break; }
692
819
  }
693
- return this.each((_, el) => {
694
- if (el === window) window.scrollTo(window.scrollX, value);
695
- else el.scrollTop = value;
696
- });
820
+ if (same) {
821
+ // Also verify no extra old attrs (names mismatch)
822
+ for (let i = 0; i < oldLen; i++) {
823
+ if (!newEl.hasAttribute(oldAttrs[i].name)) { same = false; break; }
824
+ }
825
+ }
826
+ if (same) return;
697
827
  }
698
828
 
699
- scrollLeft(value) {
700
- if (value === undefined) {
701
- const el = this.first();
702
- return el === window ? window.scrollX : el?.scrollLeft;
829
+ // Build set of new attr names for O(1) lookup during removal pass
830
+ const newNames = new Set();
831
+ for (let i = 0; i < newLen; i++) {
832
+ const attr = newAttrs[i];
833
+ newNames.add(attr.name);
834
+ if (oldEl.getAttribute(attr.name) !== attr.value) {
835
+ oldEl.setAttribute(attr.name, attr.value);
703
836
  }
704
- return this.each((_, el) => {
705
- if (el === window) window.scrollTo(value, window.scrollY);
706
- else el.scrollLeft = value;
707
- });
708
837
  }
709
838
 
710
- innerWidth() {
711
- const el = this.first();
712
- return el?.clientWidth;
839
+ // Remove stale attributes — snapshot names first because oldAttrs
840
+ // is a live NamedNodeMap that mutates on removeAttribute().
841
+ const oldNames = new Array(oldLen);
842
+ for (let i = 0; i < oldLen; i++) oldNames[i] = oldAttrs[i].name;
843
+ for (let i = oldNames.length - 1; i >= 0; i--) {
844
+ if (!newNames.has(oldNames[i])) {
845
+ oldEl.removeAttribute(oldNames[i]);
846
+ }
713
847
  }
848
+ }
714
849
 
715
- innerHeight() {
716
- const el = this.first();
717
- return el?.clientHeight;
718
- }
850
+ /**
851
+ * Sync input element value, checked, disabled states.
852
+ *
853
+ * Only updates the value when the new HTML explicitly carries a `value`
854
+ * attribute. Templates that use z-model manage values through reactive
855
+ * state + _bindModels — the morph engine should not interfere by wiping
856
+ * a live input's content to '' just because the template has no `value`
857
+ * attr. This prevents the wipe-then-restore cycle that resets cursor
858
+ * position on every keystroke.
859
+ */
860
+ function _syncInputValue(oldEl, newEl) {
861
+ const type = (oldEl.type || '').toLowerCase();
719
862
 
720
- outerWidth(includeMargin = false) {
721
- const el = this.first();
722
- if (!el) return undefined;
723
- let w = el.offsetWidth;
724
- if (includeMargin) {
725
- const style = getComputedStyle(el);
726
- w += parseFloat(style.marginLeft) + parseFloat(style.marginRight);
863
+ if (type === 'checkbox' || type === 'radio') {
864
+ if (oldEl.checked !== newEl.checked) oldEl.checked = newEl.checked;
865
+ } else {
866
+ const newVal = newEl.getAttribute('value');
867
+ if (newVal !== null && oldEl.value !== newVal) {
868
+ oldEl.value = newVal;
727
869
  }
728
- return w;
729
870
  }
730
871
 
731
- outerHeight(includeMargin = false) {
732
- const el = this.first();
733
- if (!el) return undefined;
734
- let h = el.offsetHeight;
735
- if (includeMargin) {
736
- const style = getComputedStyle(el);
737
- h += parseFloat(style.marginTop) + parseFloat(style.marginBottom);
738
- }
739
- return h;
740
- }
872
+ // Sync disabled
873
+ if (oldEl.disabled !== newEl.disabled) oldEl.disabled = newEl.disabled;
874
+ }
741
875
 
742
- // --- Content -------------------------------------------------------------
876
+ /**
877
+ * Get the reconciliation key from a node.
878
+ *
879
+ * Priority: z-key attribute → id attribute → data-id / data-key.
880
+ * Auto-detected keys use a `\0` prefix to avoid collisions with
881
+ * explicit z-key values.
882
+ *
883
+ * This means the LIS-optimised keyed path activates automatically
884
+ * whenever elements carry `id` or `data-id` / `data-key` attributes
885
+ * — no extra markup required.
886
+ *
887
+ * @returns {string|null}
888
+ */
889
+ function _getKey(node) {
890
+ if (node.nodeType !== 1) return null;
743
891
 
744
- html(content) {
745
- if (content === undefined) return this.first()?.innerHTML;
746
- // Auto-morph: if the element already has children, use the diff engine
747
- // to patch the DOM (preserves focus, scroll, state, keyed reorder via LIS).
748
- // Empty elements get raw innerHTML for fast first-paint — same strategy
749
- // the component system uses (first render = innerHTML, updates = morph).
750
- return this.each((_, el) => {
751
- if (el.childNodes.length > 0) {
752
- _morph(el, content);
753
- } else {
754
- el.innerHTML = content;
755
- }
756
- });
757
- }
892
+ // Explicit z-key — highest priority
893
+ const zk = node.getAttribute('z-key');
894
+ if (zk) return zk;
758
895
 
759
- morph(content) {
760
- return this.each((_, el) => { _morph(el, content); });
761
- }
896
+ // Auto-key: id attribute (unique by spec)
897
+ if (node.id) return '\0id:' + node.id;
762
898
 
763
- text(content) {
764
- if (content === undefined) return this.first()?.textContent;
765
- return this.each((_, el) => { el.textContent = content; });
899
+ // Auto-key: data-id or data-key attributes
900
+ const ds = node.dataset;
901
+ if (ds) {
902
+ if (ds.id) return '\0data-id:' + ds.id;
903
+ if (ds.key) return '\0data-key:' + ds.key;
766
904
  }
767
905
 
768
- val(value) {
769
- if (value === undefined) return this.first()?.value;
770
- return this.each((_, el) => { el.value = value; });
906
+ return null;
907
+ }
908
+
909
+ // --- src/core.js -------------------------------------------------
910
+ /**
911
+ * zQuery Core — Selector engine & chainable DOM collection
912
+ *
913
+ * Extends the quick-ref pattern (Id, Class, Classes, Children)
914
+ * into a full jQuery-like chainable wrapper with modern APIs.
915
+ */
916
+
917
+
918
+ // ---------------------------------------------------------------------------
919
+ // ZQueryCollection — wraps an array of elements with chainable methods
920
+ // ---------------------------------------------------------------------------
921
+ class ZQueryCollection {
922
+ constructor(elements) {
923
+ this.elements = Array.isArray(elements) ? elements : (elements ? [elements] : []);
924
+ this.length = this.elements.length;
925
+ this.elements.forEach((el, i) => { this[i] = el; });
771
926
  }
772
927
 
773
- // --- DOM Manipulation ----------------------------------------------------
928
+ // --- Iteration -----------------------------------------------------------
774
929
 
775
- append(content) {
776
- return this.each((_, el) => {
777
- if (typeof content === 'string') el.insertAdjacentHTML('beforeend', content);
778
- else if (content instanceof ZQueryCollection) content.each((__, c) => el.appendChild(c));
779
- else if (content instanceof Node) el.appendChild(content);
780
- });
930
+ each(fn) {
931
+ this.elements.forEach((el, i) => fn.call(el, i, el));
932
+ return this;
781
933
  }
782
934
 
783
- prepend(content) {
784
- return this.each((_, el) => {
785
- if (typeof content === 'string') el.insertAdjacentHTML('afterbegin', content);
786
- else if (content instanceof Node) el.insertBefore(content, el.firstChild);
787
- });
935
+ map(fn) {
936
+ return this.elements.map((el, i) => fn.call(el, i, el));
788
937
  }
789
938
 
790
- after(content) {
791
- return this.each((_, el) => {
792
- if (typeof content === 'string') el.insertAdjacentHTML('afterend', content);
793
- else if (content instanceof Node) el.parentNode.insertBefore(content, el.nextSibling);
794
- });
939
+ forEach(fn) {
940
+ this.elements.forEach((el, i) => fn(el, i, this.elements));
941
+ return this;
795
942
  }
796
943
 
797
- before(content) {
798
- return this.each((_, el) => {
799
- if (typeof content === 'string') el.insertAdjacentHTML('beforebegin', content);
800
- else if (content instanceof Node) el.parentNode.insertBefore(content, el);
801
- });
802
- }
944
+ first() { return this.elements[0] || null; }
945
+ last() { return this.elements[this.length - 1] || null; }
946
+ eq(i) { return new ZQueryCollection(this.elements[i] ? [this.elements[i]] : []); }
947
+ toArray(){ return [...this.elements]; }
803
948
 
804
- wrap(wrapper) {
805
- return this.each((_, el) => {
806
- const w = typeof wrapper === 'string' ? createFragment(wrapper).firstElementChild : wrapper.cloneNode(true);
807
- if (!w || !el.parentNode) return;
808
- el.parentNode.insertBefore(w, el);
809
- w.appendChild(el);
810
- });
949
+ [Symbol.iterator]() { return this.elements[Symbol.iterator](); }
950
+
951
+ // --- Traversal -----------------------------------------------------------
952
+
953
+ find(selector) {
954
+ const found = [];
955
+ this.elements.forEach(el => found.push(...el.querySelectorAll(selector)));
956
+ return new ZQueryCollection(found);
811
957
  }
812
958
 
813
- remove() {
814
- return this.each((_, el) => el.remove());
959
+ parent() {
960
+ const parents = [...new Set(this.elements.map(el => el.parentElement).filter(Boolean))];
961
+ return new ZQueryCollection(parents);
815
962
  }
816
963
 
817
- empty() {
818
- // textContent = '' clears all children without invoking the HTML parser
819
- return this.each((_, el) => { el.textContent = ''; });
964
+ closest(selector) {
965
+ return new ZQueryCollection(
966
+ this.elements.map(el => el.closest(selector)).filter(Boolean)
967
+ );
820
968
  }
821
969
 
822
- clone(deep = true) {
823
- return new ZQueryCollection(this.elements.map(el => el.cloneNode(deep)));
970
+ children(selector) {
971
+ const kids = [];
972
+ this.elements.forEach(el => {
973
+ kids.push(...(selector
974
+ ? el.querySelectorAll(`:scope > ${selector}`)
975
+ : el.children));
976
+ });
977
+ return new ZQueryCollection([...kids]);
824
978
  }
825
979
 
826
- replaceWith(content) {
827
- return this.each((_, el) => {
828
- if (typeof content === 'string') {
829
- // Auto-morph: diff attributes + children when the tag name matches
830
- // instead of destroying and re-creating the element.
831
- _morphElement(el, content);
832
- } else if (content instanceof Node) {
833
- el.parentNode.replaceChild(content, el);
834
- }
980
+ siblings(selector) {
981
+ const sibs = [];
982
+ this.elements.forEach(el => {
983
+ if (!el.parentElement) return;
984
+ const all = [...el.parentElement.children].filter(c => c !== el);
985
+ sibs.push(...(selector ? all.filter(c => c.matches(selector)) : all));
835
986
  });
987
+ return new ZQueryCollection(sibs);
836
988
  }
837
989
 
838
- appendTo(target) {
839
- const dest = typeof target === 'string' ? document.querySelector(target) : target instanceof ZQueryCollection ? target.first() : target;
840
- if (dest) this.each((_, el) => dest.appendChild(el));
841
- return this;
990
+ next(selector) {
991
+ const els = this.elements.map(el => el.nextElementSibling).filter(Boolean);
992
+ return new ZQueryCollection(selector ? els.filter(el => el.matches(selector)) : els);
842
993
  }
843
994
 
844
- prependTo(target) {
845
- const dest = typeof target === 'string' ? document.querySelector(target) : target instanceof ZQueryCollection ? target.first() : target;
846
- if (dest) this.each((_, el) => dest.insertBefore(el, dest.firstChild));
847
- return this;
995
+ prev(selector) {
996
+ const els = this.elements.map(el => el.previousElementSibling).filter(Boolean);
997
+ return new ZQueryCollection(selector ? els.filter(el => el.matches(selector)) : els);
848
998
  }
849
999
 
850
- insertAfter(target) {
851
- const ref = typeof target === 'string' ? document.querySelector(target) : target instanceof ZQueryCollection ? target.first() : target;
852
- if (ref && ref.parentNode) this.each((_, el) => ref.parentNode.insertBefore(el, ref.nextSibling));
853
- return this;
1000
+ nextAll(selector) {
1001
+ const result = [];
1002
+ this.elements.forEach(el => {
1003
+ let sib = el.nextElementSibling;
1004
+ while (sib) {
1005
+ if (!selector || sib.matches(selector)) result.push(sib);
1006
+ sib = sib.nextElementSibling;
1007
+ }
1008
+ });
1009
+ return new ZQueryCollection(result);
854
1010
  }
855
1011
 
856
- insertBefore(target) {
857
- const ref = typeof target === 'string' ? document.querySelector(target) : target instanceof ZQueryCollection ? target.first() : target;
858
- if (ref && ref.parentNode) this.each((_, el) => ref.parentNode.insertBefore(el, ref));
859
- return this;
1012
+ nextUntil(selector, filter) {
1013
+ const result = [];
1014
+ this.elements.forEach(el => {
1015
+ let sib = el.nextElementSibling;
1016
+ while (sib) {
1017
+ if (selector && sib.matches(selector)) break;
1018
+ if (!filter || sib.matches(filter)) result.push(sib);
1019
+ sib = sib.nextElementSibling;
1020
+ }
1021
+ });
1022
+ return new ZQueryCollection(result);
860
1023
  }
861
1024
 
862
- replaceAll(target) {
863
- const targets = typeof target === 'string'
864
- ? Array.from(document.querySelectorAll(target))
865
- : target instanceof ZQueryCollection ? target.elements : [target];
866
- targets.forEach((t, i) => {
867
- const nodes = i === 0 ? this.elements : this.elements.map(el => el.cloneNode(true));
868
- nodes.forEach(el => t.parentNode.insertBefore(el, t));
869
- t.remove();
1025
+ prevAll(selector) {
1026
+ const result = [];
1027
+ this.elements.forEach(el => {
1028
+ let sib = el.previousElementSibling;
1029
+ while (sib) {
1030
+ if (!selector || sib.matches(selector)) result.push(sib);
1031
+ sib = sib.previousElementSibling;
1032
+ }
870
1033
  });
871
- return this;
1034
+ return new ZQueryCollection(result);
872
1035
  }
873
1036
 
874
- unwrap(selector) {
1037
+ prevUntil(selector, filter) {
1038
+ const result = [];
875
1039
  this.elements.forEach(el => {
876
- const parent = el.parentElement;
877
- if (!parent || parent === document.body) return;
878
- if (selector && !parent.matches(selector)) return;
879
- parent.replaceWith(...parent.childNodes);
1040
+ let sib = el.previousElementSibling;
1041
+ while (sib) {
1042
+ if (selector && sib.matches(selector)) break;
1043
+ if (!filter || sib.matches(filter)) result.push(sib);
1044
+ sib = sib.previousElementSibling;
1045
+ }
880
1046
  });
881
- return this;
1047
+ return new ZQueryCollection(result);
882
1048
  }
883
1049
 
884
- wrapAll(wrapper) {
885
- const w = typeof wrapper === 'string' ? createFragment(wrapper).firstElementChild : wrapper.cloneNode(true);
886
- const first = this.first();
887
- if (!first) return this;
888
- first.parentNode.insertBefore(w, first);
889
- this.each((_, el) => w.appendChild(el));
890
- return this;
1050
+ parents(selector) {
1051
+ const result = [];
1052
+ this.elements.forEach(el => {
1053
+ let parent = el.parentElement;
1054
+ while (parent) {
1055
+ if (!selector || parent.matches(selector)) result.push(parent);
1056
+ parent = parent.parentElement;
1057
+ }
1058
+ });
1059
+ return new ZQueryCollection([...new Set(result)]);
891
1060
  }
892
1061
 
893
- wrapInner(wrapper) {
894
- return this.each((_, el) => {
895
- const w = typeof wrapper === 'string' ? createFragment(wrapper).firstElementChild : wrapper.cloneNode(true);
896
- while (el.firstChild) w.appendChild(el.firstChild);
897
- el.appendChild(w);
1062
+ parentsUntil(selector, filter) {
1063
+ const result = [];
1064
+ this.elements.forEach(el => {
1065
+ let parent = el.parentElement;
1066
+ while (parent) {
1067
+ if (selector && parent.matches(selector)) break;
1068
+ if (!filter || parent.matches(filter)) result.push(parent);
1069
+ parent = parent.parentElement;
1070
+ }
898
1071
  });
1072
+ return new ZQueryCollection([...new Set(result)]);
899
1073
  }
900
1074
 
901
- detach() {
902
- return this.each((_, el) => el.remove());
1075
+ contents() {
1076
+ const result = [];
1077
+ this.elements.forEach(el => result.push(...el.childNodes));
1078
+ return new ZQueryCollection(result);
903
1079
  }
904
1080
 
905
- // --- Visibility ----------------------------------------------------------
1081
+ filter(selector) {
1082
+ if (typeof selector === 'function') {
1083
+ return new ZQueryCollection(this.elements.filter(selector));
1084
+ }
1085
+ return new ZQueryCollection(this.elements.filter(el => el.matches(selector)));
1086
+ }
906
1087
 
907
- show(display = '') {
908
- return this.each((_, el) => { el.style.display = display; });
1088
+ not(selector) {
1089
+ if (typeof selector === 'function') {
1090
+ return new ZQueryCollection(this.elements.filter((el, i) => !selector.call(el, i, el)));
1091
+ }
1092
+ return new ZQueryCollection(this.elements.filter(el => !el.matches(selector)));
909
1093
  }
910
1094
 
911
- hide() {
912
- return this.each((_, el) => { el.style.display = 'none'; });
1095
+ has(selector) {
1096
+ return new ZQueryCollection(this.elements.filter(el => el.querySelector(selector)));
913
1097
  }
914
1098
 
915
- toggle(display = '') {
916
- return this.each((_, el) => {
917
- // Check inline style first (cheap) before forcing layout via getComputedStyle
918
- const hidden = el.style.display === 'none' || (el.style.display !== '' ? false : getComputedStyle(el).display === 'none');
919
- el.style.display = hidden ? display : 'none';
920
- });
1099
+ is(selector) {
1100
+ if (typeof selector === 'function') {
1101
+ return this.elements.some((el, i) => selector.call(el, i, el));
1102
+ }
1103
+ return this.elements.some(el => el.matches(selector));
921
1104
  }
922
1105
 
923
- // --- Events --------------------------------------------------------------
1106
+ slice(start, end) {
1107
+ return new ZQueryCollection(this.elements.slice(start, end));
1108
+ }
924
1109
 
925
- on(event, selectorOrHandler, handler) {
926
- // Support multiple events: "click mouseenter"
927
- const events = event.split(/\s+/);
928
- return this.each((_, el) => {
929
- events.forEach(evt => {
930
- if (typeof selectorOrHandler === 'function') {
931
- el.addEventListener(evt, selectorOrHandler);
932
- } else if (typeof selectorOrHandler === 'string') {
933
- // Delegated event — store wrapper so off() can remove it
934
- const wrapper = (e) => {
935
- if (!e.target || typeof e.target.closest !== 'function') return;
936
- const target = e.target.closest(selectorOrHandler);
937
- if (target && el.contains(target)) handler.call(target, e);
938
- };
939
- wrapper._zqOriginal = handler;
940
- wrapper._zqSelector = selectorOrHandler;
941
- el.addEventListener(evt, wrapper);
942
- // Track delegated handlers for removal
943
- if (!el._zqDelegated) el._zqDelegated = [];
944
- el._zqDelegated.push({ evt, wrapper });
945
- }
946
- });
947
- });
1110
+ add(selector, context) {
1111
+ const toAdd = (selector instanceof ZQueryCollection)
1112
+ ? selector.elements
1113
+ : (selector instanceof Node)
1114
+ ? [selector]
1115
+ : Array.from((context || document).querySelectorAll(selector));
1116
+ return new ZQueryCollection([...this.elements, ...toAdd]);
948
1117
  }
949
1118
 
950
- off(event, handler) {
951
- const events = event.split(/\s+/);
952
- return this.each((_, el) => {
953
- events.forEach(evt => {
954
- // Try direct removal first
955
- el.removeEventListener(evt, handler);
956
- // Also check delegated handlers
957
- if (el._zqDelegated) {
958
- el._zqDelegated = el._zqDelegated.filter(d => {
959
- if (d.evt === evt && d.wrapper._zqOriginal === handler) {
960
- el.removeEventListener(evt, d.wrapper);
961
- return false;
962
- }
963
- return true;
964
- });
965
- }
966
- });
967
- });
1119
+ get(index) {
1120
+ if (index === undefined) return [...this.elements];
1121
+ return index < 0 ? this.elements[this.length + index] : this.elements[index];
968
1122
  }
969
1123
 
970
- one(event, handler) {
971
- return this.each((_, el) => {
972
- el.addEventListener(event, handler, { once: true });
973
- });
1124
+ index(selector) {
1125
+ if (selector === undefined) {
1126
+ const el = this.first();
1127
+ if (!el || !el.parentElement) return -1;
1128
+ return Array.from(el.parentElement.children).indexOf(el);
1129
+ }
1130
+ const target = (typeof selector === 'string')
1131
+ ? document.querySelector(selector)
1132
+ : selector;
1133
+ return this.elements.indexOf(target);
974
1134
  }
975
1135
 
976
- trigger(event, detail) {
977
- return this.each((_, el) => {
978
- el.dispatchEvent(new CustomEvent(event, { detail, bubbles: true, cancelable: true }));
979
- });
1136
+ // --- Classes -------------------------------------------------------------
1137
+
1138
+ addClass(...names) {
1139
+ // Fast path: single class, no spaces — avoids flatMap + regex split allocation
1140
+ if (names.length === 1 && names[0].indexOf(' ') === -1) {
1141
+ const c = names[0];
1142
+ for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.add(c);
1143
+ return this;
1144
+ }
1145
+ const classes = names.flatMap(n => n.split(/\s+/));
1146
+ for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.add(...classes);
1147
+ return this;
980
1148
  }
981
1149
 
982
- // Convenience event shorthands
983
- click(fn) { return fn ? this.on('click', fn) : this.trigger('click'); }
984
- submit(fn) { return fn ? this.on('submit', fn) : this.trigger('submit'); }
985
- focus() { this.first()?.focus(); return this; }
986
- blur() { this.first()?.blur(); return this; }
987
- hover(enterFn, leaveFn) {
988
- this.on('mouseenter', enterFn);
989
- return this.on('mouseleave', leaveFn || enterFn);
1150
+ removeClass(...names) {
1151
+ if (names.length === 1 && names[0].indexOf(' ') === -1) {
1152
+ const c = names[0];
1153
+ for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.remove(c);
1154
+ return this;
1155
+ }
1156
+ const classes = names.flatMap(n => n.split(/\s+/));
1157
+ for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.remove(...classes);
1158
+ return this;
990
1159
  }
991
1160
 
992
- // --- Animation -----------------------------------------------------------
1161
+ toggleClass(...args) {
1162
+ const force = typeof args[args.length - 1] === 'boolean' ? args.pop() : undefined;
1163
+ // Fast path: single class, no spaces
1164
+ if (args.length === 1 && args[0].indexOf(' ') === -1) {
1165
+ const c = args[0];
1166
+ for (let i = 0; i < this.elements.length; i++) {
1167
+ force !== undefined ? this.elements[i].classList.toggle(c, force) : this.elements[i].classList.toggle(c);
1168
+ }
1169
+ return this;
1170
+ }
1171
+ const classes = args.flatMap(n => n.split(/\s+/));
1172
+ for (let i = 0; i < this.elements.length; i++) {
1173
+ const el = this.elements[i];
1174
+ for (let j = 0; j < classes.length; j++) {
1175
+ force !== undefined ? el.classList.toggle(classes[j], force) : el.classList.toggle(classes[j]);
1176
+ }
1177
+ }
1178
+ return this;
1179
+ }
993
1180
 
994
- animate(props, duration = 300, easing = 'ease') {
995
- // Empty collection — resolve immediately
996
- if (this.length === 0) return Promise.resolve(this);
997
- return new Promise(resolve => {
998
- let resolved = false;
999
- const count = { done: 0 };
1000
- const listeners = [];
1001
- this.each((_, el) => {
1002
- el.style.transition = `all ${duration}ms ${easing}`;
1003
- requestAnimationFrame(() => {
1004
- Object.assign(el.style, props);
1005
- const onEnd = () => {
1006
- el.removeEventListener('transitionend', onEnd);
1007
- el.style.transition = '';
1008
- if (!resolved && ++count.done >= this.length) {
1009
- resolved = true;
1010
- resolve(this);
1011
- }
1012
- };
1013
- el.addEventListener('transitionend', onEnd);
1014
- listeners.push({ el, onEnd });
1015
- });
1181
+ hasClass(name) {
1182
+ return this.first()?.classList.contains(name) || false;
1183
+ }
1184
+
1185
+ // --- Attributes ----------------------------------------------------------
1186
+
1187
+ attr(name, value) {
1188
+ if (typeof name === 'object' && name !== null) {
1189
+ return this.each((_, el) => {
1190
+ for (const [k, v] of Object.entries(name)) el.setAttribute(k, v);
1016
1191
  });
1017
- // Fallback in case transitionend doesn't fire
1018
- setTimeout(() => {
1019
- if (!resolved) {
1020
- resolved = true;
1021
- // Clean up any remaining transitionend listeners
1022
- for (const { el, onEnd } of listeners) {
1023
- el.removeEventListener('transitionend', onEnd);
1024
- el.style.transition = '';
1025
- }
1026
- resolve(this);
1027
- }
1028
- }, duration + 50);
1029
- });
1192
+ }
1193
+ if (value === undefined) return this.first()?.getAttribute(name);
1194
+ return this.each((_, el) => el.setAttribute(name, value));
1030
1195
  }
1031
1196
 
1032
- fadeIn(duration = 300) {
1033
- return this.css({ opacity: '0', display: '' }).animate({ opacity: '1' }, duration);
1197
+ removeAttr(name) {
1198
+ return this.each((_, el) => el.removeAttribute(name));
1034
1199
  }
1035
1200
 
1036
- fadeOut(duration = 300) {
1037
- return this.animate({ opacity: '0' }, duration).then(col => col.hide());
1201
+ prop(name, value) {
1202
+ if (value === undefined) return this.first()?.[name];
1203
+ return this.each((_, el) => { el[name] = value; });
1038
1204
  }
1039
1205
 
1040
- fadeToggle(duration = 300) {
1041
- return Promise.all(this.elements.map(el => {
1042
- const cs = getComputedStyle(el);
1043
- const visible = cs.opacity !== '0' && cs.display !== 'none';
1044
- const col = new ZQueryCollection([el]);
1045
- return visible ? col.fadeOut(duration) : col.fadeIn(duration);
1046
- })).then(() => this);
1206
+ data(key, value) {
1207
+ if (value === undefined) {
1208
+ if (key === undefined) return this.first()?.dataset;
1209
+ const raw = this.first()?.dataset[key];
1210
+ try { return JSON.parse(raw); } catch { return raw; }
1211
+ }
1212
+ return this.each((_, el) => { el.dataset[key] = typeof value === 'object' ? JSON.stringify(value) : value; });
1047
1213
  }
1048
1214
 
1049
- fadeTo(duration, opacity) {
1050
- return this.animate({ opacity: String(opacity) }, duration);
1215
+ // --- CSS / Dimensions ----------------------------------------------------
1216
+
1217
+ css(props, value) {
1218
+ if (typeof props === 'string' && value !== undefined) {
1219
+ return this.each((_, el) => { el.style[props] = value; });
1220
+ }
1221
+ if (typeof props === 'string') {
1222
+ const el = this.first();
1223
+ return el ? getComputedStyle(el)[props] : undefined;
1224
+ }
1225
+ return this.each((_, el) => Object.assign(el.style, props));
1051
1226
  }
1052
1227
 
1053
- slideDown(duration = 300) {
1054
- return this.each((_, el) => {
1055
- el.style.display = '';
1056
- el.style.overflow = 'hidden';
1057
- const h = el.scrollHeight + 'px';
1058
- el.style.maxHeight = '0';
1059
- el.style.transition = `max-height ${duration}ms ease`;
1060
- requestAnimationFrame(() => { el.style.maxHeight = h; });
1061
- setTimeout(() => { el.style.maxHeight = ''; el.style.overflow = ''; el.style.transition = ''; }, duration);
1062
- });
1228
+ width() { return this.first()?.getBoundingClientRect().width; }
1229
+ height() { return this.first()?.getBoundingClientRect().height; }
1230
+
1231
+ offset() {
1232
+ const r = this.first()?.getBoundingClientRect();
1233
+ return r ? { top: r.top + window.scrollY, left: r.left + window.scrollX, width: r.width, height: r.height } : null;
1063
1234
  }
1064
1235
 
1065
- slideUp(duration = 300) {
1236
+ position() {
1237
+ const el = this.first();
1238
+ return el ? { top: el.offsetTop, left: el.offsetLeft } : null;
1239
+ }
1240
+
1241
+ scrollTop(value) {
1242
+ if (value === undefined) {
1243
+ const el = this.first();
1244
+ return el === window ? window.scrollY : el?.scrollTop;
1245
+ }
1066
1246
  return this.each((_, el) => {
1067
- el.style.overflow = 'hidden';
1068
- el.style.maxHeight = el.scrollHeight + 'px';
1069
- el.style.transition = `max-height ${duration}ms ease`;
1070
- requestAnimationFrame(() => { el.style.maxHeight = '0'; });
1071
- setTimeout(() => { el.style.display = 'none'; el.style.maxHeight = ''; el.style.overflow = ''; el.style.transition = ''; }, duration);
1247
+ if (el === window) window.scrollTo(window.scrollX, value);
1248
+ else el.scrollTop = value;
1072
1249
  });
1073
1250
  }
1074
1251
 
1075
- slideToggle(duration = 300) {
1252
+ scrollLeft(value) {
1253
+ if (value === undefined) {
1254
+ const el = this.first();
1255
+ return el === window ? window.scrollX : el?.scrollLeft;
1256
+ }
1076
1257
  return this.each((_, el) => {
1077
- if (el.style.display === 'none' || getComputedStyle(el).display === 'none') {
1078
- el.style.display = '';
1079
- el.style.overflow = 'hidden';
1080
- const h = el.scrollHeight + 'px';
1081
- el.style.maxHeight = '0';
1082
- el.style.transition = `max-height ${duration}ms ease`;
1083
- requestAnimationFrame(() => { el.style.maxHeight = h; });
1084
- setTimeout(() => { el.style.maxHeight = ''; el.style.overflow = ''; el.style.transition = ''; }, duration);
1085
- } else {
1086
- el.style.overflow = 'hidden';
1087
- el.style.maxHeight = el.scrollHeight + 'px';
1088
- el.style.transition = `max-height ${duration}ms ease`;
1089
- requestAnimationFrame(() => { el.style.maxHeight = '0'; });
1090
- setTimeout(() => { el.style.display = 'none'; el.style.maxHeight = ''; el.style.overflow = ''; el.style.transition = ''; }, duration);
1091
- }
1258
+ if (el === window) window.scrollTo(value, window.scrollY);
1259
+ else el.scrollLeft = value;
1092
1260
  });
1093
1261
  }
1094
1262
 
1095
- // --- Form helpers --------------------------------------------------------
1096
-
1097
- serialize() {
1098
- const form = this.first();
1099
- if (!form || form.tagName !== 'FORM') return '';
1100
- return new URLSearchParams(new FormData(form)).toString();
1263
+ innerWidth() {
1264
+ const el = this.first();
1265
+ return el?.clientWidth;
1101
1266
  }
1102
1267
 
1103
- serializeObject() {
1104
- const form = this.first();
1105
- if (!form || form.tagName !== 'FORM') return {};
1106
- const obj = {};
1107
- new FormData(form).forEach((v, k) => {
1108
- if (obj[k] !== undefined) {
1109
- if (!Array.isArray(obj[k])) obj[k] = [obj[k]];
1110
- obj[k].push(v);
1111
- } else {
1112
- obj[k] = v;
1113
- }
1114
- });
1115
- return obj;
1268
+ innerHeight() {
1269
+ const el = this.first();
1270
+ return el?.clientHeight;
1116
1271
  }
1117
- }
1118
-
1119
-
1120
- // ---------------------------------------------------------------------------
1121
- // Helper — create document fragment from HTML string
1122
- // ---------------------------------------------------------------------------
1123
- function createFragment(html) {
1124
- const tpl = document.createElement('template');
1125
- tpl.innerHTML = html.trim();
1126
- return tpl.content;
1127
- }
1128
1272
 
1273
+ outerWidth(includeMargin = false) {
1274
+ const el = this.first();
1275
+ if (!el) return undefined;
1276
+ let w = el.offsetWidth;
1277
+ if (includeMargin) {
1278
+ const style = getComputedStyle(el);
1279
+ w += parseFloat(style.marginLeft) + parseFloat(style.marginRight);
1280
+ }
1281
+ return w;
1282
+ }
1129
1283
 
1130
- // ---------------------------------------------------------------------------
1131
- // $() main selector / creator (returns ZQueryCollection, like jQuery)
1132
- // ---------------------------------------------------------------------------
1133
- function query(selector, context) {
1134
- // null / undefined
1135
- if (!selector) return new ZQueryCollection([]);
1284
+ outerHeight(includeMargin = false) {
1285
+ const el = this.first();
1286
+ if (!el) return undefined;
1287
+ let h = el.offsetHeight;
1288
+ if (includeMargin) {
1289
+ const style = getComputedStyle(el);
1290
+ h += parseFloat(style.marginTop) + parseFloat(style.marginBottom);
1291
+ }
1292
+ return h;
1293
+ }
1136
1294
 
1137
- // Already a collection — return as-is
1138
- if (selector instanceof ZQueryCollection) return selector;
1295
+ // --- Content -------------------------------------------------------------
1139
1296
 
1140
- // DOM element or Window — wrap in collection
1141
- if (selector instanceof Node || selector === window) {
1142
- return new ZQueryCollection([selector]);
1297
+ html(content) {
1298
+ if (content === undefined) return this.first()?.innerHTML;
1299
+ // Auto-morph: if the element already has children, use the diff engine
1300
+ // to patch the DOM (preserves focus, scroll, state, keyed reorder via LIS).
1301
+ // Empty elements get raw innerHTML for fast first-paint — same strategy
1302
+ // the component system uses (first render = innerHTML, updates = morph).
1303
+ return this.each((_, el) => {
1304
+ if (el.childNodes.length > 0) {
1305
+ _morph(el, content);
1306
+ } else {
1307
+ el.innerHTML = content;
1308
+ }
1309
+ });
1143
1310
  }
1144
1311
 
1145
- // NodeList / HTMLCollection / Array — wrap in collection
1146
- if (selector instanceof NodeList || selector instanceof HTMLCollection || Array.isArray(selector)) {
1147
- return new ZQueryCollection(Array.from(selector));
1312
+ morph(content) {
1313
+ return this.each((_, el) => { _morph(el, content); });
1148
1314
  }
1149
1315
 
1150
- // HTML string → create elements, wrap in collection
1151
- if (typeof selector === 'string' && selector.trim().startsWith('<')) {
1152
- const fragment = createFragment(selector);
1153
- return new ZQueryCollection([...fragment.childNodes].filter(n => n.nodeType === 1));
1316
+ text(content) {
1317
+ if (content === undefined) return this.first()?.textContent;
1318
+ return this.each((_, el) => { el.textContent = content; });
1154
1319
  }
1155
1320
 
1156
- // CSS selector string → querySelectorAll (collection)
1157
- if (typeof selector === 'string') {
1158
- const root = context
1159
- ? (typeof context === 'string' ? document.querySelector(context) : context)
1160
- : document;
1161
- return new ZQueryCollection([...root.querySelectorAll(selector)]);
1321
+ val(value) {
1322
+ if (value === undefined) return this.first()?.value;
1323
+ return this.each((_, el) => { el.value = value; });
1162
1324
  }
1163
1325
 
1164
- return new ZQueryCollection([]);
1165
- }
1326
+ // --- DOM Manipulation ----------------------------------------------------
1166
1327
 
1328
+ append(content) {
1329
+ return this.each((_, el) => {
1330
+ if (typeof content === 'string') el.insertAdjacentHTML('beforeend', content);
1331
+ else if (content instanceof ZQueryCollection) content.each((__, c) => el.appendChild(c));
1332
+ else if (content instanceof Node) el.appendChild(content);
1333
+ });
1334
+ }
1167
1335
 
1168
- // ---------------------------------------------------------------------------
1169
- // $.all() — collection selector (returns ZQueryCollection for CSS selectors)
1170
- // ---------------------------------------------------------------------------
1171
- function queryAll(selector, context) {
1172
- // null / undefined
1173
- if (!selector) return new ZQueryCollection([]);
1336
+ prepend(content) {
1337
+ return this.each((_, el) => {
1338
+ if (typeof content === 'string') el.insertAdjacentHTML('afterbegin', content);
1339
+ else if (content instanceof Node) el.insertBefore(content, el.firstChild);
1340
+ });
1341
+ }
1174
1342
 
1175
- // Already a collection
1176
- if (selector instanceof ZQueryCollection) return selector;
1343
+ after(content) {
1344
+ return this.each((_, el) => {
1345
+ if (typeof content === 'string') el.insertAdjacentHTML('afterend', content);
1346
+ else if (content instanceof Node) el.parentNode.insertBefore(content, el.nextSibling);
1347
+ });
1348
+ }
1177
1349
 
1178
- // DOM element or Window
1179
- if (selector instanceof Node || selector === window) {
1180
- return new ZQueryCollection([selector]);
1350
+ before(content) {
1351
+ return this.each((_, el) => {
1352
+ if (typeof content === 'string') el.insertAdjacentHTML('beforebegin', content);
1353
+ else if (content instanceof Node) el.parentNode.insertBefore(content, el);
1354
+ });
1181
1355
  }
1182
1356
 
1183
- // NodeList / HTMLCollection / Array
1184
- if (selector instanceof NodeList || selector instanceof HTMLCollection || Array.isArray(selector)) {
1185
- return new ZQueryCollection(Array.from(selector));
1357
+ wrap(wrapper) {
1358
+ return this.each((_, el) => {
1359
+ const w = typeof wrapper === 'string' ? createFragment(wrapper).firstElementChild : wrapper.cloneNode(true);
1360
+ if (!w || !el.parentNode) return;
1361
+ el.parentNode.insertBefore(w, el);
1362
+ w.appendChild(el);
1363
+ });
1186
1364
  }
1187
1365
 
1188
- // HTML string → create elements
1189
- if (typeof selector === 'string' && selector.trim().startsWith('<')) {
1190
- const fragment = createFragment(selector);
1191
- return new ZQueryCollection([...fragment.childNodes].filter(n => n.nodeType === 1));
1366
+ remove() {
1367
+ return this.each((_, el) => el.remove());
1192
1368
  }
1193
1369
 
1194
- // CSS selector string → querySelectorAll (collection)
1195
- if (typeof selector === 'string') {
1196
- const root = context
1197
- ? (typeof context === 'string' ? document.querySelector(context) : context)
1198
- : document;
1199
- return new ZQueryCollection([...root.querySelectorAll(selector)]);
1370
+ empty() {
1371
+ // textContent = '' clears all children without invoking the HTML parser
1372
+ return this.each((_, el) => { el.textContent = ''; });
1200
1373
  }
1201
1374
 
1202
- return new ZQueryCollection([]);
1203
- }
1375
+ clone(deep = true) {
1376
+ return new ZQueryCollection(this.elements.map(el => el.cloneNode(deep)));
1377
+ }
1204
1378
 
1379
+ replaceWith(content) {
1380
+ return this.each((_, el) => {
1381
+ if (typeof content === 'string') {
1382
+ // Auto-morph: diff attributes + children when the tag name matches
1383
+ // instead of destroying and re-creating the element.
1384
+ _morphElement(el, content);
1385
+ } else if (content instanceof Node) {
1386
+ el.parentNode.replaceChild(content, el);
1387
+ }
1388
+ });
1389
+ }
1205
1390
 
1206
- // ---------------------------------------------------------------------------
1207
- // Quick-ref shortcuts, on $ namespace)
1208
- // ---------------------------------------------------------------------------
1209
- query.id = (id) => document.getElementById(id);
1210
- query.class = (name) => document.querySelector(`.${name}`);
1211
- query.classes = (name) => new ZQueryCollection(Array.from(document.getElementsByClassName(name)));
1212
- query.tag = (name) => new ZQueryCollection(Array.from(document.getElementsByTagName(name)));
1213
- Object.defineProperty(query, 'name', {
1214
- value: (name) => new ZQueryCollection(Array.from(document.getElementsByName(name))),
1215
- writable: true, configurable: true
1216
- });
1217
- query.children = (parentId) => {
1218
- const p = document.getElementById(parentId);
1219
- return new ZQueryCollection(p ? Array.from(p.children) : []);
1220
- };
1221
- query.qs = (sel, ctx = document) => ctx.querySelector(sel);
1222
- query.qsa = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel));
1391
+ appendTo(target) {
1392
+ const dest = typeof target === 'string' ? document.querySelector(target) : target instanceof ZQueryCollection ? target.first() : target;
1393
+ if (dest) this.each((_, el) => dest.appendChild(el));
1394
+ return this;
1395
+ }
1223
1396
 
1224
- // Create element shorthand — returns ZQueryCollection for chaining
1225
- query.create = (tag, attrs = {}, ...children) => {
1226
- const el = document.createElement(tag);
1227
- for (const [k, v] of Object.entries(attrs)) {
1228
- if (k === 'class') el.className = v;
1229
- else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
1230
- else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v);
1231
- else if (k === 'data' && typeof v === 'object') Object.entries(v).forEach(([dk, dv]) => { el.dataset[dk] = dv; });
1232
- else el.setAttribute(k, v);
1397
+ prependTo(target) {
1398
+ const dest = typeof target === 'string' ? document.querySelector(target) : target instanceof ZQueryCollection ? target.first() : target;
1399
+ if (dest) this.each((_, el) => dest.insertBefore(el, dest.firstChild));
1400
+ return this;
1233
1401
  }
1234
- children.flat().forEach(child => {
1235
- if (typeof child === 'string') el.appendChild(document.createTextNode(child));
1236
- else if (child instanceof Node) el.appendChild(child);
1237
- });
1238
- return new ZQueryCollection(el);
1239
- };
1240
1402
 
1241
- // DOM ready
1242
- query.ready = (fn) => {
1243
- if (document.readyState !== 'loading') fn();
1244
- else document.addEventListener('DOMContentLoaded', fn);
1245
- };
1403
+ insertAfter(target) {
1404
+ const ref = typeof target === 'string' ? document.querySelector(target) : target instanceof ZQueryCollection ? target.first() : target;
1405
+ if (ref && ref.parentNode) this.each((_, el) => ref.parentNode.insertBefore(el, ref.nextSibling));
1406
+ return this;
1407
+ }
1246
1408
 
1247
- // Global event listeners — supports direct, delegated, and target-bound forms
1248
- // $.on('keydown', handler) direct listener on document
1249
- // $.on('click', '.btn', handler) delegated via closest()
1250
- // $.on('scroll', window, handler) direct listener on target
1251
- query.on = (event, selectorOrHandler, handler) => {
1252
- if (typeof selectorOrHandler === 'function') {
1253
- // 2-arg: direct document listener (keydown, resize, etc.)
1254
- document.addEventListener(event, selectorOrHandler);
1255
- return;
1409
+ insertBefore(target) {
1410
+ const ref = typeof target === 'string' ? document.querySelector(target) : target instanceof ZQueryCollection ? target.first() : target;
1411
+ if (ref && ref.parentNode) this.each((_, el) => ref.parentNode.insertBefore(el, ref));
1412
+ return this;
1256
1413
  }
1257
- // EventTarget (window, element, etc.) — direct listener on target
1258
- if (typeof selectorOrHandler === 'object' && typeof selectorOrHandler.addEventListener === 'function') {
1259
- selectorOrHandler.addEventListener(event, handler);
1260
- return;
1414
+
1415
+ replaceAll(target) {
1416
+ const targets = typeof target === 'string'
1417
+ ? Array.from(document.querySelectorAll(target))
1418
+ : target instanceof ZQueryCollection ? target.elements : [target];
1419
+ targets.forEach((t, i) => {
1420
+ const nodes = i === 0 ? this.elements : this.elements.map(el => el.cloneNode(true));
1421
+ nodes.forEach(el => t.parentNode.insertBefore(el, t));
1422
+ t.remove();
1423
+ });
1424
+ return this;
1261
1425
  }
1262
- // 3-arg string: delegated
1263
- document.addEventListener(event, (e) => {
1264
- if (!e.target || typeof e.target.closest !== 'function') return;
1265
- const target = e.target.closest(selectorOrHandler);
1266
- if (target) handler.call(target, e);
1267
- });
1268
- };
1269
1426
 
1270
- // Remove a direct global listener
1271
- query.off = (event, handler) => {
1272
- document.removeEventListener(event, handler);
1273
- };
1427
+ unwrap(selector) {
1428
+ this.elements.forEach(el => {
1429
+ const parent = el.parentElement;
1430
+ if (!parent || parent === document.body) return;
1431
+ if (selector && !parent.matches(selector)) return;
1432
+ parent.replaceWith(...parent.childNodes);
1433
+ });
1434
+ return this;
1435
+ }
1274
1436
 
1275
- // Extend collection prototype (like $.fn in jQuery)
1276
- query.fn = ZQueryCollection.prototype;
1277
-
1278
- // --- src/expression.js -------------------------------------------
1279
- /**
1280
- * zQuery Expression Parser — CSP-safe expression evaluator
1281
- *
1282
- * Replaces `new Function()` / `eval()` with a hand-written parser that
1283
- * evaluates expressions safely without violating Content Security Policy.
1284
- *
1285
- * Supports:
1286
- * - Property access: user.name, items[0], items[i]
1287
- * - Method calls: items.length, str.toUpperCase()
1288
- * - Arithmetic: a + b, count * 2, i % 2
1289
- * - Comparison: a === b, count > 0, x != null
1290
- * - Logical: a && b, a || b, !a
1291
- * - Ternary: a ? b : c
1292
- * - Typeof: typeof x
1293
- * - Unary: -a, +a, !a
1294
- * - Literals: 42, 'hello', "world", true, false, null, undefined
1295
- * - Template literals: `Hello ${name}`
1296
- * - Array literals: [1, 2, 3]
1297
- * - Object literals: { foo: 'bar', baz: 1 }
1298
- * - Grouping: (a + b) * c
1299
- * - Nullish coalescing: a ?? b
1300
- * - Optional chaining: a?.b, a?.[b], a?.()
1301
- * - Arrow functions: x => x.id, (a, b) => a + b
1302
- */
1303
-
1304
- // Token types
1305
- const T = {
1306
- NUM: 1, STR: 2, IDENT: 3, OP: 4, PUNC: 5, TMPL: 6, EOF: 7
1307
- };
1308
-
1309
- // Operator precedence (higher = binds tighter)
1310
- const PREC = {
1311
- '??': 2,
1312
- '||': 3,
1313
- '&&': 4,
1314
- '==': 8, '!=': 8, '===': 8, '!==': 8,
1315
- '<': 9, '>': 9, '<=': 9, '>=': 9, 'instanceof': 9, 'in': 9,
1316
- '+': 11, '-': 11,
1317
- '*': 12, '/': 12, '%': 12,
1318
- };
1319
-
1320
- const KEYWORDS = new Set([
1321
- 'true', 'false', 'null', 'undefined', 'typeof', 'instanceof', 'in',
1322
- 'new', 'void'
1323
- ]);
1324
-
1325
- // ---------------------------------------------------------------------------
1326
- // Tokenizer
1327
- // ---------------------------------------------------------------------------
1328
- function tokenize(expr) {
1329
- const tokens = [];
1330
- let i = 0;
1331
- const len = expr.length;
1332
-
1333
- while (i < len) {
1334
- const ch = expr[i];
1335
-
1336
- // Whitespace
1337
- if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { i++; continue; }
1338
-
1339
- // Numbers
1340
- if ((ch >= '0' && ch <= '9') || (ch === '.' && i + 1 < len && expr[i + 1] >= '0' && expr[i + 1] <= '9')) {
1341
- let num = '';
1342
- if (ch === '0' && i + 1 < len && (expr[i + 1] === 'x' || expr[i + 1] === 'X')) {
1343
- num = '0x'; i += 2;
1344
- while (i < len && /[0-9a-fA-F]/.test(expr[i])) num += expr[i++];
1345
- } else {
1346
- while (i < len && ((expr[i] >= '0' && expr[i] <= '9') || expr[i] === '.')) num += expr[i++];
1347
- if (i < len && (expr[i] === 'e' || expr[i] === 'E')) {
1348
- num += expr[i++];
1349
- if (i < len && (expr[i] === '+' || expr[i] === '-')) num += expr[i++];
1350
- while (i < len && expr[i] >= '0' && expr[i] <= '9') num += expr[i++];
1351
- }
1352
- }
1353
- tokens.push({ t: T.NUM, v: Number(num) });
1354
- continue;
1355
- }
1356
-
1357
- // Strings
1358
- if (ch === "'" || ch === '"') {
1359
- const quote = ch;
1360
- let str = '';
1361
- i++;
1362
- while (i < len && expr[i] !== quote) {
1363
- if (expr[i] === '\\' && i + 1 < len) {
1364
- const esc = expr[++i];
1365
- if (esc === 'n') str += '\n';
1366
- else if (esc === 't') str += '\t';
1367
- else if (esc === 'r') str += '\r';
1368
- else if (esc === '\\') str += '\\';
1369
- else if (esc === quote) str += quote;
1370
- else str += esc;
1371
- } else {
1372
- str += expr[i];
1373
- }
1374
- i++;
1375
- }
1376
- i++; // closing quote
1377
- tokens.push({ t: T.STR, v: str });
1378
- continue;
1379
- }
1380
-
1381
- // Template literals
1382
- if (ch === '`') {
1383
- const parts = []; // alternating: string, expr, string, expr, ...
1384
- let str = '';
1385
- i++;
1386
- while (i < len && expr[i] !== '`') {
1387
- if (expr[i] === '$' && i + 1 < len && expr[i + 1] === '{') {
1388
- parts.push(str);
1389
- str = '';
1390
- i += 2;
1391
- let depth = 1;
1392
- let inner = '';
1393
- while (i < len && depth > 0) {
1394
- if (expr[i] === '{') depth++;
1395
- else if (expr[i] === '}') { depth--; if (depth === 0) break; }
1396
- inner += expr[i++];
1397
- }
1398
- i++; // closing }
1399
- parts.push({ expr: inner });
1400
- } else {
1401
- if (expr[i] === '\\' && i + 1 < len) { str += expr[++i]; }
1402
- else str += expr[i];
1403
- i++;
1404
- }
1405
- }
1406
- i++; // closing backtick
1407
- parts.push(str);
1408
- tokens.push({ t: T.TMPL, v: parts });
1409
- continue;
1410
- }
1411
-
1412
- // Identifiers & keywords
1413
- if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_' || ch === '$') {
1414
- let ident = '';
1415
- while (i < len && /[\w$]/.test(expr[i])) ident += expr[i++];
1416
- tokens.push({ t: T.IDENT, v: ident });
1417
- continue;
1418
- }
1419
-
1420
- // Multi-char operators
1421
- const two = expr.slice(i, i + 3);
1422
- if (two === '===' || two === '!==' || two === '?.') {
1423
- if (two === '?.') {
1424
- tokens.push({ t: T.OP, v: '?.' });
1425
- i += 2;
1426
- } else {
1427
- tokens.push({ t: T.OP, v: two });
1428
- i += 3;
1429
- }
1430
- continue;
1431
- }
1432
- const pair = expr.slice(i, i + 2);
1433
- if (pair === '==' || pair === '!=' || pair === '<=' || pair === '>=' ||
1434
- pair === '&&' || pair === '||' || pair === '??' || pair === '?.' ||
1435
- pair === '=>') {
1436
- tokens.push({ t: T.OP, v: pair });
1437
- i += 2;
1438
- continue;
1439
- }
1440
-
1441
- // Single char operators and punctuation
1442
- if ('+-*/%'.includes(ch)) {
1443
- tokens.push({ t: T.OP, v: ch });
1444
- i++; continue;
1445
- }
1446
- if ('<>=!'.includes(ch)) {
1447
- tokens.push({ t: T.OP, v: ch });
1448
- i++; continue;
1449
- }
1450
- // Spread operator: ...
1451
- if (ch === '.' && i + 2 < len && expr[i + 1] === '.' && expr[i + 2] === '.') {
1452
- tokens.push({ t: T.OP, v: '...' });
1453
- i += 3; continue;
1454
- }
1455
- if ('()[]{},.?:'.includes(ch)) {
1456
- tokens.push({ t: T.PUNC, v: ch });
1457
- i++; continue;
1458
- }
1459
-
1460
- // Unknown — skip
1461
- i++;
1462
- }
1463
-
1464
- tokens.push({ t: T.EOF, v: null });
1465
- return tokens;
1466
- }
1467
-
1468
- // ---------------------------------------------------------------------------
1469
- // Parser — Pratt (precedence climbing)
1470
- // ---------------------------------------------------------------------------
1471
- class Parser {
1472
- constructor(tokens, scope) {
1473
- this.tokens = tokens;
1474
- this.pos = 0;
1475
- this.scope = scope;
1476
- }
1477
-
1478
- peek() { return this.tokens[this.pos]; }
1479
- next() { return this.tokens[this.pos++]; }
1480
-
1481
- expect(type, val) {
1482
- const t = this.next();
1483
- if (t.t !== type || (val !== undefined && t.v !== val)) {
1484
- throw new Error(`Expected ${val || type} but got ${t.v}`);
1485
- }
1486
- return t;
1487
- }
1488
-
1489
- match(type, val) {
1490
- const t = this.peek();
1491
- if (t.t === type && (val === undefined || t.v === val)) {
1492
- return this.next();
1493
- }
1494
- return null;
1495
- }
1496
-
1497
- // Main entry
1498
- parse() {
1499
- const result = this.parseExpression(0);
1500
- return result;
1501
- }
1502
-
1503
- // Precedence climbing
1504
- parseExpression(minPrec) {
1505
- let left = this.parseUnary();
1506
-
1507
- while (true) {
1508
- const tok = this.peek();
1509
-
1510
- // Ternary
1511
- if (tok.t === T.PUNC && tok.v === '?') {
1512
- // Distinguish ternary ? from optional chaining ?.
1513
- if (this.tokens[this.pos + 1]?.v !== '.') {
1514
- if (1 <= minPrec) break; // ternary has very low precedence
1515
- this.next(); // consume ?
1516
- const truthy = this.parseExpression(0);
1517
- this.expect(T.PUNC, ':');
1518
- const falsy = this.parseExpression(0);
1519
- left = { type: 'ternary', cond: left, truthy, falsy };
1520
- continue;
1521
- }
1522
- }
1523
-
1524
- // Binary operators
1525
- if (tok.t === T.OP && tok.v in PREC) {
1526
- const prec = PREC[tok.v];
1527
- if (prec <= minPrec) break;
1528
- this.next();
1529
- const right = this.parseExpression(prec);
1530
- left = { type: 'binary', op: tok.v, left, right };
1531
- continue;
1532
- }
1533
-
1534
- // instanceof and in as binary operators
1535
- if (tok.t === T.IDENT && (tok.v === 'instanceof' || tok.v === 'in') && PREC[tok.v] > minPrec) {
1536
- const prec = PREC[tok.v];
1537
- this.next();
1538
- const right = this.parseExpression(prec);
1539
- left = { type: 'binary', op: tok.v, left, right };
1540
- continue;
1541
- }
1542
-
1543
- break;
1544
- }
1545
-
1546
- return left;
1547
- }
1548
-
1549
- parseUnary() {
1550
- const tok = this.peek();
1551
-
1552
- // typeof
1553
- if (tok.t === T.IDENT && tok.v === 'typeof') {
1554
- this.next();
1555
- const arg = this.parseUnary();
1556
- return { type: 'typeof', arg };
1557
- }
1437
+ wrapAll(wrapper) {
1438
+ const w = typeof wrapper === 'string' ? createFragment(wrapper).firstElementChild : wrapper.cloneNode(true);
1439
+ const first = this.first();
1440
+ if (!first) return this;
1441
+ first.parentNode.insertBefore(w, first);
1442
+ this.each((_, el) => w.appendChild(el));
1443
+ return this;
1444
+ }
1558
1445
 
1559
- // void
1560
- if (tok.t === T.IDENT && tok.v === 'void') {
1561
- this.next();
1562
- this.parseUnary(); // evaluate but discard
1563
- return { type: 'literal', value: undefined };
1564
- }
1446
+ wrapInner(wrapper) {
1447
+ return this.each((_, el) => {
1448
+ const w = typeof wrapper === 'string' ? createFragment(wrapper).firstElementChild : wrapper.cloneNode(true);
1449
+ while (el.firstChild) w.appendChild(el.firstChild);
1450
+ el.appendChild(w);
1451
+ });
1452
+ }
1565
1453
 
1566
- // !expr
1567
- if (tok.t === T.OP && tok.v === '!') {
1568
- this.next();
1569
- const arg = this.parseUnary();
1570
- return { type: 'not', arg };
1571
- }
1454
+ detach() {
1455
+ return this.each((_, el) => el.remove());
1456
+ }
1572
1457
 
1573
- // -expr, +expr
1574
- if (tok.t === T.OP && (tok.v === '-' || tok.v === '+')) {
1575
- this.next();
1576
- const arg = this.parseUnary();
1577
- return { type: 'unary', op: tok.v, arg };
1578
- }
1458
+ // --- Visibility ----------------------------------------------------------
1579
1459
 
1580
- return this.parsePostfix();
1460
+ show(display = '') {
1461
+ return this.each((_, el) => { el.style.display = display; });
1581
1462
  }
1582
1463
 
1583
- parsePostfix() {
1584
- let left = this.parsePrimary();
1464
+ hide() {
1465
+ return this.each((_, el) => { el.style.display = 'none'; });
1466
+ }
1585
1467
 
1586
- while (true) {
1587
- const tok = this.peek();
1468
+ toggle(display = '') {
1469
+ return this.each((_, el) => {
1470
+ // Check inline style first (cheap) before forcing layout via getComputedStyle
1471
+ const hidden = el.style.display === 'none' || (el.style.display !== '' ? false : getComputedStyle(el).display === 'none');
1472
+ el.style.display = hidden ? display : 'none';
1473
+ });
1474
+ }
1588
1475
 
1589
- // Property access: a.b
1590
- if (tok.t === T.PUNC && tok.v === '.') {
1591
- this.next();
1592
- const prop = this.next();
1593
- left = { type: 'member', obj: left, prop: prop.v, computed: false };
1594
- // Check for method call: a.b()
1595
- if (this.peek().t === T.PUNC && this.peek().v === '(') {
1596
- left = this._parseCall(left);
1597
- }
1598
- continue;
1599
- }
1476
+ // --- Events --------------------------------------------------------------
1600
1477
 
1601
- // Optional chaining: a?.b, a?.[b], a?.()
1602
- if (tok.t === T.OP && tok.v === '?.') {
1603
- this.next();
1604
- const next = this.peek();
1605
- if (next.t === T.PUNC && next.v === '[') {
1606
- // a?.[expr]
1607
- this.next();
1608
- const prop = this.parseExpression(0);
1609
- this.expect(T.PUNC, ']');
1610
- left = { type: 'optional_member', obj: left, prop, computed: true };
1611
- } else if (next.t === T.PUNC && next.v === '(') {
1612
- // a?.()
1613
- left = { type: 'optional_call', callee: left, args: this._parseArgs() };
1614
- } else {
1615
- // a?.b
1616
- const prop = this.next();
1617
- left = { type: 'optional_member', obj: left, prop: prop.v, computed: false };
1618
- if (this.peek().t === T.PUNC && this.peek().v === '(') {
1619
- left = this._parseCall(left);
1620
- }
1478
+ on(event, selectorOrHandler, handler) {
1479
+ // Support multiple events: "click mouseenter"
1480
+ const events = event.split(/\s+/);
1481
+ return this.each((_, el) => {
1482
+ events.forEach(evt => {
1483
+ if (typeof selectorOrHandler === 'function') {
1484
+ el.addEventListener(evt, selectorOrHandler);
1485
+ } else if (typeof selectorOrHandler === 'string') {
1486
+ // Delegated event — store wrapper so off() can remove it
1487
+ const wrapper = (e) => {
1488
+ if (!e.target || typeof e.target.closest !== 'function') return;
1489
+ const target = e.target.closest(selectorOrHandler);
1490
+ if (target && el.contains(target)) handler.call(target, e);
1491
+ };
1492
+ wrapper._zqOriginal = handler;
1493
+ wrapper._zqSelector = selectorOrHandler;
1494
+ el.addEventListener(evt, wrapper);
1495
+ // Track delegated handlers for removal
1496
+ if (!el._zqDelegated) el._zqDelegated = [];
1497
+ el._zqDelegated.push({ evt, wrapper });
1621
1498
  }
1622
- continue;
1623
- }
1499
+ });
1500
+ });
1501
+ }
1624
1502
 
1625
- // Computed access: a[b]
1626
- if (tok.t === T.PUNC && tok.v === '[') {
1627
- this.next();
1628
- const prop = this.parseExpression(0);
1629
- this.expect(T.PUNC, ']');
1630
- left = { type: 'member', obj: left, prop, computed: true };
1631
- // Check for method call: a[b]()
1632
- if (this.peek().t === T.PUNC && this.peek().v === '(') {
1633
- left = this._parseCall(left);
1503
+ off(event, handler) {
1504
+ const events = event.split(/\s+/);
1505
+ return this.each((_, el) => {
1506
+ events.forEach(evt => {
1507
+ // Try direct removal first
1508
+ el.removeEventListener(evt, handler);
1509
+ // Also check delegated handlers
1510
+ if (el._zqDelegated) {
1511
+ el._zqDelegated = el._zqDelegated.filter(d => {
1512
+ if (d.evt === evt && d.wrapper._zqOriginal === handler) {
1513
+ el.removeEventListener(evt, d.wrapper);
1514
+ return false;
1515
+ }
1516
+ return true;
1517
+ });
1634
1518
  }
1635
- continue;
1636
- }
1519
+ });
1520
+ });
1521
+ }
1637
1522
 
1638
- // Function call: fn()
1639
- if (tok.t === T.PUNC && tok.v === '(') {
1640
- left = this._parseCall(left);
1641
- continue;
1642
- }
1523
+ one(event, handler) {
1524
+ return this.each((_, el) => {
1525
+ el.addEventListener(event, handler, { once: true });
1526
+ });
1527
+ }
1643
1528
 
1644
- break;
1645
- }
1529
+ trigger(event, detail) {
1530
+ return this.each((_, el) => {
1531
+ el.dispatchEvent(new CustomEvent(event, { detail, bubbles: true, cancelable: true }));
1532
+ });
1533
+ }
1646
1534
 
1647
- return left;
1535
+ // Convenience event shorthands
1536
+ click(fn) { return fn ? this.on('click', fn) : this.trigger('click'); }
1537
+ submit(fn) { return fn ? this.on('submit', fn) : this.trigger('submit'); }
1538
+ focus() { this.first()?.focus(); return this; }
1539
+ blur() { this.first()?.blur(); return this; }
1540
+ hover(enterFn, leaveFn) {
1541
+ this.on('mouseenter', enterFn);
1542
+ return this.on('mouseleave', leaveFn || enterFn);
1648
1543
  }
1649
1544
 
1650
- _parseCall(callee) {
1651
- const args = this._parseArgs();
1652
- return { type: 'call', callee, args };
1545
+ // --- Animation -----------------------------------------------------------
1546
+
1547
+ animate(props, duration = 300, easing = 'ease') {
1548
+ // Empty collection — resolve immediately
1549
+ if (this.length === 0) return Promise.resolve(this);
1550
+ return new Promise(resolve => {
1551
+ let resolved = false;
1552
+ const count = { done: 0 };
1553
+ const listeners = [];
1554
+ this.each((_, el) => {
1555
+ el.style.transition = `all ${duration}ms ${easing}`;
1556
+ requestAnimationFrame(() => {
1557
+ Object.assign(el.style, props);
1558
+ const onEnd = () => {
1559
+ el.removeEventListener('transitionend', onEnd);
1560
+ el.style.transition = '';
1561
+ if (!resolved && ++count.done >= this.length) {
1562
+ resolved = true;
1563
+ resolve(this);
1564
+ }
1565
+ };
1566
+ el.addEventListener('transitionend', onEnd);
1567
+ listeners.push({ el, onEnd });
1568
+ });
1569
+ });
1570
+ // Fallback in case transitionend doesn't fire
1571
+ setTimeout(() => {
1572
+ if (!resolved) {
1573
+ resolved = true;
1574
+ // Clean up any remaining transitionend listeners
1575
+ for (const { el, onEnd } of listeners) {
1576
+ el.removeEventListener('transitionend', onEnd);
1577
+ el.style.transition = '';
1578
+ }
1579
+ resolve(this);
1580
+ }
1581
+ }, duration + 50);
1582
+ });
1653
1583
  }
1654
1584
 
1655
- _parseArgs() {
1656
- this.expect(T.PUNC, '(');
1657
- const args = [];
1658
- while (!(this.peek().t === T.PUNC && this.peek().v === ')') && this.peek().t !== T.EOF) {
1659
- if (this.peek().t === T.OP && this.peek().v === '...') {
1660
- this.next();
1661
- args.push({ type: 'spread', arg: this.parseExpression(0) });
1662
- } else {
1663
- args.push(this.parseExpression(0));
1664
- }
1665
- if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
1666
- }
1667
- this.expect(T.PUNC, ')');
1668
- return args;
1585
+ fadeIn(duration = 300) {
1586
+ return this.css({ opacity: '0', display: '' }).animate({ opacity: '1' }, duration);
1669
1587
  }
1670
1588
 
1671
- parsePrimary() {
1672
- const tok = this.peek();
1589
+ fadeOut(duration = 300) {
1590
+ return this.animate({ opacity: '0' }, duration).then(col => col.hide());
1591
+ }
1673
1592
 
1674
- // Number literal
1675
- if (tok.t === T.NUM) {
1676
- this.next();
1677
- return { type: 'literal', value: tok.v };
1678
- }
1593
+ fadeToggle(duration = 300) {
1594
+ return Promise.all(this.elements.map(el => {
1595
+ const cs = getComputedStyle(el);
1596
+ const visible = cs.opacity !== '0' && cs.display !== 'none';
1597
+ const col = new ZQueryCollection([el]);
1598
+ return visible ? col.fadeOut(duration) : col.fadeIn(duration);
1599
+ })).then(() => this);
1600
+ }
1679
1601
 
1680
- // String literal
1681
- if (tok.t === T.STR) {
1682
- this.next();
1683
- return { type: 'literal', value: tok.v };
1684
- }
1602
+ fadeTo(duration, opacity) {
1603
+ return this.animate({ opacity: String(opacity) }, duration);
1604
+ }
1685
1605
 
1686
- // Template literal
1687
- if (tok.t === T.TMPL) {
1688
- this.next();
1689
- return { type: 'template', parts: tok.v };
1690
- }
1606
+ slideDown(duration = 300) {
1607
+ return this.each((_, el) => {
1608
+ el.style.display = '';
1609
+ el.style.overflow = 'hidden';
1610
+ const h = el.scrollHeight + 'px';
1611
+ el.style.maxHeight = '0';
1612
+ el.style.transition = `max-height ${duration}ms ease`;
1613
+ requestAnimationFrame(() => { el.style.maxHeight = h; });
1614
+ setTimeout(() => { el.style.maxHeight = ''; el.style.overflow = ''; el.style.transition = ''; }, duration);
1615
+ });
1616
+ }
1691
1617
 
1692
- // Arrow function with parens: () =>, (a) =>, (a, b) =>
1693
- // or regular grouping: (expr)
1694
- if (tok.t === T.PUNC && tok.v === '(') {
1695
- const savedPos = this.pos;
1696
- this.next(); // consume (
1697
- const params = [];
1698
- let couldBeArrow = true;
1618
+ slideUp(duration = 300) {
1619
+ return this.each((_, el) => {
1620
+ el.style.overflow = 'hidden';
1621
+ el.style.maxHeight = el.scrollHeight + 'px';
1622
+ el.style.transition = `max-height ${duration}ms ease`;
1623
+ requestAnimationFrame(() => { el.style.maxHeight = '0'; });
1624
+ setTimeout(() => { el.style.display = 'none'; el.style.maxHeight = ''; el.style.overflow = ''; el.style.transition = ''; }, duration);
1625
+ });
1626
+ }
1699
1627
 
1700
- if (this.peek().t === T.PUNC && this.peek().v === ')') {
1701
- // () => ... — no params
1628
+ slideToggle(duration = 300) {
1629
+ return this.each((_, el) => {
1630
+ if (el.style.display === 'none' || getComputedStyle(el).display === 'none') {
1631
+ el.style.display = '';
1632
+ el.style.overflow = 'hidden';
1633
+ const h = el.scrollHeight + 'px';
1634
+ el.style.maxHeight = '0';
1635
+ el.style.transition = `max-height ${duration}ms ease`;
1636
+ requestAnimationFrame(() => { el.style.maxHeight = h; });
1637
+ setTimeout(() => { el.style.maxHeight = ''; el.style.overflow = ''; el.style.transition = ''; }, duration);
1702
1638
  } else {
1703
- while (couldBeArrow) {
1704
- const p = this.peek();
1705
- if (p.t === T.IDENT && !KEYWORDS.has(p.v)) {
1706
- params.push(this.next().v);
1707
- if (this.peek().t === T.PUNC && this.peek().v === ',') {
1708
- this.next();
1709
- } else {
1710
- break;
1711
- }
1712
- } else {
1713
- couldBeArrow = false;
1714
- }
1715
- }
1639
+ el.style.overflow = 'hidden';
1640
+ el.style.maxHeight = el.scrollHeight + 'px';
1641
+ el.style.transition = `max-height ${duration}ms ease`;
1642
+ requestAnimationFrame(() => { el.style.maxHeight = '0'; });
1643
+ setTimeout(() => { el.style.display = 'none'; el.style.maxHeight = ''; el.style.overflow = ''; el.style.transition = ''; }, duration);
1716
1644
  }
1645
+ });
1646
+ }
1717
1647
 
1718
- if (couldBeArrow && this.peek().t === T.PUNC && this.peek().v === ')') {
1719
- this.next(); // consume )
1720
- if (this.peek().t === T.OP && this.peek().v === '=>') {
1721
- this.next(); // consume =>
1722
- const body = this.parseExpression(0);
1723
- return { type: 'arrow', params, body };
1724
- }
1725
- }
1648
+ // --- Form helpers --------------------------------------------------------
1726
1649
 
1727
- // Not an arrow — restore and parse as grouping
1728
- this.pos = savedPos;
1729
- this.next(); // consume (
1730
- const expr = this.parseExpression(0);
1731
- this.expect(T.PUNC, ')');
1732
- return expr;
1733
- }
1650
+ serialize() {
1651
+ const form = this.first();
1652
+ if (!form || form.tagName !== 'FORM') return '';
1653
+ return new URLSearchParams(new FormData(form)).toString();
1654
+ }
1734
1655
 
1735
- // Array literal
1736
- if (tok.t === T.PUNC && tok.v === '[') {
1737
- this.next();
1738
- const elements = [];
1739
- while (!(this.peek().t === T.PUNC && this.peek().v === ']') && this.peek().t !== T.EOF) {
1740
- if (this.peek().t === T.OP && this.peek().v === '...') {
1741
- this.next();
1742
- elements.push({ type: 'spread', arg: this.parseExpression(0) });
1743
- } else {
1744
- elements.push(this.parseExpression(0));
1745
- }
1746
- if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
1656
+ serializeObject() {
1657
+ const form = this.first();
1658
+ if (!form || form.tagName !== 'FORM') return {};
1659
+ const obj = {};
1660
+ new FormData(form).forEach((v, k) => {
1661
+ if (obj[k] !== undefined) {
1662
+ if (!Array.isArray(obj[k])) obj[k] = [obj[k]];
1663
+ obj[k].push(v);
1664
+ } else {
1665
+ obj[k] = v;
1747
1666
  }
1748
- this.expect(T.PUNC, ']');
1749
- return { type: 'array', elements };
1750
- }
1667
+ });
1668
+ return obj;
1669
+ }
1670
+ }
1751
1671
 
1752
- // Object literal
1753
- if (tok.t === T.PUNC && tok.v === '{') {
1754
- this.next();
1755
- const properties = [];
1756
- while (!(this.peek().t === T.PUNC && this.peek().v === '}') && this.peek().t !== T.EOF) {
1757
- // Spread in object: { ...obj }
1758
- if (this.peek().t === T.OP && this.peek().v === '...') {
1759
- this.next();
1760
- properties.push({ spread: true, value: this.parseExpression(0) });
1761
- if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
1762
- continue;
1763
- }
1764
1672
 
1765
- const keyTok = this.next();
1766
- let key;
1767
- if (keyTok.t === T.IDENT || keyTok.t === T.STR) key = keyTok.v;
1768
- else if (keyTok.t === T.NUM) key = String(keyTok.v);
1769
- else throw new Error('Invalid object key: ' + keyTok.v);
1673
+ // ---------------------------------------------------------------------------
1674
+ // Helper — create document fragment from HTML string
1675
+ // ---------------------------------------------------------------------------
1676
+ function createFragment(html) {
1677
+ const tpl = document.createElement('template');
1678
+ tpl.innerHTML = html.trim();
1679
+ return tpl.content;
1680
+ }
1770
1681
 
1771
- // Shorthand property: { foo } means { foo: foo }
1772
- if (this.peek().t === T.PUNC && (this.peek().v === ',' || this.peek().v === '}')) {
1773
- properties.push({ key, value: { type: 'ident', name: key } });
1774
- } else {
1775
- this.expect(T.PUNC, ':');
1776
- properties.push({ key, value: this.parseExpression(0) });
1777
- }
1778
- if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
1779
- }
1780
- this.expect(T.PUNC, '}');
1781
- return { type: 'object', properties };
1782
- }
1783
1682
 
1784
- // Identifiers & keywords
1785
- if (tok.t === T.IDENT) {
1786
- this.next();
1683
+ // ---------------------------------------------------------------------------
1684
+ // $() main selector / creator (returns ZQueryCollection, like jQuery)
1685
+ // ---------------------------------------------------------------------------
1686
+ function query(selector, context) {
1687
+ // null / undefined
1688
+ if (!selector) return new ZQueryCollection([]);
1787
1689
 
1788
- // Keywords
1789
- if (tok.v === 'true') return { type: 'literal', value: true };
1790
- if (tok.v === 'false') return { type: 'literal', value: false };
1791
- if (tok.v === 'null') return { type: 'literal', value: null };
1792
- if (tok.v === 'undefined') return { type: 'literal', value: undefined };
1690
+ // Already a collection — return as-is
1691
+ if (selector instanceof ZQueryCollection) return selector;
1793
1692
 
1794
- // new keyword
1795
- if (tok.v === 'new') {
1796
- let classExpr = this.parsePrimary();
1797
- // Handle member access (e.g. ns.MyClass) without consuming call args
1798
- while (this.peek().t === T.PUNC && this.peek().v === '.') {
1799
- this.next();
1800
- const prop = this.next();
1801
- classExpr = { type: 'member', obj: classExpr, prop: prop.v, computed: false };
1802
- }
1803
- let args = [];
1804
- if (this.peek().t === T.PUNC && this.peek().v === '(') {
1805
- args = this._parseArgs();
1806
- }
1807
- return { type: 'new', callee: classExpr, args };
1808
- }
1693
+ // DOM element or Window — wrap in collection
1694
+ if (selector instanceof Node || selector === window) {
1695
+ return new ZQueryCollection([selector]);
1696
+ }
1809
1697
 
1810
- // Arrow function: x => expr
1811
- if (this.peek().t === T.OP && this.peek().v === '=>') {
1812
- this.next(); // consume =>
1813
- const body = this.parseExpression(0);
1814
- return { type: 'arrow', params: [tok.v], body };
1815
- }
1698
+ // NodeList / HTMLCollection / Array — wrap in collection
1699
+ if (selector instanceof NodeList || selector instanceof HTMLCollection || Array.isArray(selector)) {
1700
+ return new ZQueryCollection(Array.from(selector));
1701
+ }
1816
1702
 
1817
- return { type: 'ident', name: tok.v };
1818
- }
1703
+ // HTML string → create elements, wrap in collection
1704
+ if (typeof selector === 'string' && selector.trim().startsWith('<')) {
1705
+ const fragment = createFragment(selector);
1706
+ return new ZQueryCollection([...fragment.childNodes].filter(n => n.nodeType === 1));
1707
+ }
1819
1708
 
1820
- // Fallback return undefined for unparseable
1821
- this.next();
1822
- return { type: 'literal', value: undefined };
1709
+ // CSS selector string querySelectorAll (collection)
1710
+ if (typeof selector === 'string') {
1711
+ const root = context
1712
+ ? (typeof context === 'string' ? document.querySelector(context) : context)
1713
+ : document;
1714
+ return new ZQueryCollection([...root.querySelectorAll(selector)]);
1823
1715
  }
1716
+
1717
+ return new ZQueryCollection([]);
1824
1718
  }
1825
1719
 
1720
+
1826
1721
  // ---------------------------------------------------------------------------
1827
- // Evaluatorwalks the AST, resolves against scope
1722
+ // $.all()collection selector (returns ZQueryCollection for CSS selectors)
1828
1723
  // ---------------------------------------------------------------------------
1724
+ function queryAll(selector, context) {
1725
+ // null / undefined
1726
+ if (!selector) return new ZQueryCollection([]);
1829
1727
 
1830
- /** Safe property access whitelist for built-in prototypes */
1831
- const SAFE_ARRAY_METHODS = new Set([
1832
- 'length', 'map', 'filter', 'find', 'findIndex', 'some', 'every',
1833
- 'reduce', 'reduceRight', 'forEach', 'includes', 'indexOf', 'lastIndexOf',
1834
- 'join', 'slice', 'concat', 'flat', 'flatMap', 'reverse', 'sort',
1835
- 'fill', 'keys', 'values', 'entries', 'at', 'toString',
1836
- ]);
1837
-
1838
- const SAFE_STRING_METHODS = new Set([
1839
- 'length', 'charAt', 'charCodeAt', 'includes', 'indexOf', 'lastIndexOf',
1840
- 'slice', 'substring', 'trim', 'trimStart', 'trimEnd', 'toLowerCase',
1841
- 'toUpperCase', 'split', 'replace', 'replaceAll', 'match', 'search',
1842
- 'startsWith', 'endsWith', 'padStart', 'padEnd', 'repeat', 'at',
1843
- 'toString', 'valueOf',
1844
- ]);
1845
-
1846
- const SAFE_NUMBER_METHODS = new Set([
1847
- 'toFixed', 'toPrecision', 'toString', 'valueOf',
1848
- ]);
1728
+ // Already a collection
1729
+ if (selector instanceof ZQueryCollection) return selector;
1849
1730
 
1850
- const SAFE_OBJECT_METHODS = new Set([
1851
- 'hasOwnProperty', 'toString', 'valueOf',
1852
- ]);
1731
+ // DOM element or Window
1732
+ if (selector instanceof Node || selector === window) {
1733
+ return new ZQueryCollection([selector]);
1734
+ }
1853
1735
 
1854
- const SAFE_MATH_PROPS = new Set([
1855
- 'PI', 'E', 'LN2', 'LN10', 'LOG2E', 'LOG10E', 'SQRT2', 'SQRT1_2',
1856
- 'abs', 'ceil', 'floor', 'round', 'trunc', 'max', 'min', 'pow',
1857
- 'sqrt', 'sign', 'random', 'log', 'log2', 'log10',
1858
- ]);
1736
+ // NodeList / HTMLCollection / Array
1737
+ if (selector instanceof NodeList || selector instanceof HTMLCollection || Array.isArray(selector)) {
1738
+ return new ZQueryCollection(Array.from(selector));
1739
+ }
1859
1740
 
1860
- const SAFE_JSON_PROPS = new Set(['parse', 'stringify']);
1741
+ // HTML string create elements
1742
+ if (typeof selector === 'string' && selector.trim().startsWith('<')) {
1743
+ const fragment = createFragment(selector);
1744
+ return new ZQueryCollection([...fragment.childNodes].filter(n => n.nodeType === 1));
1745
+ }
1861
1746
 
1862
- /**
1863
- * Check if property access is safe
1864
- */
1865
- function _isSafeAccess(obj, prop) {
1866
- // Never allow access to dangerous properties
1867
- const BLOCKED = new Set([
1868
- 'constructor', '__proto__', 'prototype', '__defineGetter__',
1869
- '__defineSetter__', '__lookupGetter__', '__lookupSetter__',
1870
- 'call', 'apply', 'bind',
1871
- ]);
1872
- if (typeof prop === 'string' && BLOCKED.has(prop)) return false;
1747
+ // CSS selector string → querySelectorAll (collection)
1748
+ if (typeof selector === 'string') {
1749
+ const root = context
1750
+ ? (typeof context === 'string' ? document.querySelector(context) : context)
1751
+ : document;
1752
+ return new ZQueryCollection([...root.querySelectorAll(selector)]);
1753
+ }
1873
1754
 
1874
- // Always allow plain object/function property access and array index access
1875
- if (obj !== null && obj !== undefined && (typeof obj === 'object' || typeof obj === 'function')) return true;
1876
- if (typeof obj === 'string') return SAFE_STRING_METHODS.has(prop);
1877
- if (typeof obj === 'number') return SAFE_NUMBER_METHODS.has(prop);
1878
- return false;
1755
+ return new ZQueryCollection([]);
1879
1756
  }
1880
1757
 
1881
- function evaluate(node, scope) {
1882
- if (!node) return undefined;
1883
-
1884
- switch (node.type) {
1885
- case 'literal':
1886
- return node.value;
1887
1758
 
1888
- case 'ident': {
1889
- const name = node.name;
1890
- // Check scope layers in order
1891
- for (const layer of scope) {
1892
- if (layer && typeof layer === 'object' && name in layer) {
1893
- return layer[name];
1894
- }
1895
- }
1896
- // Built-in globals (safe ones only)
1897
- if (name === 'Math') return Math;
1898
- if (name === 'JSON') return JSON;
1899
- if (name === 'Date') return Date;
1900
- if (name === 'Array') return Array;
1901
- if (name === 'Object') return Object;
1902
- if (name === 'String') return String;
1903
- if (name === 'Number') return Number;
1904
- if (name === 'Boolean') return Boolean;
1905
- if (name === 'parseInt') return parseInt;
1906
- if (name === 'parseFloat') return parseFloat;
1907
- if (name === 'isNaN') return isNaN;
1908
- if (name === 'isFinite') return isFinite;
1909
- if (name === 'Infinity') return Infinity;
1910
- if (name === 'NaN') return NaN;
1911
- if (name === 'encodeURIComponent') return encodeURIComponent;
1912
- if (name === 'decodeURIComponent') return decodeURIComponent;
1913
- if (name === 'console') return console;
1914
- if (name === 'Map') return Map;
1915
- if (name === 'Set') return Set;
1916
- if (name === 'RegExp') return RegExp;
1917
- if (name === 'Error') return Error;
1918
- if (name === 'URL') return URL;
1919
- if (name === 'URLSearchParams') return URLSearchParams;
1920
- return undefined;
1921
- }
1759
+ // ---------------------------------------------------------------------------
1760
+ // Quick-ref shortcuts, on $ namespace)
1761
+ // ---------------------------------------------------------------------------
1762
+ query.id = (id) => document.getElementById(id);
1763
+ query.class = (name) => document.querySelector(`.${name}`);
1764
+ query.classes = (name) => new ZQueryCollection(Array.from(document.getElementsByClassName(name)));
1765
+ query.tag = (name) => new ZQueryCollection(Array.from(document.getElementsByTagName(name)));
1766
+ Object.defineProperty(query, 'name', {
1767
+ value: (name) => new ZQueryCollection(Array.from(document.getElementsByName(name))),
1768
+ writable: true, configurable: true
1769
+ });
1770
+ query.children = (parentId) => {
1771
+ const p = document.getElementById(parentId);
1772
+ return new ZQueryCollection(p ? Array.from(p.children) : []);
1773
+ };
1774
+ query.qs = (sel, ctx = document) => ctx.querySelector(sel);
1775
+ query.qsa = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel));
1922
1776
 
1923
- case 'template': {
1924
- // Template literal with interpolation
1925
- let result = '';
1926
- for (const part of node.parts) {
1927
- if (typeof part === 'string') {
1928
- result += part;
1929
- } else if (part && part.expr) {
1930
- result += String(safeEval(part.expr, scope) ?? '');
1931
- }
1932
- }
1933
- return result;
1934
- }
1777
+ // Create element shorthand — returns ZQueryCollection for chaining
1778
+ query.create = (tag, attrs = {}, ...children) => {
1779
+ const el = document.createElement(tag);
1780
+ for (const [k, v] of Object.entries(attrs)) {
1781
+ if (k === 'class') el.className = v;
1782
+ else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
1783
+ else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v);
1784
+ else if (k === 'data' && typeof v === 'object') Object.entries(v).forEach(([dk, dv]) => { el.dataset[dk] = dv; });
1785
+ else el.setAttribute(k, v);
1786
+ }
1787
+ children.flat().forEach(child => {
1788
+ if (typeof child === 'string') el.appendChild(document.createTextNode(child));
1789
+ else if (child instanceof Node) el.appendChild(child);
1790
+ });
1791
+ return new ZQueryCollection(el);
1792
+ };
1935
1793
 
1936
- case 'member': {
1937
- const obj = evaluate(node.obj, scope);
1938
- if (obj == null) return undefined;
1939
- const prop = node.computed ? evaluate(node.prop, scope) : node.prop;
1940
- if (!_isSafeAccess(obj, prop)) return undefined;
1941
- return obj[prop];
1942
- }
1794
+ // DOM ready
1795
+ query.ready = (fn) => {
1796
+ if (document.readyState !== 'loading') fn();
1797
+ else document.addEventListener('DOMContentLoaded', fn);
1798
+ };
1943
1799
 
1944
- case 'optional_member': {
1945
- const obj = evaluate(node.obj, scope);
1946
- if (obj == null) return undefined;
1947
- const prop = node.computed ? evaluate(node.prop, scope) : node.prop;
1948
- if (!_isSafeAccess(obj, prop)) return undefined;
1949
- return obj[prop];
1950
- }
1800
+ // Global event listeners — supports direct, delegated, and target-bound forms
1801
+ // $.on('keydown', handler) → direct listener on document
1802
+ // $.on('click', '.btn', handler) delegated via closest()
1803
+ // $.on('scroll', window, handler) → direct listener on target
1804
+ query.on = (event, selectorOrHandler, handler) => {
1805
+ if (typeof selectorOrHandler === 'function') {
1806
+ // 2-arg: direct document listener (keydown, resize, etc.)
1807
+ document.addEventListener(event, selectorOrHandler);
1808
+ return;
1809
+ }
1810
+ // EventTarget (window, element, etc.) — direct listener on target
1811
+ if (typeof selectorOrHandler === 'object' && typeof selectorOrHandler.addEventListener === 'function') {
1812
+ selectorOrHandler.addEventListener(event, handler);
1813
+ return;
1814
+ }
1815
+ // 3-arg string: delegated
1816
+ document.addEventListener(event, (e) => {
1817
+ if (!e.target || typeof e.target.closest !== 'function') return;
1818
+ const target = e.target.closest(selectorOrHandler);
1819
+ if (target) handler.call(target, e);
1820
+ });
1821
+ };
1951
1822
 
1952
- case 'call': {
1953
- const result = _resolveCall(node, scope, false);
1954
- return result;
1955
- }
1823
+ // Remove a direct global listener
1824
+ query.off = (event, handler) => {
1825
+ document.removeEventListener(event, handler);
1826
+ };
1956
1827
 
1957
- case 'optional_call': {
1958
- const calleeNode = node.callee;
1959
- const args = _evalArgs(node.args, scope);
1960
- // Method call: obj?.method() — bind `this` to obj
1961
- if (calleeNode.type === 'member' || calleeNode.type === 'optional_member') {
1962
- const obj = evaluate(calleeNode.obj, scope);
1963
- if (obj == null) return undefined;
1964
- const prop = calleeNode.computed ? evaluate(calleeNode.prop, scope) : calleeNode.prop;
1965
- if (!_isSafeAccess(obj, prop)) return undefined;
1966
- const fn = obj[prop];
1967
- if (typeof fn !== 'function') return undefined;
1968
- return fn.apply(obj, args);
1969
- }
1970
- const callee = evaluate(calleeNode, scope);
1971
- if (callee == null) return undefined;
1972
- if (typeof callee !== 'function') return undefined;
1973
- return callee(...args);
1974
- }
1828
+ // Extend collection prototype (like $.fn in jQuery)
1829
+ query.fn = ZQueryCollection.prototype;
1830
+
1831
+ // --- src/expression.js -------------------------------------------
1832
+ /**
1833
+ * zQuery Expression Parser — CSP-safe expression evaluator
1834
+ *
1835
+ * Replaces `new Function()` / `eval()` with a hand-written parser that
1836
+ * evaluates expressions safely without violating Content Security Policy.
1837
+ *
1838
+ * Supports:
1839
+ * - Property access: user.name, items[0], items[i]
1840
+ * - Method calls: items.length, str.toUpperCase()
1841
+ * - Arithmetic: a + b, count * 2, i % 2
1842
+ * - Comparison: a === b, count > 0, x != null
1843
+ * - Logical: a && b, a || b, !a
1844
+ * - Ternary: a ? b : c
1845
+ * - Typeof: typeof x
1846
+ * - Unary: -a, +a, !a
1847
+ * - Literals: 42, 'hello', "world", true, false, null, undefined
1848
+ * - Template literals: `Hello ${name}`
1849
+ * - Array literals: [1, 2, 3]
1850
+ * - Object literals: { foo: 'bar', baz: 1 }
1851
+ * - Grouping: (a + b) * c
1852
+ * - Nullish coalescing: a ?? b
1853
+ * - Optional chaining: a?.b, a?.[b], a?.()
1854
+ * - Arrow functions: x => x.id, (a, b) => a + b
1855
+ */
1975
1856
 
1976
- case 'new': {
1977
- const Ctor = evaluate(node.callee, scope);
1978
- if (typeof Ctor !== 'function') return undefined;
1979
- // Only allow safe constructors
1980
- if (Ctor === Date || Ctor === Array || Ctor === Map || Ctor === Set ||
1981
- Ctor === RegExp || Ctor === Error || Ctor === URL || Ctor === URLSearchParams) {
1982
- const args = _evalArgs(node.args, scope);
1983
- return new Ctor(...args);
1984
- }
1985
- return undefined;
1986
- }
1857
+ // Token types
1858
+ const T = {
1859
+ NUM: 1, STR: 2, IDENT: 3, OP: 4, PUNC: 5, TMPL: 6, EOF: 7
1860
+ };
1987
1861
 
1988
- case 'binary':
1989
- return _evalBinary(node, scope);
1862
+ // Operator precedence (higher = binds tighter)
1863
+ const PREC = {
1864
+ '??': 2,
1865
+ '||': 3,
1866
+ '&&': 4,
1867
+ '==': 8, '!=': 8, '===': 8, '!==': 8,
1868
+ '<': 9, '>': 9, '<=': 9, '>=': 9, 'instanceof': 9, 'in': 9,
1869
+ '+': 11, '-': 11,
1870
+ '*': 12, '/': 12, '%': 12,
1871
+ };
1990
1872
 
1991
- case 'unary': {
1992
- const val = evaluate(node.arg, scope);
1993
- return node.op === '-' ? -val : +val;
1994
- }
1873
+ const KEYWORDS = new Set([
1874
+ 'true', 'false', 'null', 'undefined', 'typeof', 'instanceof', 'in',
1875
+ 'new', 'void'
1876
+ ]);
1995
1877
 
1996
- case 'not':
1997
- return !evaluate(node.arg, scope);
1878
+ // ---------------------------------------------------------------------------
1879
+ // Tokenizer
1880
+ // ---------------------------------------------------------------------------
1881
+ function tokenize(expr) {
1882
+ const tokens = [];
1883
+ let i = 0;
1884
+ const len = expr.length;
1998
1885
 
1999
- case 'typeof': {
2000
- try {
2001
- return typeof evaluate(node.arg, scope);
2002
- } catch {
2003
- return 'undefined';
2004
- }
2005
- }
1886
+ while (i < len) {
1887
+ const ch = expr[i];
2006
1888
 
2007
- case 'ternary': {
2008
- const cond = evaluate(node.cond, scope);
2009
- return cond ? evaluate(node.truthy, scope) : evaluate(node.falsy, scope);
1889
+ // Whitespace
1890
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { i++; continue; }
1891
+
1892
+ // Numbers
1893
+ if ((ch >= '0' && ch <= '9') || (ch === '.' && i + 1 < len && expr[i + 1] >= '0' && expr[i + 1] <= '9')) {
1894
+ let num = '';
1895
+ if (ch === '0' && i + 1 < len && (expr[i + 1] === 'x' || expr[i + 1] === 'X')) {
1896
+ num = '0x'; i += 2;
1897
+ while (i < len && /[0-9a-fA-F]/.test(expr[i])) num += expr[i++];
1898
+ } else {
1899
+ while (i < len && ((expr[i] >= '0' && expr[i] <= '9') || expr[i] === '.')) num += expr[i++];
1900
+ if (i < len && (expr[i] === 'e' || expr[i] === 'E')) {
1901
+ num += expr[i++];
1902
+ if (i < len && (expr[i] === '+' || expr[i] === '-')) num += expr[i++];
1903
+ while (i < len && expr[i] >= '0' && expr[i] <= '9') num += expr[i++];
1904
+ }
1905
+ }
1906
+ tokens.push({ t: T.NUM, v: Number(num) });
1907
+ continue;
2010
1908
  }
2011
1909
 
2012
- case 'array': {
2013
- const arr = [];
2014
- for (const e of node.elements) {
2015
- if (e.type === 'spread') {
2016
- const iterable = evaluate(e.arg, scope);
2017
- if (iterable != null && typeof iterable[Symbol.iterator] === 'function') {
2018
- for (const v of iterable) arr.push(v);
2019
- }
1910
+ // Strings
1911
+ if (ch === "'" || ch === '"') {
1912
+ const quote = ch;
1913
+ let str = '';
1914
+ i++;
1915
+ while (i < len && expr[i] !== quote) {
1916
+ if (expr[i] === '\\' && i + 1 < len) {
1917
+ const esc = expr[++i];
1918
+ if (esc === 'n') str += '\n';
1919
+ else if (esc === 't') str += '\t';
1920
+ else if (esc === 'r') str += '\r';
1921
+ else if (esc === '\\') str += '\\';
1922
+ else if (esc === quote) str += quote;
1923
+ else str += esc;
2020
1924
  } else {
2021
- arr.push(evaluate(e, scope));
1925
+ str += expr[i];
2022
1926
  }
1927
+ i++;
2023
1928
  }
2024
- return arr;
1929
+ i++; // closing quote
1930
+ tokens.push({ t: T.STR, v: str });
1931
+ continue;
2025
1932
  }
2026
1933
 
2027
- case 'object': {
2028
- const obj = {};
2029
- for (const prop of node.properties) {
2030
- if (prop.spread) {
2031
- const source = evaluate(prop.value, scope);
2032
- if (source != null && typeof source === 'object') {
2033
- Object.assign(obj, source);
1934
+ // Template literals
1935
+ if (ch === '`') {
1936
+ const parts = []; // alternating: string, expr, string, expr, ...
1937
+ let str = '';
1938
+ i++;
1939
+ while (i < len && expr[i] !== '`') {
1940
+ if (expr[i] === '$' && i + 1 < len && expr[i + 1] === '{') {
1941
+ parts.push(str);
1942
+ str = '';
1943
+ i += 2;
1944
+ let depth = 1;
1945
+ let inner = '';
1946
+ while (i < len && depth > 0) {
1947
+ if (expr[i] === '{') depth++;
1948
+ else if (expr[i] === '}') { depth--; if (depth === 0) break; }
1949
+ inner += expr[i++];
2034
1950
  }
1951
+ i++; // closing }
1952
+ parts.push({ expr: inner });
2035
1953
  } else {
2036
- obj[prop.key] = evaluate(prop.value, scope);
1954
+ if (expr[i] === '\\' && i + 1 < len) { str += expr[++i]; }
1955
+ else str += expr[i];
1956
+ i++;
2037
1957
  }
2038
1958
  }
2039
- return obj;
1959
+ i++; // closing backtick
1960
+ parts.push(str);
1961
+ tokens.push({ t: T.TMPL, v: parts });
1962
+ continue;
2040
1963
  }
2041
1964
 
2042
- case 'arrow': {
2043
- const paramNames = node.params;
2044
- const bodyNode = node.body;
2045
- const closedScope = scope;
2046
- return function(...args) {
2047
- const arrowScope = {};
2048
- paramNames.forEach((name, i) => { arrowScope[name] = args[i]; });
2049
- return evaluate(bodyNode, [arrowScope, ...closedScope]);
2050
- };
1965
+ // Identifiers & keywords
1966
+ if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_' || ch === '$') {
1967
+ let ident = '';
1968
+ while (i < len && /[\w$]/.test(expr[i])) ident += expr[i++];
1969
+ tokens.push({ t: T.IDENT, v: ident });
1970
+ continue;
2051
1971
  }
2052
1972
 
2053
- default:
2054
- return undefined;
2055
- }
2056
- }
2057
-
2058
- /**
2059
- * Evaluate a list of argument AST nodes, flattening any spread elements.
2060
- */
2061
- function _evalArgs(argNodes, scope) {
2062
- const result = [];
2063
- for (const a of argNodes) {
2064
- if (a.type === 'spread') {
2065
- const iterable = evaluate(a.arg, scope);
2066
- if (iterable != null && typeof iterable[Symbol.iterator] === 'function') {
2067
- for (const v of iterable) result.push(v);
1973
+ // Multi-char operators
1974
+ const two = expr.slice(i, i + 3);
1975
+ if (two === '===' || two === '!==' || two === '?.') {
1976
+ if (two === '?.') {
1977
+ tokens.push({ t: T.OP, v: '?.' });
1978
+ i += 2;
1979
+ } else {
1980
+ tokens.push({ t: T.OP, v: two });
1981
+ i += 3;
2068
1982
  }
2069
- } else {
2070
- result.push(evaluate(a, scope));
1983
+ continue;
1984
+ }
1985
+ const pair = expr.slice(i, i + 2);
1986
+ if (pair === '==' || pair === '!=' || pair === '<=' || pair === '>=' ||
1987
+ pair === '&&' || pair === '||' || pair === '??' || pair === '?.' ||
1988
+ pair === '=>') {
1989
+ tokens.push({ t: T.OP, v: pair });
1990
+ i += 2;
1991
+ continue;
2071
1992
  }
2072
- }
2073
- return result;
2074
- }
2075
1993
 
2076
- /**
2077
- * Resolve and execute a function call safely.
2078
- */
2079
- function _resolveCall(node, scope) {
2080
- const callee = node.callee;
2081
- const args = _evalArgs(node.args, scope);
1994
+ // Single char operators and punctuation
1995
+ if ('+-*/%'.includes(ch)) {
1996
+ tokens.push({ t: T.OP, v: ch });
1997
+ i++; continue;
1998
+ }
1999
+ if ('<>=!'.includes(ch)) {
2000
+ tokens.push({ t: T.OP, v: ch });
2001
+ i++; continue;
2002
+ }
2003
+ // Spread operator: ...
2004
+ if (ch === '.' && i + 2 < len && expr[i + 1] === '.' && expr[i + 2] === '.') {
2005
+ tokens.push({ t: T.OP, v: '...' });
2006
+ i += 3; continue;
2007
+ }
2008
+ if ('()[]{},.?:'.includes(ch)) {
2009
+ tokens.push({ t: T.PUNC, v: ch });
2010
+ i++; continue;
2011
+ }
2082
2012
 
2083
- // Method call: obj.method() bind `this` to obj
2084
- if (callee.type === 'member' || callee.type === 'optional_member') {
2085
- const obj = evaluate(callee.obj, scope);
2086
- if (obj == null) return undefined;
2087
- const prop = callee.computed ? evaluate(callee.prop, scope) : callee.prop;
2088
- if (!_isSafeAccess(obj, prop)) return undefined;
2089
- const fn = obj[prop];
2090
- if (typeof fn !== 'function') return undefined;
2091
- return fn.apply(obj, args);
2013
+ // Unknownskip
2014
+ i++;
2092
2015
  }
2093
2016
 
2094
- // Direct call: fn(args)
2095
- const fn = evaluate(callee, scope);
2096
- if (typeof fn !== 'function') return undefined;
2097
- return fn(...args);
2017
+ tokens.push({ t: T.EOF, v: null });
2018
+ return tokens;
2098
2019
  }
2099
2020
 
2100
- /**
2101
- * Evaluate binary expression.
2102
- */
2103
- function _evalBinary(node, scope) {
2104
- // Short-circuit for logical ops
2105
- if (node.op === '&&') {
2106
- const left = evaluate(node.left, scope);
2107
- return left ? evaluate(node.right, scope) : left;
2108
- }
2109
- if (node.op === '||') {
2110
- const left = evaluate(node.left, scope);
2111
- return left ? left : evaluate(node.right, scope);
2112
- }
2113
- if (node.op === '??') {
2114
- const left = evaluate(node.left, scope);
2115
- return left != null ? left : evaluate(node.right, scope);
2021
+ // ---------------------------------------------------------------------------
2022
+ // Parser Pratt (precedence climbing)
2023
+ // ---------------------------------------------------------------------------
2024
+ class Parser {
2025
+ constructor(tokens, scope) {
2026
+ this.tokens = tokens;
2027
+ this.pos = 0;
2028
+ this.scope = scope;
2116
2029
  }
2117
2030
 
2118
- const left = evaluate(node.left, scope);
2119
- const right = evaluate(node.right, scope);
2031
+ peek() { return this.tokens[this.pos]; }
2032
+ next() { return this.tokens[this.pos++]; }
2120
2033
 
2121
- switch (node.op) {
2122
- case '+': return left + right;
2123
- case '-': return left - right;
2124
- case '*': return left * right;
2125
- case '/': return left / right;
2126
- case '%': return left % right;
2127
- case '==': return left == right;
2128
- case '!=': return left != right;
2129
- case '===': return left === right;
2130
- case '!==': return left !== right;
2131
- case '<': return left < right;
2132
- case '>': return left > right;
2133
- case '<=': return left <= right;
2134
- case '>=': return left >= right;
2135
- case 'instanceof': return left instanceof right;
2136
- case 'in': return left in right;
2137
- default: return undefined;
2034
+ expect(type, val) {
2035
+ const t = this.next();
2036
+ if (t.t !== type || (val !== undefined && t.v !== val)) {
2037
+ throw new Error(`Expected ${val || type} but got ${t.v}`);
2038
+ }
2039
+ return t;
2138
2040
  }
2139
- }
2140
2041
 
2042
+ match(type, val) {
2043
+ const t = this.peek();
2044
+ if (t.t === type && (val === undefined || t.v === val)) {
2045
+ return this.next();
2046
+ }
2047
+ return null;
2048
+ }
2141
2049
 
2142
- // ---------------------------------------------------------------------------
2143
- // Public API
2144
- // ---------------------------------------------------------------------------
2145
-
2146
- /**
2147
- * Safely evaluate a JS expression string against scope layers.
2148
- *
2149
- * @param {string} expr — expression string
2150
- * @param {object[]} scope — array of scope objects, checked in order
2151
- * Typical: [loopVars, state, { props, refs, $ }]
2152
- * @returns {*} — evaluation result, or undefined on error
2153
- */
2050
+ // Main entry
2051
+ parse() {
2052
+ const result = this.parseExpression(0);
2053
+ return result;
2054
+ }
2154
2055
 
2155
- // AST cache (LRU) — avoids re-tokenizing and re-parsing the same expression.
2156
- // Uses Map insertion-order: on hit, delete + re-set moves entry to the end.
2157
- // Eviction removes the least-recently-used (first) entry when at capacity.
2158
- const _astCache = new Map();
2159
- const _AST_CACHE_MAX = 512;
2056
+ // Precedence climbing
2057
+ parseExpression(minPrec) {
2058
+ let left = this.parseUnary();
2160
2059
 
2161
- function safeEval(expr, scope) {
2162
- try {
2163
- const trimmed = expr.trim();
2164
- if (!trimmed) return undefined;
2060
+ while (true) {
2061
+ const tok = this.peek();
2165
2062
 
2166
- // Fast path for simple identifiers: "count", "name", "visible"
2167
- // Avoids full tokenize→parse→evaluate overhead for the most common case.
2168
- if (/^[a-zA-Z_$][\w$]*$/.test(trimmed)) {
2169
- for (const layer of scope) {
2170
- if (layer && typeof layer === 'object' && trimmed in layer) {
2171
- return layer[trimmed];
2063
+ // Ternary
2064
+ if (tok.t === T.PUNC && tok.v === '?') {
2065
+ // Distinguish ternary ? from optional chaining ?.
2066
+ if (this.tokens[this.pos + 1]?.v !== '.') {
2067
+ if (1 <= minPrec) break; // ternary has very low precedence
2068
+ this.next(); // consume ?
2069
+ const truthy = this.parseExpression(0);
2070
+ this.expect(T.PUNC, ':');
2071
+ const falsy = this.parseExpression(0);
2072
+ left = { type: 'ternary', cond: left, truthy, falsy };
2073
+ continue;
2172
2074
  }
2173
2075
  }
2174
- // Fall through to full parser for built-in globals (Math, JSON, etc.)
2175
- }
2176
2076
 
2177
- // Check AST cache (LRU: move to end on hit)
2178
- let ast = _astCache.get(trimmed);
2179
- if (ast) {
2180
- _astCache.delete(trimmed);
2181
- _astCache.set(trimmed, ast);
2182
- } else {
2183
- const tokens = tokenize(trimmed);
2184
- const parser = new Parser(tokens, scope);
2185
- ast = parser.parse();
2077
+ // Binary operators
2078
+ if (tok.t === T.OP && tok.v in PREC) {
2079
+ const prec = PREC[tok.v];
2080
+ if (prec <= minPrec) break;
2081
+ this.next();
2082
+ const right = this.parseExpression(prec);
2083
+ left = { type: 'binary', op: tok.v, left, right };
2084
+ continue;
2085
+ }
2186
2086
 
2187
- // Evict oldest entries when cache is full
2188
- if (_astCache.size >= _AST_CACHE_MAX) {
2189
- const first = _astCache.keys().next().value;
2190
- _astCache.delete(first);
2087
+ // instanceof and in as binary operators
2088
+ if (tok.t === T.IDENT && (tok.v === 'instanceof' || tok.v === 'in') && PREC[tok.v] > minPrec) {
2089
+ const prec = PREC[tok.v];
2090
+ this.next();
2091
+ const right = this.parseExpression(prec);
2092
+ left = { type: 'binary', op: tok.v, left, right };
2093
+ continue;
2191
2094
  }
2192
- _astCache.set(trimmed, ast);
2193
- }
2194
2095
 
2195
- return evaluate(ast, scope);
2196
- } catch (err) {
2197
- if (typeof console !== 'undefined' && console.debug) {
2198
- console.debug(`[zQuery EXPR_EVAL] Failed to evaluate: "${expr}"`, err.message);
2096
+ break;
2199
2097
  }
2200
- return undefined;
2098
+
2099
+ return left;
2201
2100
  }
2202
- }
2203
-
2204
- // --- src/diff.js -------------------------------------------------
2205
- /**
2206
- * zQuery Diff — Lightweight DOM morphing engine
2207
- *
2208
- * Patches an existing DOM tree to match new HTML without destroying nodes
2209
- * that haven't changed. Preserves focus, scroll positions, third-party
2210
- * widget state, video playback, and other live DOM state.
2211
- *
2212
- * Approach: walk old and new trees in parallel, reconcile node by node.
2213
- * Keyed elements (via `z-key`) get matched across position changes.
2214
- *
2215
- * Performance advantages over virtual DOM (React/Angular):
2216
- * - No virtual tree allocation or diffing — works directly on real DOM
2217
- * - Skips unchanged subtrees via fast isEqualNode() check
2218
- * - z-skip attribute to opt out of diffing entire subtrees
2219
- * - Reuses a single template element for HTML parsing (zero GC pressure)
2220
- * - Keyed reconciliation uses LIS (Longest Increasing Subsequence) to
2221
- * minimize DOM moves — same algorithm as Vue 3 / ivi
2222
- * - Minimal attribute diffing with early bail-out
2223
- */
2224
2101
 
2225
- // ---------------------------------------------------------------------------
2226
- // Reusable template element — avoids per-call allocation
2227
- // ---------------------------------------------------------------------------
2228
- let _tpl = null;
2102
+ parseUnary() {
2103
+ const tok = this.peek();
2229
2104
 
2230
- function _getTemplate() {
2231
- if (!_tpl) _tpl = document.createElement('template');
2232
- return _tpl;
2233
- }
2105
+ // typeof
2106
+ if (tok.t === T.IDENT && tok.v === 'typeof') {
2107
+ this.next();
2108
+ const arg = this.parseUnary();
2109
+ return { type: 'typeof', arg };
2110
+ }
2234
2111
 
2235
- // ---------------------------------------------------------------------------
2236
- // morph(existingRoot, newHTML) patch existing DOM to match newHTML
2237
- // ---------------------------------------------------------------------------
2112
+ // void
2113
+ if (tok.t === T.IDENT && tok.v === 'void') {
2114
+ this.next();
2115
+ this.parseUnary(); // evaluate but discard
2116
+ return { type: 'literal', value: undefined };
2117
+ }
2238
2118
 
2239
- /**
2240
- * Morph an existing DOM element's children to match new HTML.
2241
- * Only touches nodes that actually differ.
2242
- *
2243
- * @param {Element} rootEl The live DOM container to patch
2244
- * @param {string} newHTML — The desired HTML string
2245
- */
2246
- function morph(rootEl, newHTML) {
2247
- const start = typeof window !== 'undefined' && window.__zqMorphHook ? performance.now() : 0;
2248
- const tpl = _getTemplate();
2249
- tpl.innerHTML = newHTML;
2250
- const newRoot = tpl.content;
2119
+ // !expr
2120
+ if (tok.t === T.OP && tok.v === '!') {
2121
+ this.next();
2122
+ const arg = this.parseUnary();
2123
+ return { type: 'not', arg };
2124
+ }
2251
2125
 
2252
- // Move children into a wrapper for consistent handling.
2253
- // We move (not clone) from the template cheaper than cloning.
2254
- const tempDiv = document.createElement('div');
2255
- while (newRoot.firstChild) tempDiv.appendChild(newRoot.firstChild);
2126
+ // -expr, +expr
2127
+ if (tok.t === T.OP && (tok.v === '-' || tok.v === '+')) {
2128
+ this.next();
2129
+ const arg = this.parseUnary();
2130
+ return { type: 'unary', op: tok.v, arg };
2131
+ }
2256
2132
 
2257
- _morphChildren(rootEl, tempDiv);
2133
+ return this.parsePostfix();
2134
+ }
2258
2135
 
2259
- if (start) window.__zqMorphHook(rootEl, performance.now() - start);
2260
- }
2136
+ parsePostfix() {
2137
+ let left = this.parsePrimary();
2261
2138
 
2262
- /**
2263
- * Morph a single element in place — diffs attributes and children
2264
- * without replacing the node reference. Useful for replaceWith-style
2265
- * updates where you want to keep the element identity when the tag
2266
- * name matches.
2267
- *
2268
- * If the new HTML produces a different tag, falls back to native replace.
2269
- *
2270
- * @param {Element} oldEl — The live DOM element to patch
2271
- * @param {string} newHTML — HTML string for the replacement element
2272
- * @returns {Element} — The resulting element (same ref if morphed, new if replaced)
2273
- */
2274
- function morphElement(oldEl, newHTML) {
2275
- const start = typeof window !== 'undefined' && window.__zqMorphHook ? performance.now() : 0;
2276
- const tpl = _getTemplate();
2277
- tpl.innerHTML = newHTML;
2278
- const newEl = tpl.content.firstElementChild;
2279
- if (!newEl) return oldEl;
2139
+ while (true) {
2140
+ const tok = this.peek();
2280
2141
 
2281
- // Same tag — morph in place (preserves identity, event listeners, refs)
2282
- if (oldEl.nodeName === newEl.nodeName) {
2283
- _morphAttributes(oldEl, newEl);
2284
- _morphChildren(oldEl, newEl);
2285
- if (start) window.__zqMorphHook(oldEl, performance.now() - start);
2286
- return oldEl;
2287
- }
2142
+ // Property access: a.b
2143
+ if (tok.t === T.PUNC && tok.v === '.') {
2144
+ this.next();
2145
+ const prop = this.next();
2146
+ left = { type: 'member', obj: left, prop: prop.v, computed: false };
2147
+ // Check for method call: a.b()
2148
+ if (this.peek().t === T.PUNC && this.peek().v === '(') {
2149
+ left = this._parseCall(left);
2150
+ }
2151
+ continue;
2152
+ }
2288
2153
 
2289
- // Different tag must replace (can't morph <div> into <span>)
2290
- const clone = newEl.cloneNode(true);
2291
- oldEl.parentNode.replaceChild(clone, oldEl);
2292
- if (start) window.__zqMorphHook(clone, performance.now() - start);
2293
- return clone;
2294
- }
2154
+ // Optional chaining: a?.b, a?.[b], a?.()
2155
+ if (tok.t === T.OP && tok.v === '?.') {
2156
+ this.next();
2157
+ const next = this.peek();
2158
+ if (next.t === T.PUNC && next.v === '[') {
2159
+ // a?.[expr]
2160
+ this.next();
2161
+ const prop = this.parseExpression(0);
2162
+ this.expect(T.PUNC, ']');
2163
+ left = { type: 'optional_member', obj: left, prop, computed: true };
2164
+ } else if (next.t === T.PUNC && next.v === '(') {
2165
+ // a?.()
2166
+ left = { type: 'optional_call', callee: left, args: this._parseArgs() };
2167
+ } else {
2168
+ // a?.b
2169
+ const prop = this.next();
2170
+ left = { type: 'optional_member', obj: left, prop: prop.v, computed: false };
2171
+ if (this.peek().t === T.PUNC && this.peek().v === '(') {
2172
+ left = this._parseCall(left);
2173
+ }
2174
+ }
2175
+ continue;
2176
+ }
2295
2177
 
2296
- /**
2297
- * Reconcile children of `oldParent` to match `newParent`.
2298
- *
2299
- * @param {Element} oldParent — live DOM parent
2300
- * @param {Element} newParent — desired state parent
2301
- */
2302
- function _morphChildren(oldParent, newParent) {
2303
- // Snapshot live NodeLists into arrays childNodes is live and
2304
- // mutates during insertBefore/removeChild. Using a for loop to push
2305
- // avoids spread operator overhead for large child lists.
2306
- const oldCN = oldParent.childNodes;
2307
- const newCN = newParent.childNodes;
2308
- const oldLen = oldCN.length;
2309
- const newLen = newCN.length;
2310
- const oldChildren = new Array(oldLen);
2311
- const newChildren = new Array(newLen);
2312
- for (let i = 0; i < oldLen; i++) oldChildren[i] = oldCN[i];
2313
- for (let i = 0; i < newLen; i++) newChildren[i] = newCN[i];
2178
+ // Computed access: a[b]
2179
+ if (tok.t === T.PUNC && tok.v === '[') {
2180
+ this.next();
2181
+ const prop = this.parseExpression(0);
2182
+ this.expect(T.PUNC, ']');
2183
+ left = { type: 'member', obj: left, prop, computed: true };
2184
+ // Check for method call: a[b]()
2185
+ if (this.peek().t === T.PUNC && this.peek().v === '(') {
2186
+ left = this._parseCall(left);
2187
+ }
2188
+ continue;
2189
+ }
2190
+
2191
+ // Function call: fn()
2192
+ if (tok.t === T.PUNC && tok.v === '(') {
2193
+ left = this._parseCall(left);
2194
+ continue;
2195
+ }
2314
2196
 
2315
- // Scan for keyed elements — only build maps if keys exist
2316
- let hasKeys = false;
2317
- let oldKeyMap, newKeyMap;
2197
+ break;
2198
+ }
2318
2199
 
2319
- for (let i = 0; i < oldLen; i++) {
2320
- if (_getKey(oldChildren[i]) != null) { hasKeys = true; break; }
2200
+ return left;
2321
2201
  }
2322
- if (!hasKeys) {
2323
- for (let i = 0; i < newLen; i++) {
2324
- if (_getKey(newChildren[i]) != null) { hasKeys = true; break; }
2325
- }
2202
+
2203
+ _parseCall(callee) {
2204
+ const args = this._parseArgs();
2205
+ return { type: 'call', callee, args };
2326
2206
  }
2327
2207
 
2328
- if (hasKeys) {
2329
- oldKeyMap = new Map();
2330
- newKeyMap = new Map();
2331
- for (let i = 0; i < oldLen; i++) {
2332
- const key = _getKey(oldChildren[i]);
2333
- if (key != null) oldKeyMap.set(key, i);
2334
- }
2335
- for (let i = 0; i < newLen; i++) {
2336
- const key = _getKey(newChildren[i]);
2337
- if (key != null) newKeyMap.set(key, i);
2208
+ _parseArgs() {
2209
+ this.expect(T.PUNC, '(');
2210
+ const args = [];
2211
+ while (!(this.peek().t === T.PUNC && this.peek().v === ')') && this.peek().t !== T.EOF) {
2212
+ if (this.peek().t === T.OP && this.peek().v === '...') {
2213
+ this.next();
2214
+ args.push({ type: 'spread', arg: this.parseExpression(0) });
2215
+ } else {
2216
+ args.push(this.parseExpression(0));
2217
+ }
2218
+ if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
2338
2219
  }
2339
- _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap);
2340
- } else {
2341
- _morphChildrenUnkeyed(oldParent, oldChildren, newChildren);
2220
+ this.expect(T.PUNC, ')');
2221
+ return args;
2342
2222
  }
2343
- }
2344
2223
 
2345
- /**
2346
- * Unkeyed reconciliation — positional matching.
2347
- */
2348
- function _morphChildrenUnkeyed(oldParent, oldChildren, newChildren) {
2349
- const oldLen = oldChildren.length;
2350
- const newLen = newChildren.length;
2351
- const minLen = oldLen < newLen ? oldLen : newLen;
2224
+ parsePrimary() {
2225
+ const tok = this.peek();
2352
2226
 
2353
- // Morph overlapping range
2354
- for (let i = 0; i < minLen; i++) {
2355
- _morphNode(oldParent, oldChildren[i], newChildren[i]);
2356
- }
2227
+ // Number literal
2228
+ if (tok.t === T.NUM) {
2229
+ this.next();
2230
+ return { type: 'literal', value: tok.v };
2231
+ }
2357
2232
 
2358
- // Append new nodes
2359
- if (newLen > oldLen) {
2360
- for (let i = oldLen; i < newLen; i++) {
2361
- oldParent.appendChild(newChildren[i].cloneNode(true));
2233
+ // String literal
2234
+ if (tok.t === T.STR) {
2235
+ this.next();
2236
+ return { type: 'literal', value: tok.v };
2362
2237
  }
2363
- }
2364
2238
 
2365
- // Remove excess old nodes (iterate backwards to avoid index shifting)
2366
- if (oldLen > newLen) {
2367
- for (let i = oldLen - 1; i >= newLen; i--) {
2368
- oldParent.removeChild(oldChildren[i]);
2239
+ // Template literal
2240
+ if (tok.t === T.TMPL) {
2241
+ this.next();
2242
+ return { type: 'template', parts: tok.v };
2369
2243
  }
2370
- }
2371
- }
2372
2244
 
2373
- /**
2374
- * Keyed reconciliation — match by z-key, reorder with minimal moves
2375
- * using Longest Increasing Subsequence (LIS) to find the maximum set
2376
- * of nodes that are already in the correct relative order, then only
2377
- * move the remaining nodes.
2378
- */
2379
- function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap) {
2380
- const consumed = new Set();
2381
- const newLen = newChildren.length;
2382
- const matched = new Array(newLen);
2245
+ // Arrow function with parens: () =>, (a) =>, (a, b) =>
2246
+ // or regular grouping: (expr)
2247
+ if (tok.t === T.PUNC && tok.v === '(') {
2248
+ const savedPos = this.pos;
2249
+ this.next(); // consume (
2250
+ const params = [];
2251
+ let couldBeArrow = true;
2383
2252
 
2384
- // Step 1: Match new children to old children by key
2385
- for (let i = 0; i < newLen; i++) {
2386
- const key = _getKey(newChildren[i]);
2387
- if (key != null && oldKeyMap.has(key)) {
2388
- const oldIdx = oldKeyMap.get(key);
2389
- matched[i] = oldChildren[oldIdx];
2390
- consumed.add(oldIdx);
2391
- } else {
2392
- matched[i] = null;
2393
- }
2394
- }
2253
+ if (this.peek().t === T.PUNC && this.peek().v === ')') {
2254
+ // () => ... no params
2255
+ } else {
2256
+ while (couldBeArrow) {
2257
+ const p = this.peek();
2258
+ if (p.t === T.IDENT && !KEYWORDS.has(p.v)) {
2259
+ params.push(this.next().v);
2260
+ if (this.peek().t === T.PUNC && this.peek().v === ',') {
2261
+ this.next();
2262
+ } else {
2263
+ break;
2264
+ }
2265
+ } else {
2266
+ couldBeArrow = false;
2267
+ }
2268
+ }
2269
+ }
2395
2270
 
2396
- // Step 2: Remove old keyed nodes not in the new tree
2397
- for (let i = oldChildren.length - 1; i >= 0; i--) {
2398
- if (!consumed.has(i)) {
2399
- const key = _getKey(oldChildren[i]);
2400
- if (key != null && !newKeyMap.has(key)) {
2401
- oldParent.removeChild(oldChildren[i]);
2271
+ if (couldBeArrow && this.peek().t === T.PUNC && this.peek().v === ')') {
2272
+ this.next(); // consume )
2273
+ if (this.peek().t === T.OP && this.peek().v === '=>') {
2274
+ this.next(); // consume =>
2275
+ const body = this.parseExpression(0);
2276
+ return { type: 'arrow', params, body };
2277
+ }
2402
2278
  }
2279
+
2280
+ // Not an arrow — restore and parse as grouping
2281
+ this.pos = savedPos;
2282
+ this.next(); // consume (
2283
+ const expr = this.parseExpression(0);
2284
+ this.expect(T.PUNC, ')');
2285
+ return expr;
2403
2286
  }
2404
- }
2405
2287
 
2406
- // Step 3: Build index array for LIS of matched old indices.
2407
- // This finds the largest set of keyed nodes already in order,
2408
- // so we only need to move the rest — O(n log n) instead of O(n²).
2409
- const oldIndices = []; // Maps new-position → old-position (or -1)
2410
- for (let i = 0; i < newLen; i++) {
2411
- if (matched[i]) {
2412
- const key = _getKey(newChildren[i]);
2413
- oldIndices.push(oldKeyMap.get(key));
2414
- } else {
2415
- oldIndices.push(-1);
2288
+ // Array literal
2289
+ if (tok.t === T.PUNC && tok.v === '[') {
2290
+ this.next();
2291
+ const elements = [];
2292
+ while (!(this.peek().t === T.PUNC && this.peek().v === ']') && this.peek().t !== T.EOF) {
2293
+ if (this.peek().t === T.OP && this.peek().v === '...') {
2294
+ this.next();
2295
+ elements.push({ type: 'spread', arg: this.parseExpression(0) });
2296
+ } else {
2297
+ elements.push(this.parseExpression(0));
2298
+ }
2299
+ if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
2300
+ }
2301
+ this.expect(T.PUNC, ']');
2302
+ return { type: 'array', elements };
2416
2303
  }
2417
- }
2418
2304
 
2419
- const lisSet = _lis(oldIndices);
2305
+ // Object literal
2306
+ if (tok.t === T.PUNC && tok.v === '{') {
2307
+ this.next();
2308
+ const properties = [];
2309
+ while (!(this.peek().t === T.PUNC && this.peek().v === '}') && this.peek().t !== T.EOF) {
2310
+ // Spread in object: { ...obj }
2311
+ if (this.peek().t === T.OP && this.peek().v === '...') {
2312
+ this.next();
2313
+ properties.push({ spread: true, value: this.parseExpression(0) });
2314
+ if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
2315
+ continue;
2316
+ }
2420
2317
 
2421
- // Step 4: Insert / reorder / morph — walk new children forward,
2422
- // using LIS to decide which nodes stay in place.
2423
- let cursor = oldParent.firstChild;
2424
- const unkeyedOld = [];
2425
- for (let i = 0; i < oldChildren.length; i++) {
2426
- if (!consumed.has(i) && _getKey(oldChildren[i]) == null) {
2427
- unkeyedOld.push(oldChildren[i]);
2318
+ const keyTok = this.next();
2319
+ let key;
2320
+ if (keyTok.t === T.IDENT || keyTok.t === T.STR) key = keyTok.v;
2321
+ else if (keyTok.t === T.NUM) key = String(keyTok.v);
2322
+ else throw new Error('Invalid object key: ' + keyTok.v);
2323
+
2324
+ // Shorthand property: { foo } means { foo: foo }
2325
+ if (this.peek().t === T.PUNC && (this.peek().v === ',' || this.peek().v === '}')) {
2326
+ properties.push({ key, value: { type: 'ident', name: key } });
2327
+ } else {
2328
+ this.expect(T.PUNC, ':');
2329
+ properties.push({ key, value: this.parseExpression(0) });
2330
+ }
2331
+ if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
2332
+ }
2333
+ this.expect(T.PUNC, '}');
2334
+ return { type: 'object', properties };
2428
2335
  }
2429
- }
2430
- let unkeyedIdx = 0;
2431
2336
 
2432
- for (let i = 0; i < newLen; i++) {
2433
- const newNode = newChildren[i];
2434
- const newKey = _getKey(newNode);
2435
- let oldNode = matched[i];
2337
+ // Identifiers & keywords
2338
+ if (tok.t === T.IDENT) {
2339
+ this.next();
2436
2340
 
2437
- if (!oldNode && newKey == null) {
2438
- oldNode = unkeyedOld[unkeyedIdx++] || null;
2439
- }
2341
+ // Keywords
2342
+ if (tok.v === 'true') return { type: 'literal', value: true };
2343
+ if (tok.v === 'false') return { type: 'literal', value: false };
2344
+ if (tok.v === 'null') return { type: 'literal', value: null };
2345
+ if (tok.v === 'undefined') return { type: 'literal', value: undefined };
2440
2346
 
2441
- if (oldNode) {
2442
- // If this node is NOT part of the LIS, it needs to be moved
2443
- if (!lisSet.has(i)) {
2444
- oldParent.insertBefore(oldNode, cursor);
2445
- }
2446
- // Capture next sibling BEFORE _morphNode — if _morphNode calls
2447
- // replaceChild, oldNode is removed and nextSibling becomes stale.
2448
- const nextSib = oldNode.nextSibling;
2449
- _morphNode(oldParent, oldNode, newNode);
2450
- cursor = nextSib;
2451
- } else {
2452
- // Insert new node
2453
- const clone = newNode.cloneNode(true);
2454
- if (cursor) {
2455
- oldParent.insertBefore(clone, cursor);
2456
- } else {
2457
- oldParent.appendChild(clone);
2347
+ // new keyword
2348
+ if (tok.v === 'new') {
2349
+ let classExpr = this.parsePrimary();
2350
+ // Handle member access (e.g. ns.MyClass) without consuming call args
2351
+ while (this.peek().t === T.PUNC && this.peek().v === '.') {
2352
+ this.next();
2353
+ const prop = this.next();
2354
+ classExpr = { type: 'member', obj: classExpr, prop: prop.v, computed: false };
2355
+ }
2356
+ let args = [];
2357
+ if (this.peek().t === T.PUNC && this.peek().v === '(') {
2358
+ args = this._parseArgs();
2359
+ }
2360
+ return { type: 'new', callee: classExpr, args };
2458
2361
  }
2459
- }
2460
- }
2461
-
2462
- // Remove remaining unkeyed old nodes
2463
- while (unkeyedIdx < unkeyedOld.length) {
2464
- const leftover = unkeyedOld[unkeyedIdx++];
2465
- if (leftover.parentNode === oldParent) {
2466
- oldParent.removeChild(leftover);
2467
- }
2468
- }
2469
2362
 
2470
- // Remove any remaining keyed old nodes that weren't consumed
2471
- for (let i = 0; i < oldChildren.length; i++) {
2472
- if (!consumed.has(i)) {
2473
- const node = oldChildren[i];
2474
- if (node.parentNode === oldParent && _getKey(node) != null && !newKeyMap.has(_getKey(node))) {
2475
- oldParent.removeChild(node);
2363
+ // Arrow function: x => expr
2364
+ if (this.peek().t === T.OP && this.peek().v === '=>') {
2365
+ this.next(); // consume =>
2366
+ const body = this.parseExpression(0);
2367
+ return { type: 'arrow', params: [tok.v], body };
2476
2368
  }
2369
+
2370
+ return { type: 'ident', name: tok.v };
2477
2371
  }
2372
+
2373
+ // Fallback — return undefined for unparseable
2374
+ this.next();
2375
+ return { type: 'literal', value: undefined };
2478
2376
  }
2479
2377
  }
2480
2378
 
2379
+ // ---------------------------------------------------------------------------
2380
+ // Evaluator — walks the AST, resolves against scope
2381
+ // ---------------------------------------------------------------------------
2382
+
2383
+ /** Safe property access whitelist for built-in prototypes */
2384
+ const SAFE_ARRAY_METHODS = new Set([
2385
+ 'length', 'map', 'filter', 'find', 'findIndex', 'some', 'every',
2386
+ 'reduce', 'reduceRight', 'forEach', 'includes', 'indexOf', 'lastIndexOf',
2387
+ 'join', 'slice', 'concat', 'flat', 'flatMap', 'reverse', 'sort',
2388
+ 'fill', 'keys', 'values', 'entries', 'at', 'toString',
2389
+ ]);
2390
+
2391
+ const SAFE_STRING_METHODS = new Set([
2392
+ 'length', 'charAt', 'charCodeAt', 'includes', 'indexOf', 'lastIndexOf',
2393
+ 'slice', 'substring', 'trim', 'trimStart', 'trimEnd', 'toLowerCase',
2394
+ 'toUpperCase', 'split', 'replace', 'replaceAll', 'match', 'search',
2395
+ 'startsWith', 'endsWith', 'padStart', 'padEnd', 'repeat', 'at',
2396
+ 'toString', 'valueOf',
2397
+ ]);
2398
+
2399
+ const SAFE_NUMBER_METHODS = new Set([
2400
+ 'toFixed', 'toPrecision', 'toString', 'valueOf',
2401
+ ]);
2402
+
2403
+ const SAFE_OBJECT_METHODS = new Set([
2404
+ 'hasOwnProperty', 'toString', 'valueOf',
2405
+ ]);
2406
+
2407
+ const SAFE_MATH_PROPS = new Set([
2408
+ 'PI', 'E', 'LN2', 'LN10', 'LOG2E', 'LOG10E', 'SQRT2', 'SQRT1_2',
2409
+ 'abs', 'ceil', 'floor', 'round', 'trunc', 'max', 'min', 'pow',
2410
+ 'sqrt', 'sign', 'random', 'log', 'log2', 'log10',
2411
+ ]);
2412
+
2413
+ const SAFE_JSON_PROPS = new Set(['parse', 'stringify']);
2414
+
2481
2415
  /**
2482
- * Compute the Longest Increasing Subsequence of an index array.
2483
- * Returns a Set of positions (in the input) that form the LIS.
2484
- * Entries with value -1 (unmatched) are excluded.
2485
- *
2486
- * O(n log n) — same algorithm used by Vue 3 and ivi.
2487
- *
2488
- * @param {number[]} arr — array of old-tree indices (-1 = unmatched)
2489
- * @returns {Set<number>} — positions in arr belonging to the LIS
2416
+ * Check if property access is safe
2490
2417
  */
2491
- function _lis(arr) {
2492
- const len = arr.length;
2493
- const result = new Set();
2494
- if (len === 0) return result;
2418
+ function _isSafeAccess(obj, prop) {
2419
+ // Never allow access to dangerous properties
2420
+ const BLOCKED = new Set([
2421
+ 'constructor', '__proto__', 'prototype', '__defineGetter__',
2422
+ '__defineSetter__', '__lookupGetter__', '__lookupSetter__',
2423
+ 'call', 'apply', 'bind',
2424
+ ]);
2425
+ if (typeof prop === 'string' && BLOCKED.has(prop)) return false;
2495
2426
 
2496
- // tails[i] = index in arr of the smallest tail element for LIS of length i+1
2497
- const tails = [];
2498
- // prev[i] = predecessor index in arr for the LIS ending at arr[i]
2499
- const prev = new Array(len).fill(-1);
2500
- const tailIndices = []; // parallel to tails: actual positions
2427
+ // Always allow plain object/function property access and array index access
2428
+ if (obj !== null && obj !== undefined && (typeof obj === 'object' || typeof obj === 'function')) return true;
2429
+ if (typeof obj === 'string') return SAFE_STRING_METHODS.has(prop);
2430
+ if (typeof obj === 'number') return SAFE_NUMBER_METHODS.has(prop);
2431
+ return false;
2432
+ }
2501
2433
 
2502
- for (let i = 0; i < len; i++) {
2503
- if (arr[i] === -1) continue;
2504
- const val = arr[i];
2434
+ function evaluate(node, scope) {
2435
+ if (!node) return undefined;
2505
2436
 
2506
- // Binary search for insertion point in tails
2507
- let lo = 0, hi = tails.length;
2508
- while (lo < hi) {
2509
- const mid = (lo + hi) >> 1;
2510
- if (tails[mid] < val) lo = mid + 1;
2511
- else hi = mid;
2437
+ switch (node.type) {
2438
+ case 'literal':
2439
+ return node.value;
2440
+
2441
+ case 'ident': {
2442
+ const name = node.name;
2443
+ // Check scope layers in order
2444
+ for (const layer of scope) {
2445
+ if (layer && typeof layer === 'object' && name in layer) {
2446
+ return layer[name];
2447
+ }
2448
+ }
2449
+ // Built-in globals (safe ones only)
2450
+ if (name === 'Math') return Math;
2451
+ if (name === 'JSON') return JSON;
2452
+ if (name === 'Date') return Date;
2453
+ if (name === 'Array') return Array;
2454
+ if (name === 'Object') return Object;
2455
+ if (name === 'String') return String;
2456
+ if (name === 'Number') return Number;
2457
+ if (name === 'Boolean') return Boolean;
2458
+ if (name === 'parseInt') return parseInt;
2459
+ if (name === 'parseFloat') return parseFloat;
2460
+ if (name === 'isNaN') return isNaN;
2461
+ if (name === 'isFinite') return isFinite;
2462
+ if (name === 'Infinity') return Infinity;
2463
+ if (name === 'NaN') return NaN;
2464
+ if (name === 'encodeURIComponent') return encodeURIComponent;
2465
+ if (name === 'decodeURIComponent') return decodeURIComponent;
2466
+ if (name === 'console') return console;
2467
+ if (name === 'Map') return Map;
2468
+ if (name === 'Set') return Set;
2469
+ if (name === 'RegExp') return RegExp;
2470
+ if (name === 'Error') return Error;
2471
+ if (name === 'URL') return URL;
2472
+ if (name === 'URLSearchParams') return URLSearchParams;
2473
+ return undefined;
2512
2474
  }
2513
2475
 
2514
- tails[lo] = val;
2515
- tailIndices[lo] = i;
2516
- prev[i] = lo > 0 ? tailIndices[lo - 1] : -1;
2517
- }
2476
+ case 'template': {
2477
+ // Template literal with interpolation
2478
+ let result = '';
2479
+ for (const part of node.parts) {
2480
+ if (typeof part === 'string') {
2481
+ result += part;
2482
+ } else if (part && part.expr) {
2483
+ result += String(safeEval(part.expr, scope) ?? '');
2484
+ }
2485
+ }
2486
+ return result;
2487
+ }
2518
2488
 
2519
- // Reconstruct: walk backwards from the last element of LIS
2520
- let k = tailIndices[tails.length - 1];
2521
- for (let i = tails.length - 1; i >= 0; i--) {
2522
- result.add(k);
2523
- k = prev[k];
2524
- }
2489
+ case 'member': {
2490
+ const obj = evaluate(node.obj, scope);
2491
+ if (obj == null) return undefined;
2492
+ const prop = node.computed ? evaluate(node.prop, scope) : node.prop;
2493
+ if (!_isSafeAccess(obj, prop)) return undefined;
2494
+ return obj[prop];
2495
+ }
2525
2496
 
2526
- return result;
2527
- }
2497
+ case 'optional_member': {
2498
+ const obj = evaluate(node.obj, scope);
2499
+ if (obj == null) return undefined;
2500
+ const prop = node.computed ? evaluate(node.prop, scope) : node.prop;
2501
+ if (!_isSafeAccess(obj, prop)) return undefined;
2502
+ return obj[prop];
2503
+ }
2528
2504
 
2529
- /**
2530
- * Morph a single node in place.
2531
- */
2532
- function _morphNode(parent, oldNode, newNode) {
2533
- // Text / comment nodes — just update content
2534
- if (oldNode.nodeType === 3 || oldNode.nodeType === 8) {
2535
- if (newNode.nodeType === oldNode.nodeType) {
2536
- if (oldNode.nodeValue !== newNode.nodeValue) {
2537
- oldNode.nodeValue = newNode.nodeValue;
2505
+ case 'call': {
2506
+ const result = _resolveCall(node, scope, false);
2507
+ return result;
2508
+ }
2509
+
2510
+ case 'optional_call': {
2511
+ const calleeNode = node.callee;
2512
+ const args = _evalArgs(node.args, scope);
2513
+ // Method call: obj?.method() — bind `this` to obj
2514
+ if (calleeNode.type === 'member' || calleeNode.type === 'optional_member') {
2515
+ const obj = evaluate(calleeNode.obj, scope);
2516
+ if (obj == null) return undefined;
2517
+ const prop = calleeNode.computed ? evaluate(calleeNode.prop, scope) : calleeNode.prop;
2518
+ if (!_isSafeAccess(obj, prop)) return undefined;
2519
+ const fn = obj[prop];
2520
+ if (typeof fn !== 'function') return undefined;
2521
+ return fn.apply(obj, args);
2538
2522
  }
2539
- return;
2523
+ const callee = evaluate(calleeNode, scope);
2524
+ if (callee == null) return undefined;
2525
+ if (typeof callee !== 'function') return undefined;
2526
+ return callee(...args);
2540
2527
  }
2541
- // Different node types — replace
2542
- parent.replaceChild(newNode.cloneNode(true), oldNode);
2543
- return;
2544
- }
2545
2528
 
2546
- // Different node types or tag names — replace entirely
2547
- if (oldNode.nodeType !== newNode.nodeType ||
2548
- oldNode.nodeName !== newNode.nodeName) {
2549
- parent.replaceChild(newNode.cloneNode(true), oldNode);
2550
- return;
2551
- }
2529
+ case 'new': {
2530
+ const Ctor = evaluate(node.callee, scope);
2531
+ if (typeof Ctor !== 'function') return undefined;
2532
+ // Only allow safe constructors
2533
+ if (Ctor === Date || Ctor === Array || Ctor === Map || Ctor === Set ||
2534
+ Ctor === RegExp || Ctor === Error || Ctor === URL || Ctor === URLSearchParams) {
2535
+ const args = _evalArgs(node.args, scope);
2536
+ return new Ctor(...args);
2537
+ }
2538
+ return undefined;
2539
+ }
2552
2540
 
2553
- // Both are elements — diff attributes then recurse children
2554
- if (oldNode.nodeType === 1) {
2555
- // z-skip: developer opt-out — skip diffing this subtree entirely.
2556
- // Useful for third-party widgets, canvas, video, or large static content.
2557
- if (oldNode.hasAttribute('z-skip')) return;
2541
+ case 'binary':
2542
+ return _evalBinary(node, scope);
2558
2543
 
2559
- // Fast bail-out: if the elements are identical, skip everything.
2560
- // isEqualNode() is a native C++ comparison — much faster than walking
2561
- // attributes + children in JS when trees haven't changed.
2562
- if (oldNode.isEqualNode(newNode)) return;
2544
+ case 'unary': {
2545
+ const val = evaluate(node.arg, scope);
2546
+ return node.op === '-' ? -val : +val;
2547
+ }
2563
2548
 
2564
- _morphAttributes(oldNode, newNode);
2549
+ case 'not':
2550
+ return !evaluate(node.arg, scope);
2551
+
2552
+ case 'typeof': {
2553
+ try {
2554
+ return typeof evaluate(node.arg, scope);
2555
+ } catch {
2556
+ return 'undefined';
2557
+ }
2558
+ }
2565
2559
 
2566
- // Special elements: don't recurse into their children
2567
- const tag = oldNode.nodeName;
2568
- if (tag === 'INPUT') {
2569
- _syncInputValue(oldNode, newNode);
2570
- return;
2560
+ case 'ternary': {
2561
+ const cond = evaluate(node.cond, scope);
2562
+ return cond ? evaluate(node.truthy, scope) : evaluate(node.falsy, scope);
2571
2563
  }
2572
- if (tag === 'TEXTAREA') {
2573
- if (oldNode.value !== newNode.textContent) {
2574
- oldNode.value = newNode.textContent || '';
2564
+
2565
+ case 'array': {
2566
+ const arr = [];
2567
+ for (const e of node.elements) {
2568
+ if (e.type === 'spread') {
2569
+ const iterable = evaluate(e.arg, scope);
2570
+ if (iterable != null && typeof iterable[Symbol.iterator] === 'function') {
2571
+ for (const v of iterable) arr.push(v);
2572
+ }
2573
+ } else {
2574
+ arr.push(evaluate(e, scope));
2575
+ }
2575
2576
  }
2576
- return;
2577
+ return arr;
2577
2578
  }
2578
- if (tag === 'SELECT') {
2579
- _morphChildren(oldNode, newNode);
2580
- if (oldNode.value !== newNode.value) {
2581
- oldNode.value = newNode.value;
2579
+
2580
+ case 'object': {
2581
+ const obj = {};
2582
+ for (const prop of node.properties) {
2583
+ if (prop.spread) {
2584
+ const source = evaluate(prop.value, scope);
2585
+ if (source != null && typeof source === 'object') {
2586
+ Object.assign(obj, source);
2587
+ }
2588
+ } else {
2589
+ obj[prop.key] = evaluate(prop.value, scope);
2590
+ }
2582
2591
  }
2583
- return;
2592
+ return obj;
2584
2593
  }
2585
2594
 
2586
- // Generic element — recurse children
2587
- _morphChildren(oldNode, newNode);
2595
+ case 'arrow': {
2596
+ const paramNames = node.params;
2597
+ const bodyNode = node.body;
2598
+ const closedScope = scope;
2599
+ return function(...args) {
2600
+ const arrowScope = {};
2601
+ paramNames.forEach((name, i) => { arrowScope[name] = args[i]; });
2602
+ return evaluate(bodyNode, [arrowScope, ...closedScope]);
2603
+ };
2604
+ }
2605
+
2606
+ default:
2607
+ return undefined;
2588
2608
  }
2589
2609
  }
2590
2610
 
2591
2611
  /**
2592
- * Sync attributes from newEl onto oldEl.
2593
- * Uses a single pass: build a set of new attribute names, iterate
2594
- * old attrs for removals, then apply new attrs.
2612
+ * Evaluate a list of argument AST nodes, flattening any spread elements.
2595
2613
  */
2596
- function _morphAttributes(oldEl, newEl) {
2597
- const newAttrs = newEl.attributes;
2598
- const oldAttrs = oldEl.attributes;
2599
- const newLen = newAttrs.length;
2600
- const oldLen = oldAttrs.length;
2601
-
2602
- // Fast path: if both have same number of attributes, check if they're identical
2603
- if (newLen === oldLen) {
2604
- let same = true;
2605
- for (let i = 0; i < newLen; i++) {
2606
- const na = newAttrs[i];
2607
- if (oldEl.getAttribute(na.name) !== na.value) { same = false; break; }
2608
- }
2609
- if (same) {
2610
- // Also verify no extra old attrs (names mismatch)
2611
- for (let i = 0; i < oldLen; i++) {
2612
- if (!newEl.hasAttribute(oldAttrs[i].name)) { same = false; break; }
2614
+ function _evalArgs(argNodes, scope) {
2615
+ const result = [];
2616
+ for (const a of argNodes) {
2617
+ if (a.type === 'spread') {
2618
+ const iterable = evaluate(a.arg, scope);
2619
+ if (iterable != null && typeof iterable[Symbol.iterator] === 'function') {
2620
+ for (const v of iterable) result.push(v);
2613
2621
  }
2622
+ } else {
2623
+ result.push(evaluate(a, scope));
2614
2624
  }
2615
- if (same) return;
2616
2625
  }
2626
+ return result;
2627
+ }
2617
2628
 
2618
- // Build set of new attr names for O(1) lookup during removal pass
2619
- const newNames = new Set();
2620
- for (let i = 0; i < newLen; i++) {
2621
- const attr = newAttrs[i];
2622
- newNames.add(attr.name);
2623
- if (oldEl.getAttribute(attr.name) !== attr.value) {
2624
- oldEl.setAttribute(attr.name, attr.value);
2625
- }
2626
- }
2629
+ /**
2630
+ * Resolve and execute a function call safely.
2631
+ */
2632
+ function _resolveCall(node, scope) {
2633
+ const callee = node.callee;
2634
+ const args = _evalArgs(node.args, scope);
2627
2635
 
2628
- // Remove stale attributessnapshot names first because oldAttrs
2629
- // is a live NamedNodeMap that mutates on removeAttribute().
2630
- const oldNames = new Array(oldLen);
2631
- for (let i = 0; i < oldLen; i++) oldNames[i] = oldAttrs[i].name;
2632
- for (let i = oldNames.length - 1; i >= 0; i--) {
2633
- if (!newNames.has(oldNames[i])) {
2634
- oldEl.removeAttribute(oldNames[i]);
2635
- }
2636
+ // Method call: obj.method()bind `this` to obj
2637
+ if (callee.type === 'member' || callee.type === 'optional_member') {
2638
+ const obj = evaluate(callee.obj, scope);
2639
+ if (obj == null) return undefined;
2640
+ const prop = callee.computed ? evaluate(callee.prop, scope) : callee.prop;
2641
+ if (!_isSafeAccess(obj, prop)) return undefined;
2642
+ const fn = obj[prop];
2643
+ if (typeof fn !== 'function') return undefined;
2644
+ return fn.apply(obj, args);
2636
2645
  }
2646
+
2647
+ // Direct call: fn(args)
2648
+ const fn = evaluate(callee, scope);
2649
+ if (typeof fn !== 'function') return undefined;
2650
+ return fn(...args);
2637
2651
  }
2638
2652
 
2639
2653
  /**
2640
- * Sync input element value, checked, disabled states.
2654
+ * Evaluate binary expression.
2641
2655
  */
2642
- function _syncInputValue(oldEl, newEl) {
2643
- const type = (oldEl.type || '').toLowerCase();
2644
-
2645
- if (type === 'checkbox' || type === 'radio') {
2646
- if (oldEl.checked !== newEl.checked) oldEl.checked = newEl.checked;
2647
- } else {
2648
- if (oldEl.value !== (newEl.getAttribute('value') || '')) {
2649
- oldEl.value = newEl.getAttribute('value') || '';
2650
- }
2656
+ function _evalBinary(node, scope) {
2657
+ // Short-circuit for logical ops
2658
+ if (node.op === '&&') {
2659
+ const left = evaluate(node.left, scope);
2660
+ return left ? evaluate(node.right, scope) : left;
2661
+ }
2662
+ if (node.op === '||') {
2663
+ const left = evaluate(node.left, scope);
2664
+ return left ? left : evaluate(node.right, scope);
2665
+ }
2666
+ if (node.op === '??') {
2667
+ const left = evaluate(node.left, scope);
2668
+ return left != null ? left : evaluate(node.right, scope);
2651
2669
  }
2652
2670
 
2653
- // Sync disabled
2654
- if (oldEl.disabled !== newEl.disabled) oldEl.disabled = newEl.disabled;
2671
+ const left = evaluate(node.left, scope);
2672
+ const right = evaluate(node.right, scope);
2673
+
2674
+ switch (node.op) {
2675
+ case '+': return left + right;
2676
+ case '-': return left - right;
2677
+ case '*': return left * right;
2678
+ case '/': return left / right;
2679
+ case '%': return left % right;
2680
+ case '==': return left == right;
2681
+ case '!=': return left != right;
2682
+ case '===': return left === right;
2683
+ case '!==': return left !== right;
2684
+ case '<': return left < right;
2685
+ case '>': return left > right;
2686
+ case '<=': return left <= right;
2687
+ case '>=': return left >= right;
2688
+ case 'instanceof': return left instanceof right;
2689
+ case 'in': return left in right;
2690
+ default: return undefined;
2691
+ }
2655
2692
  }
2656
2693
 
2694
+
2695
+ // ---------------------------------------------------------------------------
2696
+ // Public API
2697
+ // ---------------------------------------------------------------------------
2698
+
2657
2699
  /**
2658
- * Get the reconciliation key from a node.
2659
- *
2660
- * Priority: z-key attribute → id attribute → data-id / data-key.
2661
- * Auto-detected keys use a `\0` prefix to avoid collisions with
2662
- * explicit z-key values.
2663
- *
2664
- * This means the LIS-optimised keyed path activates automatically
2665
- * whenever elements carry `id` or `data-id` / `data-key` attributes
2666
- * — no extra markup required.
2700
+ * Safely evaluate a JS expression string against scope layers.
2667
2701
  *
2668
- * @returns {string|null}
2702
+ * @param {string} expr — expression string
2703
+ * @param {object[]} scope — array of scope objects, checked in order
2704
+ * Typical: [loopVars, state, { props, refs, $ }]
2705
+ * @returns {*} — evaluation result, or undefined on error
2669
2706
  */
2670
- function _getKey(node) {
2671
- if (node.nodeType !== 1) return null;
2672
2707
 
2673
- // Explicit z-keyhighest priority
2674
- const zk = node.getAttribute('z-key');
2675
- if (zk) return zk;
2708
+ // AST cache (LRU) avoids re-tokenizing and re-parsing the same expression.
2709
+ // Uses Map insertion-order: on hit, delete + re-set moves entry to the end.
2710
+ // Eviction removes the least-recently-used (first) entry when at capacity.
2711
+ const _astCache = new Map();
2712
+ const _AST_CACHE_MAX = 512;
2676
2713
 
2677
- // Auto-key: id attribute (unique by spec)
2678
- if (node.id) return '\0id:' + node.id;
2714
+ function safeEval(expr, scope) {
2715
+ try {
2716
+ const trimmed = expr.trim();
2717
+ if (!trimmed) return undefined;
2679
2718
 
2680
- // Auto-key: data-id or data-key attributes
2681
- const ds = node.dataset;
2682
- if (ds) {
2683
- if (ds.id) return '\0data-id:' + ds.id;
2684
- if (ds.key) return '\0data-key:' + ds.key;
2685
- }
2719
+ // Fast path for simple identifiers: "count", "name", "visible"
2720
+ // Avoids full tokenize→parse→evaluate overhead for the most common case.
2721
+ if (/^[a-zA-Z_$][\w$]*$/.test(trimmed)) {
2722
+ for (const layer of scope) {
2723
+ if (layer && typeof layer === 'object' && trimmed in layer) {
2724
+ return layer[trimmed];
2725
+ }
2726
+ }
2727
+ // Fall through to full parser for built-in globals (Math, JSON, etc.)
2728
+ }
2686
2729
 
2687
- return null;
2730
+ // Check AST cache (LRU: move to end on hit)
2731
+ let ast = _astCache.get(trimmed);
2732
+ if (ast) {
2733
+ _astCache.delete(trimmed);
2734
+ _astCache.set(trimmed, ast);
2735
+ } else {
2736
+ const tokens = tokenize(trimmed);
2737
+ const parser = new Parser(tokens, scope);
2738
+ ast = parser.parse();
2739
+
2740
+ // Evict oldest entries when cache is full
2741
+ if (_astCache.size >= _AST_CACHE_MAX) {
2742
+ const first = _astCache.keys().next().value;
2743
+ _astCache.delete(first);
2744
+ }
2745
+ _astCache.set(trimmed, ast);
2746
+ }
2747
+
2748
+ return evaluate(ast, scope);
2749
+ } catch (err) {
2750
+ if (typeof console !== 'undefined' && console.debug) {
2751
+ console.debug(`[zQuery EXPR_EVAL] Failed to evaluate: "${expr}"`, err.message);
2752
+ }
2753
+ return undefined;
2754
+ }
2688
2755
  }
2689
2756
 
2690
2757
  // --- src/component.js --------------------------------------------
@@ -3191,11 +3258,14 @@ class Component {
3191
3258
  this._bindRefs();
3192
3259
  this._bindModels();
3193
3260
 
3194
- // Restore focus if the morph replaced the focused element
3261
+ // Restore focus if the morph replaced the focused element.
3262
+ // Always restore selectionRange — even when the element is still
3263
+ // the activeElement — because _bindModels or morph attribute syncing
3264
+ // can alter the value and move the cursor.
3195
3265
  if (_focusInfo) {
3196
3266
  const el = this._el.querySelector(_focusInfo.selector);
3197
- if (el && el !== document.activeElement) {
3198
- el.focus();
3267
+ if (el) {
3268
+ if (el !== document.activeElement) el.focus();
3199
3269
  try {
3200
3270
  if (_focusInfo.start !== null && _focusInfo.start !== undefined) {
3201
3271
  el.setSelectionRange(_focusInfo.start, _focusInfo.end, _focusInfo.dir);
@@ -5833,12 +5903,15 @@ $.onError = onError;
5833
5903
  $.ZQueryError = ZQueryError;
5834
5904
  $.ErrorCode = ErrorCode;
5835
5905
  $.guardCallback = guardCallback;
5906
+ $.guardAsync = guardAsync;
5836
5907
  $.validate = validate;
5908
+ $.formatError = formatError;
5837
5909
 
5838
5910
  // --- Meta ------------------------------------------------------------------
5839
- $.version = '0.9.7';
5840
- $.libSize = '~100 KB';
5841
- $.meta = {}; // populated at build time by CLI bundler
5911
+ $.version = '0.9.9';
5912
+ $.libSize = '~101 KB';
5913
+ $.unitTests = {"passed":1808,"failed":0,"total":1808,"suites":493,"duration":2896,"ok":true};
5914
+ $.meta = {}; // populated at build time by CLI bundler
5842
5915
 
5843
5916
  $.noConflict = () => {
5844
5917
  if (typeof window !== 'undefined' && window.$ === $) {