zero-query 0.9.6 → 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 (59) hide show
  1. package/README.md +36 -8
  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 +1949 -1894
  38. package/dist/zquery.min.js +2 -2
  39. package/index.d.ts +10 -1
  40. package/index.js +5 -3
  41. package/package.json +1 -1
  42. package/src/component.js +6 -3
  43. package/src/diff.js +15 -2
  44. package/src/http.js +37 -0
  45. package/tests/cli.test.js +304 -0
  46. package/tests/http.test.js +200 -0
  47. package/types/http.d.ts +15 -4
  48. package/cli/scaffold/app/components/about.js +0 -131
  49. package/cli/scaffold/app/components/api-demo.js +0 -103
  50. package/cli/scaffold/app/components/contacts/contacts.css +0 -246
  51. package/cli/scaffold/app/components/contacts/contacts.html +0 -140
  52. package/cli/scaffold/app/components/contacts/contacts.js +0 -153
  53. package/cli/scaffold/app/components/counter.js +0 -85
  54. package/cli/scaffold/app/components/home.js +0 -137
  55. package/cli/scaffold/app/components/todos.js +0 -131
  56. package/cli/scaffold/app/routes.js +0 -13
  57. /package/cli/scaffold/{LICENSE → default/LICENSE} +0 -0
  58. /package/cli/scaffold/{assets → default/assets}/.gitkeep +0 -0
  59. /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.6
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,972 +353,1471 @@ 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',
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
+ }
1348
+
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
+ }
1354
+
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
+ }
1360
+
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
+ }
1372
+
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
+ }
1382
+
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;
1390
+ }
1391
+
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
+ }
1399
+
1400
+ detach() {
1401
+ return this.each((_, el) => el.remove());
1402
+ }
1403
+
1404
+ // --- Visibility ----------------------------------------------------------
1405
+
1406
+ show(display = '') {
1407
+ return this.each((_, el) => { el.style.display = display; });
1408
+ }
1409
+
1410
+ hide() {
1411
+ return this.each((_, el) => { el.style.display = 'none'; });
1412
+ }
1413
+
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
+ });
1420
+ }
1421
+
1422
+ // --- Events --------------------------------------------------------------
1423
+
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
+ }
1448
+
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
+ });
1464
+ }
1465
+ });
1466
+ });
1467
+ }
1468
+
1469
+ one(event, handler) {
1470
+ return this.each((_, el) => {
1471
+ el.addEventListener(event, handler, { once: true });
1472
+ });
1473
+ }
1474
+
1475
+ trigger(event, detail) {
1476
+ return this.each((_, el) => {
1477
+ el.dispatchEvent(new CustomEvent(event, { detail, bubbles: true, cancelable: true }));
1478
+ });
1479
+ }
1480
+
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
+ }
1490
+
1491
+ // --- Animation -----------------------------------------------------------
1492
+
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 = '';
1524
+ }
1525
+ resolve(this);
1526
+ }
1527
+ }, duration + 50);
1528
+ });
1529
+ }
1530
+
1531
+ fadeIn(duration = 300) {
1532
+ return this.css({ opacity: '0', display: '' }).animate({ opacity: '1' }, duration);
1533
+ }
1534
+
1535
+ fadeOut(duration = 300) {
1536
+ return this.animate({ opacity: '0' }, duration).then(col => col.hide());
1537
+ }
1538
+
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);
1546
+ }
1547
+
1548
+ fadeTo(duration, opacity) {
1549
+ return this.animate({ opacity: String(opacity) }, duration);
1550
+ }
1551
+
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
+ }
1563
+
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
+ }
1573
+
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);
1584
+ } else {
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);
1590
+ }
1591
+ });
1592
+ }
1593
+
1594
+ // --- Form helpers --------------------------------------------------------
1595
+
1596
+ serialize() {
1597
+ const form = this.first();
1598
+ if (!form || form.tagName !== 'FORM') return '';
1599
+ return new URLSearchParams(new FormData(form)).toString();
1600
+ }
1601
+
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;
1612
+ }
1613
+ });
1614
+ return obj;
1615
+ }
1616
+ }
1617
+
1618
+
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
+ }
1627
+
1628
+
1629
+ // ---------------------------------------------------------------------------
1630
+ // $() — main selector / creator (returns ZQueryCollection, like jQuery)
1631
+ // ---------------------------------------------------------------------------
1632
+ function query(selector, context) {
1633
+ // null / undefined
1634
+ if (!selector) return new ZQueryCollection([]);
1635
+
1636
+ // Already a collection — return as-is
1637
+ if (selector instanceof ZQueryCollection) return selector;
1638
+
1639
+ // DOM element or Window — wrap in collection
1640
+ if (selector instanceof Node || selector === window) {
1641
+ return new ZQueryCollection([selector]);
1642
+ }
1643
+
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
+ }
1648
+
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
+ }
1654
+
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)]);
1661
+ }
1662
+
1663
+ return new ZQueryCollection([]);
1664
+ }
1665
+
1666
+
1667
+ // ---------------------------------------------------------------------------
1668
+ // $.all() — collection selector (returns ZQueryCollection for CSS selectors)
1669
+ // ---------------------------------------------------------------------------
1670
+ function queryAll(selector, context) {
1671
+ // null / undefined
1672
+ if (!selector) return new ZQueryCollection([]);
1673
+
1674
+ // Already a collection
1675
+ if (selector instanceof ZQueryCollection) return selector;
1676
+
1677
+ // DOM element or Window
1678
+ if (selector instanceof Node || selector === window) {
1679
+ return new ZQueryCollection([selector]);
1680
+ }
1681
+
1682
+ // NodeList / HTMLCollection / Array
1683
+ if (selector instanceof NodeList || selector instanceof HTMLCollection || Array.isArray(selector)) {
1684
+ return new ZQueryCollection(Array.from(selector));
1685
+ }
1686
+
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
+ }
1692
+
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
+ }
1700
+
1701
+ return new ZQueryCollection([]);
1702
+ }
1703
+
1704
+
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));
1722
+
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
+ };
1739
+
1740
+ // DOM ready
1741
+ query.ready = (fn) => {
1742
+ if (document.readyState !== 'loading') fn();
1743
+ else document.addEventListener('DOMContentLoaded', fn);
1744
+ };
1745
+
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
+ };
1768
+
1769
+ // Remove a direct global listener
1770
+ query.off = (event, handler) => {
1771
+ document.removeEventListener(event, handler);
1772
+ };
1773
+
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
+ */
1802
+
1803
+ // Token types
1804
+ const T = {
1805
+ NUM: 1, STR: 2, IDENT: 3, OP: 4, PUNC: 5, TMPL: 6, EOF: 7
1806
+ };
1807
+
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
+ };
1818
+
1819
+ const KEYWORDS = new Set([
1820
+ 'true', 'false', 'null', 'undefined', 'typeof', 'instanceof', 'in',
1322
1821
  'new', 'void'
1323
1822
  ]);
