zero-query 0.9.7 → 0.9.8

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