1324
1823
 
@@ -1407,1284 +1906,798 @@ function tokenize(expr) {
1407
1906
  parts.push(str);
1408
1907
  tokens.push({ t: T.TMPL, v: parts });
1409
1908
  continue;
1410
- }
1411
-
1412
- // Identifiers & keywords
1413
- if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_' || ch === '$') {
1414
- let ident = '';
1415
- while (i < len && /[\w$]/.test(expr[i])) ident += expr[i++];
1416
- tokens.push({ t: T.IDENT, v: ident });
1417
- continue;
1418
- }
1419
-
1420
- // Multi-char operators
1421
- const two = expr.slice(i, i + 3);
1422
- if (two === '===' || two === '!==' || two === '?.') {
1423
- if (two === '?.') {
1424
- tokens.push({ t: T.OP, v: '?.' });
1425
- i += 2;
1426
- } else {
1427
- tokens.push({ t: T.OP, v: two });
1428
- i += 3;
1429
- }
1430
- continue;
1431
- }
1432
- const pair = expr.slice(i, i + 2);
1433
- if (pair === '==' || pair === '!=' || pair === '<=' || pair === '>=' ||
1434
- pair === '&&' || pair === '||' || pair === '??' || pair === '?.' ||
1435
- pair === '=>') {
1436
- tokens.push({ t: T.OP, v: pair });
1437
- i += 2;
1438
- continue;
1439
- }
1440
-
1441
- // Single char operators and punctuation
1442
- if ('+-*/%'.includes(ch)) {
1443
- tokens.push({ t: T.OP, v: ch });
1444
- i++; continue;
1445
- }
1446
- if ('<>=!'.includes(ch)) {
1447
- tokens.push({ t: T.OP, v: ch });
1448
- i++; continue;
1449
- }
1450
- // Spread operator: ...
1451
- if (ch === '.' && i + 2 < len && expr[i + 1] === '.' && expr[i + 2] === '.') {
1452
- tokens.push({ t: T.OP, v: '...' });
1453
- i += 3; continue;
1454
- }
1455
- if ('()[]{},.?:'.includes(ch)) {
1456
- tokens.push({ t: T.PUNC, v: ch });
1457
- i++; continue;
1458
- }
1459
-
1460
- // Unknown — skip
1461
- i++;
1462
- }
1463
-
1464
- tokens.push({ t: T.EOF, v: null });
1465
- return tokens;
1466
- }
1467
-
1468
- // ---------------------------------------------------------------------------
1469
- // Parser — Pratt (precedence climbing)
1470
- // ---------------------------------------------------------------------------
1471
- class Parser {
1472
- constructor(tokens, scope) {
1473
- this.tokens = tokens;
1474
- this.pos = 0;
1475
- this.scope = scope;
1476
- }
1477
-
1478
- peek() { return this.tokens[this.pos]; }
1479
- next() { return this.tokens[this.pos++]; }
1480
-
1481
- expect(type, val) {
1482
- const t = this.next();
1483
- if (t.t !== type || (val !== undefined && t.v !== val)) {
1484
- throw new Error(`Expected ${val || type} but got ${t.v}`);
1485
- }
1486
- return t;
1487
- }
1488
-
1489
- match(type, val) {
1490
- const t = this.peek();
1491
- if (t.t === type && (val === undefined || t.v === val)) {
1492
- return this.next();
1493
- }
1494
- return null;
1495
- }
1496
-
1497
- // Main entry
1498
- parse() {
1499
- const result = this.parseExpression(0);
1500
- return result;
1501
- }
1502
-
1503
- // Precedence climbing
1504
- parseExpression(minPrec) {
1505
- let left = this.parseUnary();
1506
-
1507
- while (true) {
1508
- const tok = this.peek();
1509
-
1510
- // Ternary
1511
- if (tok.t === T.PUNC && tok.v === '?') {
1512
- // Distinguish ternary ? from optional chaining ?.
1513
- if (this.tokens[this.pos + 1]?.v !== '.') {
1514
- if (1 <= minPrec) break; // ternary has very low precedence
1515
- this.next(); // consume ?
1516
- const truthy = this.parseExpression(0);
1517
- this.expect(T.PUNC, ':');
1518
- const falsy = this.parseExpression(0);
1519
- left = { type: 'ternary', cond: left, truthy, falsy };
1520
- continue;
1521
- }
1522
- }
1523
-
1524
- // Binary operators
1525
- if (tok.t === T.OP && tok.v in PREC) {
1526
- const prec = PREC[tok.v];
1527
- if (prec <= minPrec) break;
1528
- this.next();
1529
- const right = this.parseExpression(prec);
1530
- left = { type: 'binary', op: tok.v, left, right };
1531
- continue;
1532
- }
1533
-
1534
- // instanceof and in as binary operators
1535
- if (tok.t === T.IDENT && (tok.v === 'instanceof' || tok.v === 'in') && PREC[tok.v] > minPrec) {
1536
- const prec = PREC[tok.v];
1537
- this.next();
1538
- const right = this.parseExpression(prec);
1539
- left = { type: 'binary', op: tok.v, left, right };
1540
- continue;
1541
- }
1542
-
1543
- break;
1544
- }
1545
-
1546
- return left;
1547
- }
1548
-
1549
- parseUnary() {
1550
- const tok = this.peek();
1551
-
1552
- // typeof
1553
- if (tok.t === T.IDENT && tok.v === 'typeof') {
1554
- this.next();
1555
- const arg = this.parseUnary();
1556
- return { type: 'typeof', arg };
1557
- }
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();
1581
- }
1582
-
1583
- parsePostfix() {
1584
- let left = this.parsePrimary();
1585
-
1586
- while (true) {
1587
- const tok = this.peek();
1588
-
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
- }
1600
-
1601
- // Optional chaining: a?.b, a?.[b], a?.()
1602
- if (tok.t === T.OP && tok.v === '?.') {
1603
- this.next();
1604
- const next = this.peek();
1605
- if (next.t === T.PUNC && next.v === '[') {
1606
- // a?.[expr]
1607
- this.next();
1608
- const prop = this.parseExpression(0);
1609
- this.expect(T.PUNC, ']');
1610
- left = { type: 'optional_member', obj: left, prop, computed: true };
1611
- } else if (next.t === T.PUNC && next.v === '(') {
1612
- // a?.()
1613
- left = { type: 'optional_call', callee: left, args: this._parseArgs() };
1614
- } else {
1615
- // a?.b
1616
- const prop = this.next();
1617
- left = { type: 'optional_member', obj: left, prop: prop.v, computed: false };
1618
- if (this.peek().t === T.PUNC && this.peek().v === '(') {
1619
- left = this._parseCall(left);
1620
- }
1621
- }
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
- }
1643
-
1644
- break;
1645
- }
1646
-
1647
- return left;
1648
- }
1909
+ }
1649
1910
 
1650
- _parseCall(callee) {
1651
- const args = this._parseArgs();
1652
- return { type: 'call', callee, args };
1653
- }
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;
1917
+ }
1654
1918
 
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) });
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;
1662
1925
  } else {
1663
- args.push(this.parseExpression(0));
1926
+ tokens.push({ t: T.OP, v: two });
1927
+ i += 3;
1664
1928
  }
1665
- if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
1929
+ continue;
1666
1930
  }
1667
- this.expect(T.PUNC, ')');
1668
- return args;
1669
- }
1670
-
1671
- parsePrimary() {
1672
- const tok = this.peek();
1673
-
1674
- // Number literal
1675
- if (tok.t === T.NUM) {
1676
- this.next();
1677
- return { type: 'literal', value: tok.v };
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;
1678
1938
  }
1679
1939
 
1680
- // String literal
1681
- if (tok.t === T.STR) {
1682
- this.next();
1683
- return { type: 'literal', value: tok.v };
1940
+ // Single char operators and punctuation
1941
+ if ('+-*/%'.includes(ch)) {
1942
+ tokens.push({ t: T.OP, v: ch });
1943
+ i++; continue;
1684
1944
  }
1685
-
1686
- // Template literal
1687
- if (tok.t === T.TMPL) {
1688
- this.next();
1689
- return { type: 'template', parts: tok.v };
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;
1690
1957
  }
1691
1958
 
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;
1959
+ // Unknown skip
1960
+ i++;
1961
+ }
1699
1962
 
1700
- if (this.peek().t === T.PUNC && this.peek().v === ')') {
1701
- // () => ... — no params
1702
- } 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
- }
1716
- }
1963
+ tokens.push({ t: T.EOF, v: null });
1964
+ return tokens;
1965
+ }
1717
1966
 
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
- }
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;
1975
+ }
1726
1976
 
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
- }
1977
+ peek() { return this.tokens[this.pos]; }
1978
+ next() { return this.tokens[this.pos++]; }
1734
1979
 
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();
1747
- }
1748
- this.expect(T.PUNC, ']');
1749
- return { type: 'array', elements };
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}`);
1750
1984
  }
1985
+ return t;
1986
+ }
1751
1987
 
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
-
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);
1770
-
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 };
1988
+ match(type, val) {
1989
+ const t = this.peek();
1990
+ if (t.t === type && (val === undefined || t.v === val)) {
1991
+ return this.next();
1782
1992
  }
1993
+ return null;
1994
+ }
1783
1995
 
1784
- // Identifiers & keywords
1785
- if (tok.t === T.IDENT) {
1786
- this.next();
1996
+ // Main entry
1997
+ parse() {
1998
+ const result = this.parseExpression(0);
1999
+ return result;
2000
+ }
1787
2001
 
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 };
2002
+ // Precedence climbing
2003
+ parseExpression(minPrec) {
2004
+ let left = this.parseUnary();
1793
2005
 
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();
2006
+ while (true) {
2007
+ const tok = this.peek();
2008
+
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;
1806
2020
  }
1807
- return { type: 'new', callee: classExpr, args };
1808
2021
  }
1809
2022
 
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 };
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;
1815
2031
  }
1816
2032
 
1817
- return { type: 'ident', name: tok.v };
1818
- }
1819
-
1820
- // Fallback — return undefined for unparseable
1821
- this.next();
1822
- return { type: 'literal', value: undefined };
1823
- }
1824
- }
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;
2040
+ }
1825
2041
 
1826
- // ---------------------------------------------------------------------------
1827
- // Evaluator — walks the AST, resolves against scope
1828
- // ---------------------------------------------------------------------------
2042
+ break;
2043
+ }
1829
2044
 
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
- ]);
2045
+ return left;
2046
+ }
1837
2047
 
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
- ]);
2048
+ parseUnary() {
2049
+ const tok = this.peek();
1845
2050
 
1846
- const SAFE_NUMBER_METHODS = new Set([
1847
- 'toFixed', 'toPrecision', 'toString', 'valueOf',
1848
- ]);
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
+ }
1849
2057
 
1850
- const SAFE_OBJECT_METHODS = new Set([
1851
- 'hasOwnProperty', 'toString', 'valueOf',
1852
- ]);
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
+ }
1853
2064
 
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
- ]);
2065
+ // !expr
2066
+ if (tok.t === T.OP && tok.v === '!') {
2067
+ this.next();
2068
+ const arg = this.parseUnary();
2069
+ return { type: 'not', arg };
2070
+ }
1859
2071
 
1860
- const SAFE_JSON_PROPS = new Set(['parse', 'stringify']);
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
+ }
1861
2078
 
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;
2079
+ return this.parsePostfix();
2080
+ }
1873
2081
 
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;
1879
- }
2082
+ parsePostfix() {
2083
+ let left = this.parsePrimary();
1880
2084
 
1881
- function evaluate(node, scope) {
1882
- if (!node) return undefined;
2085
+ while (true) {
2086
+ const tok = this.peek();
1883
2087
 
1884
- switch (node.type) {
1885
- case 'literal':
1886
- return node.value;
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
+ }
1887
2099
 
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];
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
+ }
1894
2120
  }
2121
+ continue;
1895
2122
  }
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
- }
1922
2123
 
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) ?? '');
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);
1931
2133
  }
2134
+ continue;
1932
2135
  }
1933
- return result;
1934
- }
1935
2136
 
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
- }
2137
+ // Function call: fn()
2138
+ if (tok.t === T.PUNC && tok.v === '(') {
2139
+ left = this._parseCall(left);
2140
+ continue;
2141
+ }
1943
2142
 
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];
2143
+ break;
1950
2144
  }
1951
2145
 
1952
- case 'call': {
1953
- const result = _resolveCall(node, scope, false);
1954
- return result;
1955
- }
2146
+ return left;
2147
+ }
1956
2148
 
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
- }
2149
+ _parseCall(callee) {
2150
+ const args = this._parseArgs();
2151
+ return { type: 'call', callee, args };
2152
+ }
1975
2153
 
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);
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));
1984
2163
  }
1985
- return undefined;
2164
+ if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
1986
2165
  }
2166
+ this.expect(T.PUNC, ')');
2167
+ return args;
2168
+ }
1987
2169
 
1988
- case 'binary':
1989
- return _evalBinary(node, scope);
2170
+ parsePrimary() {
2171
+ const tok = this.peek();
1990
2172
 
1991
- case 'unary': {
1992
- const val = evaluate(node.arg, scope);
1993
- return node.op === '-' ? -val : +val;
2173
+ // Number literal
2174
+ if (tok.t === T.NUM) {
2175
+ this.next();
2176
+ return { type: 'literal', value: tok.v };
1994
2177
  }
1995
2178
 
1996
- case 'not':
1997
- return !evaluate(node.arg, scope);
1998
-
1999
- case 'typeof': {
2000
- try {
2001
- return typeof evaluate(node.arg, scope);
2002
- } catch {
2003
- return 'undefined';
2004
- }
2179
+ // String literal
2180
+ if (tok.t === T.STR) {
2181
+ this.next();
2182
+ return { type: 'literal', value: tok.v };
2005
2183
  }
2006
2184
 
2007
- case 'ternary': {
2008
- const cond = evaluate(node.cond, scope);
2009
- return cond ? evaluate(node.truthy, scope) : evaluate(node.falsy, scope);
2185
+ // Template literal
2186
+ if (tok.t === T.TMPL) {
2187
+ this.next();
2188
+ return { type: 'template', parts: tok.v };
2010
2189
  }
2011
2190
 
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);
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;
2198
+
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;
2019
2213
  }
2020
- } else {
2021
- arr.push(evaluate(e, scope));
2022
2214
  }
2023
2215
  }
2024
- return arr;
2025
- }
2026
2216
 
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);
2034
- }
2035
- } else {
2036
- obj[prop.key] = evaluate(prop.value, scope);
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 };
2037
2223
  }
2038
2224
  }
2039
- return obj;
2040
- }
2041
2225
 
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
- };
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;
2051
2232
  }
2052
2233
 
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);
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();
2068
2246
  }
2069
- } else {
2070
- result.push(evaluate(a, scope));
2247
+ this.expect(T.PUNC, ']');
2248
+ return { type: 'array', elements };
2071
2249
  }
2072
- }
2073
- return result;
2074
- }
2075
-
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);
2082
-
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);
2092
- }
2093
-
2094
- // Direct call: fn(args)
2095
- const fn = evaluate(callee, scope);
2096
- if (typeof fn !== 'function') return undefined;
2097
- return fn(...args);
2098
- }
2099
-
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);
2116
- }
2117
-
2118
- const left = evaluate(node.left, scope);
2119
- const right = evaluate(node.right, scope);
2120
-
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;
2138
- }
2139
- }
2140
2250
 
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
+ }
2141
2263
 
2142
- // ---------------------------------------------------------------------------
2143
- // Public API
2144
- // ---------------------------------------------------------------------------
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);
2145
2269
 
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
- */
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 };
2281
+ }
2154
2282
 
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;
2283
+ // Identifiers & keywords
2284
+ if (tok.t === T.IDENT) {
2285
+ this.next();
2160
2286
 
2161
- function safeEval(expr, scope) {
2162
- try {
2163
- const trimmed = expr.trim();
2164
- if (!trimmed) return undefined;
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 };
2165
2292
 
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];
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();
2172
2305
  }
2306
+ return { type: 'new', callee: classExpr, args };
2173
2307
  }
2174
- // Fall through to full parser for built-in globals (Math, JSON, etc.)
2175
- }
2176
-
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();
2186
2308
 
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);
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 };
2191
2314
  }
2192
- _astCache.set(trimmed, ast);
2193
- }
2194
2315
 
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);
2316
+ return { type: 'ident', name: tok.v };
2199
2317
  }
2200
- return undefined;
2201
- }
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
-
2225
- // ---------------------------------------------------------------------------
2226
- // Reusable template element — avoids per-call allocation
2227
- // ---------------------------------------------------------------------------
2228
- let _tpl = null;
2229
2318
 
2230
- function _getTemplate() {
2231
- if (!_tpl) _tpl = document.createElement('template');
2232
- return _tpl;
2319
+ // Fallback — return undefined for unparseable
2320
+ this.next();
2321
+ return { type: 'literal', value: undefined };
2322
+ }
2233
2323
  }
2234
2324
 
2235
2325
  // ---------------------------------------------------------------------------
2236
- // morph(existingRoot, newHTML) patch existing DOM to match newHTML
2326
+ // Evaluatorwalks the AST, resolves against scope
2237
2327
  // ---------------------------------------------------------------------------
2238
2328
 
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;
2251
-
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);
2256
-
2257
- _morphChildren(rootEl, tempDiv);
2258
-
2259
- if (start) window.__zqMorphHook(rootEl, performance.now() - start);
2260
- }
2261
-
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;
2280
-
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
- }
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
+ ]);
2288
2336
 
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
- }
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
+ ]);
2295
2344
 
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];
2345
+ const SAFE_NUMBER_METHODS = new Set([
2346
+ 'toFixed', 'toPrecision', 'toString', 'valueOf',
2347
+ ]);
2314
2348
 
2315
- // Scan for keyed elements — only build maps if keys exist
2316
- let hasKeys = false;
2317
- let oldKeyMap, newKeyMap;
2349
+ const SAFE_OBJECT_METHODS = new Set([
2350
+ 'hasOwnProperty', 'toString', 'valueOf',
2351
+ ]);
2318
2352
 
2319
- for (let i = 0; i < oldLen; i++) {
2320
- if (_getKey(oldChildren[i]) != null) { hasKeys = true; break; }
2321
- }
2322
- if (!hasKeys) {
2323
- for (let i = 0; i < newLen; i++) {
2324
- if (_getKey(newChildren[i]) != null) { hasKeys = true; break; }
2325
- }
2326
- }
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
+ ]);
2327
2358
 
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);
2338
- }
2339
- _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap);
2340
- } else {
2341
- _morphChildrenUnkeyed(oldParent, oldChildren, newChildren);
2342
- }
2343
- }
2359
+ const SAFE_JSON_PROPS = new Set(['parse', 'stringify']);
2344
2360
 
2345
2361
  /**
2346
- * Unkeyed reconciliation positional matching.
2362
+ * Check if property access is safe
2347
2363
  */
2348
- function _morphChildrenUnkeyed(oldParent, oldChildren, newChildren) {
2349
- const oldLen = oldChildren.length;
2350
- const newLen = newChildren.length;
2351
- const minLen = oldLen < newLen ? oldLen : newLen;
2352
-
2353
- // Morph overlapping range
2354
- for (let i = 0; i < minLen; i++) {
2355
- _morphNode(oldParent, oldChildren[i], newChildren[i]);
2356
- }
2357
-
2358
- // Append new nodes
2359
- if (newLen > oldLen) {
2360
- for (let i = oldLen; i < newLen; i++) {
2361
- oldParent.appendChild(newChildren[i].cloneNode(true));
2362
- }
2363
- }
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;
2364
2372
 
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]);
2369
- }
2370
- }
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;
2371
2378
  }
2372
2379
 
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);
2380
+ function evaluate(node, scope) {
2381
+ if (!node) return undefined;
2383
2382
 
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
- }
2383
+ switch (node.type) {
2384
+ case 'literal':
2385
+ return node.value;
2395
2386
 
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]);
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
+ }
2402
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;
2403
2420
  }
2404
- }
2405
2421
 
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);
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;
2416
2433
  }
2417
- }
2418
-
2419
- const lisSet = _lis(oldIndices);
2420
2434
 
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]);
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];
2428
2441
  }
2429
- }
2430
- let unkeyedIdx = 0;
2431
-
2432
- for (let i = 0; i < newLen; i++) {
2433
- const newNode = newChildren[i];
2434
- const newKey = _getKey(newNode);
2435
- let oldNode = matched[i];
2436
2442
 
2437
- if (!oldNode && newKey == null) {
2438
- oldNode = unkeyedOld[unkeyedIdx++] || null;
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];
2439
2449
  }
2440
2450
 
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);
2458
- }
2451
+ case 'call': {
2452
+ const result = _resolveCall(node, scope, false);
2453
+ return result;
2459
2454
  }
2460
- }
2461
2455
 
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);
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);
2468
+ }
2469
+ const callee = evaluate(calleeNode, scope);
2470
+ if (callee == null) return undefined;
2471
+ if (typeof callee !== 'function') return undefined;
2472
+ return callee(...args);
2467
2473
  }
2468
- }
2469
2474
 
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);
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);
2476
2483
  }
2484
+ return undefined;
2477
2485
  }
2478
- }
2479
- }
2480
-
2481
- /**
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
2490
- */
2491
- function _lis(arr) {
2492
- const len = arr.length;
2493
- const result = new Set();
2494
- if (len === 0) return result;
2495
-
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
2501
2486
 
2502
- for (let i = 0; i < len; i++) {
2503
- if (arr[i] === -1) continue;
2504
- const val = arr[i];
2487
+ case 'binary':
2488
+ return _evalBinary(node, scope);
2505
2489
 
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;
2490
+ case 'unary': {
2491
+ const val = evaluate(node.arg, scope);
2492
+ return node.op === '-' ? -val : +val;
2512
2493
  }
2513
2494
 
2514
- tails[lo] = val;
2515
- tailIndices[lo] = i;
2516
- prev[i] = lo > 0 ? tailIndices[lo - 1] : -1;
2517
- }
2518
-
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
- }
2525
-
2526
- return result;
2527
- }
2495
+ case 'not':
2496
+ return !evaluate(node.arg, scope);
2528
2497
 
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;
2498
+ case 'typeof': {
2499
+ try {
2500
+ return typeof evaluate(node.arg, scope);
2501
+ } catch {
2502
+ return 'undefined';
2538
2503
  }
2539
- return;
2540
2504
  }
2541
- // Different node types — replace
2542
- parent.replaceChild(newNode.cloneNode(true), oldNode);
2543
- return;
2544
- }
2545
2505
 
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
- }
2552
-
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;
2558
-
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;
2563
-
2564
- _morphAttributes(oldNode, newNode);
2565
-
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);
@@ -5127,6 +5143,7 @@ const http = {
5127
5143
  put: (url, data, opts) => request('PUT', url, data, opts),
5128
5144
  patch: (url, data, opts) => request('PATCH', url, data, opts),
5129
5145
  delete: (url, data, opts) => request('DELETE', url, data, opts),
5146
+ head: (url, opts) => request('HEAD', url, undefined, opts),
5130
5147
 
5131
5148
  /**
5132
5149
  * Configure defaults
@@ -5137,20 +5154,56 @@ const http = {
5137
5154
  if (opts.timeout !== undefined) _config.timeout = opts.timeout;
5138
5155
  },
5139
5156
 
5157
+ /**
5158
+ * Read-only snapshot of current configuration
5159
+ */
5160
+ getConfig() {
5161
+ return {
5162
+ baseURL: _config.baseURL,
5163
+ headers: { ..._config.headers },
5164
+ timeout: _config.timeout,
5165
+ };
5166
+ },
5167
+
5140
5168
  /**
5141
5169
  * Add request interceptor
5142
5170
  * @param {Function} fn — (fetchOpts, url) → void | false | { url, options }
5171
+ * @returns {Function} unsubscribe function
5143
5172
  */
5144
5173
  onRequest(fn) {
5145
5174
  _interceptors.request.push(fn);
5175
+ return () => {
5176
+ const idx = _interceptors.request.indexOf(fn);
5177
+ if (idx !== -1) _interceptors.request.splice(idx, 1);
5178
+ };
5146
5179
  },
5147
5180
 
5148
5181
  /**
5149
5182
  * Add response interceptor
5150
5183
  * @param {Function} fn — (result) → void
5184
+ * @returns {Function} unsubscribe function
5151
5185
  */
5152
5186
  onResponse(fn) {
5153
5187
  _interceptors.response.push(fn);
5188
+ return () => {
5189
+ const idx = _interceptors.response.indexOf(fn);
5190
+ if (idx !== -1) _interceptors.response.splice(idx, 1);
5191
+ };
5192
+ },
5193
+
5194
+ /**
5195
+ * Clear interceptors — all, or just 'request' / 'response'
5196
+ */
5197
+ clearInterceptors(type) {
5198
+ if (!type || type === 'request') _interceptors.request.length = 0;
5199
+ if (!type || type === 'response') _interceptors.response.length = 0;
5200
+ },
5201
+
5202
+ /**
5203
+ * Run multiple requests in parallel
5204
+ */
5205
+ all(requests) {
5206
+ return Promise.all(requests);
5154
5207
  },
5155
5208
 
5156
5209
  /**
@@ -5750,6 +5803,7 @@ $.post = http.post;
5750
5803
  $.put = http.put;
5751
5804
  $.patch = http.patch;
5752
5805
  $.delete = http.delete;
5806
+ $.head = http.head;
5753
5807
 
5754
5808
  // --- Utilities -------------------------------------------------------------
5755
5809
  $.debounce = debounce;
@@ -5798,9 +5852,10 @@ $.guardCallback = guardCallback;
5798
5852
  $.validate = validate;
5799
5853
 
5800
5854
  // --- Meta ------------------------------------------------------------------
5801
- $.version = '0.9.6';
5802
- $.libSize = '~100 KB';
5803
- $.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
5804
5859
 
5805
5860
  $.noConflict = () => {
5806
5861
  if (typeof window !== 'undefined' && window.$ === $) {