zero-query 0.9.9 → 1.0.0

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 (97) hide show
  1. package/README.md +33 -32
  2. package/cli/args.js +1 -1
  3. package/cli/commands/build.js +2 -2
  4. package/cli/commands/bundle.js +15 -15
  5. package/cli/commands/create.js +2 -2
  6. package/cli/commands/dev/devtools/index.js +1 -1
  7. package/cli/commands/dev/devtools/js/core.js +14 -14
  8. package/cli/commands/dev/devtools/js/elements.js +4 -4
  9. package/cli/commands/dev/devtools/js/stats.js +1 -1
  10. package/cli/commands/dev/devtools/styles.css +2 -2
  11. package/cli/commands/dev/index.js +2 -2
  12. package/cli/commands/dev/logger.js +1 -1
  13. package/cli/commands/dev/overlay.js +21 -14
  14. package/cli/commands/dev/server.js +5 -5
  15. package/cli/commands/dev/validator.js +7 -7
  16. package/cli/commands/dev/watcher.js +6 -6
  17. package/cli/help.js +3 -3
  18. package/cli/index.js +2 -2
  19. package/cli/scaffold/default/app/app.js +17 -18
  20. package/cli/scaffold/default/app/components/about.js +9 -9
  21. package/cli/scaffold/default/app/components/api-demo.js +6 -6
  22. package/cli/scaffold/default/app/components/contact-card.js +4 -4
  23. package/cli/scaffold/default/app/components/contacts/contacts.css +2 -2
  24. package/cli/scaffold/default/app/components/contacts/contacts.html +3 -3
  25. package/cli/scaffold/default/app/components/contacts/contacts.js +11 -11
  26. package/cli/scaffold/default/app/components/counter.js +8 -8
  27. package/cli/scaffold/default/app/components/home.js +13 -13
  28. package/cli/scaffold/default/app/components/not-found.js +1 -1
  29. package/cli/scaffold/default/app/components/playground/playground.css +1 -1
  30. package/cli/scaffold/default/app/components/playground/playground.html +11 -11
  31. package/cli/scaffold/default/app/components/playground/playground.js +11 -11
  32. package/cli/scaffold/default/app/components/todos.js +8 -8
  33. package/cli/scaffold/default/app/components/toolkit/toolkit.css +1 -1
  34. package/cli/scaffold/default/app/components/toolkit/toolkit.html +4 -4
  35. package/cli/scaffold/default/app/components/toolkit/toolkit.js +7 -7
  36. package/cli/scaffold/default/app/routes.js +1 -1
  37. package/cli/scaffold/default/app/store.js +1 -1
  38. package/cli/scaffold/default/global.css +2 -2
  39. package/cli/scaffold/default/index.html +2 -2
  40. package/cli/scaffold/minimal/app/app.js +6 -7
  41. package/cli/scaffold/minimal/app/components/about.js +5 -5
  42. package/cli/scaffold/minimal/app/components/counter.js +6 -6
  43. package/cli/scaffold/minimal/app/components/home.js +8 -8
  44. package/cli/scaffold/minimal/app/components/not-found.js +1 -1
  45. package/cli/scaffold/minimal/app/routes.js +1 -1
  46. package/cli/scaffold/minimal/app/store.js +1 -1
  47. package/cli/scaffold/minimal/global.css +2 -2
  48. package/cli/scaffold/minimal/index.html +1 -1
  49. package/cli/scaffold/ssr/app/app.js +1 -2
  50. package/cli/scaffold/ssr/app/components/about.js +5 -5
  51. package/cli/scaffold/ssr/app/components/home.js +2 -2
  52. package/cli/scaffold/ssr/app/components/not-found.js +1 -1
  53. package/cli/scaffold/ssr/app/routes.js +1 -1
  54. package/cli/scaffold/ssr/global.css +2 -2
  55. package/cli/scaffold/ssr/index.html +2 -2
  56. package/cli/scaffold/ssr/server/index.js +4 -4
  57. package/cli/utils.js +6 -6
  58. package/dist/zquery.dist.zip +0 -0
  59. package/dist/zquery.js +508 -227
  60. package/dist/zquery.min.js +2 -2
  61. package/index.d.ts +16 -13
  62. package/index.js +7 -5
  63. package/package.json +2 -2
  64. package/src/component.js +64 -63
  65. package/src/core.js +15 -15
  66. package/src/diff.js +38 -38
  67. package/src/errors.js +17 -17
  68. package/src/expression.js +15 -17
  69. package/src/http.js +4 -4
  70. package/src/reactive.js +75 -9
  71. package/src/router.js +104 -24
  72. package/src/ssr.js +28 -28
  73. package/src/store.js +103 -21
  74. package/src/utils.js +64 -12
  75. package/tests/audit.test.js +143 -15
  76. package/tests/cli.test.js +20 -20
  77. package/tests/component.test.js +121 -121
  78. package/tests/core.test.js +56 -56
  79. package/tests/diff.test.js +42 -42
  80. package/tests/errors.test.js +5 -5
  81. package/tests/expression.test.js +58 -53
  82. package/tests/http.test.js +20 -20
  83. package/tests/reactive.test.js +185 -24
  84. package/tests/router.test.js +501 -74
  85. package/tests/ssr.test.js +15 -13
  86. package/tests/store.test.js +264 -23
  87. package/tests/utils.test.js +163 -26
  88. package/types/collection.d.ts +2 -2
  89. package/types/component.d.ts +5 -5
  90. package/types/errors.d.ts +3 -3
  91. package/types/http.d.ts +3 -3
  92. package/types/misc.d.ts +9 -9
  93. package/types/reactive.d.ts +25 -3
  94. package/types/router.d.ts +10 -6
  95. package/types/ssr.d.ts +2 -2
  96. package/types/store.d.ts +40 -5
  97. package/types/utils.d.ts +1 -1
package/dist/zquery.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * zQuery (zeroQuery) v0.9.9
2
+ * zQuery (zeroQuery) v1.0.0
3
3
  * Lightweight Frontend Library
4
4
  * https://github.com/tonywied17/zero-query
5
5
  * (c) 2026 Anthony Wiedman - MIT License
@@ -9,7 +9,7 @@
9
9
 
10
10
  // --- src/errors.js -----------------------------------------------
11
11
  /**
12
- * zQuery Errors Structured error handling system
12
+ * zQuery Errors - Structured error handling system
13
13
  *
14
14
  * Provides typed error classes and a configurable error handler so that
15
15
  * errors surface consistently across all modules (reactive, component,
@@ -21,7 +21,7 @@
21
21
  */
22
22
 
23
23
  // ---------------------------------------------------------------------------
24
- // Error codes every zQuery error has a unique code for programmatic use
24
+ // Error codes - every zQuery error has a unique code for programmatic use
25
25
  // ---------------------------------------------------------------------------
26
26
  const ErrorCode = Object.freeze({
27
27
  // Reactive
@@ -71,14 +71,14 @@ const ErrorCode = Object.freeze({
71
71
 
72
72
 
73
73
  // ---------------------------------------------------------------------------
74
- // ZQueryError custom error class
74
+ // ZQueryError - custom error class
75
75
  // ---------------------------------------------------------------------------
76
76
  class ZQueryError extends Error {
77
77
  /**
78
- * @param {string} code one of ErrorCode values
79
- * @param {string} message human-readable description
80
- * @param {object} [context] extra data (component name, expression, etc.)
81
- * @param {Error} [cause] original error
78
+ * @param {string} code - one of ErrorCode values
79
+ * @param {string} message - human-readable description
80
+ * @param {object} [context] - extra data (component name, expression, etc.)
81
+ * @param {Error} [cause] - original error
82
82
  */
83
83
  constructor(code, message, context = {}, cause) {
84
84
  super(message);
@@ -98,10 +98,10 @@ let _errorHandlers = [];
98
98
  /**
99
99
  * Register a global error handler.
100
100
  * Called whenever zQuery catches an error internally.
101
- * Multiple handlers are supported each receives the error.
101
+ * Multiple handlers are supported - each receives the error.
102
102
  * Pass `null` to clear all handlers.
103
103
  *
104
- * @param {Function|null} handler (error: ZQueryError) => void
104
+ * @param {Function|null} handler - (error: ZQueryError) => void
105
105
  * @returns {Function} unsubscribe function to remove this handler
106
106
  */
107
107
  function onError(handler) {
@@ -119,9 +119,9 @@ function onError(handler) {
119
119
 
120
120
  /**
121
121
  * Report an error through the global handler and console.
122
- * Non-throwing used for recoverable errors in callbacks, lifecycle hooks, etc.
122
+ * Non-throwing - used for recoverable errors in callbacks, lifecycle hooks, etc.
123
123
  *
124
- * @param {string} code ErrorCode
124
+ * @param {string} code - ErrorCode
125
125
  * @param {string} message
126
126
  * @param {object} [context]
127
127
  * @param {Error} [cause]
@@ -145,7 +145,7 @@ function reportError(code, message, context = {}, cause) {
145
145
  * the current execution context.
146
146
  *
147
147
  * @param {Function} fn
148
- * @param {string} code ErrorCode to use if the callback throws
148
+ * @param {string} code - ErrorCode to use if the callback throws
149
149
  * @param {object} [context]
150
150
  * @returns {Function}
151
151
  */
@@ -164,8 +164,8 @@ function guardCallback(fn, code, context = {}) {
164
164
  * Throws ZQueryError on failure (for fast-fail at API boundaries).
165
165
  *
166
166
  * @param {*} value
167
- * @param {string} name parameter name for error message
168
- * @param {string} expectedType 'string', 'function', 'object', etc.
167
+ * @param {string} name - parameter name for error message
168
+ * @param {string} expectedType - 'string', 'function', 'object', etc.
169
169
  */
170
170
  function validate(value, name, expectedType) {
171
171
  if (value === undefined || value === null) {
@@ -200,11 +200,11 @@ function formatError(err) {
200
200
  }
201
201
 
202
202
  /**
203
- * Async version of guardCallback wraps an async function so that
203
+ * Async version of guardCallback - wraps an async function so that
204
204
  * rejections are caught, reported, and don't crash execution.
205
205
  *
206
- * @param {Function} fn async function
207
- * @param {string} code ErrorCode to use
206
+ * @param {Function} fn - async function
207
+ * @param {string} code - ErrorCode to use
208
208
  * @param {object} [context]
209
209
  * @returns {Function}
210
210
  */
@@ -220,7 +220,7 @@ function guardAsync(fn, code, context = {}) {
220
220
 
221
221
  // --- src/reactive.js ---------------------------------------------
222
222
  /**
223
- * zQuery Reactive Proxy-based deep reactivity system
223
+ * zQuery Reactive - Proxy-based deep reactivity system
224
224
  *
225
225
  * Creates observable objects that trigger callbacks on mutation.
226
226
  * Used internally by components and store for auto-updates.
@@ -287,7 +287,7 @@ function reactive(target, onChange, _path = '') {
287
287
 
288
288
 
289
289
  // ---------------------------------------------------------------------------
290
- // Signal lightweight reactive primitive (inspired by Solid/Preact signals)
290
+ // Signal - lightweight reactive primitive (inspired by Solid/Preact signals)
291
291
  // ---------------------------------------------------------------------------
292
292
  class Signal {
293
293
  constructor(value) {
@@ -316,7 +316,11 @@ class Signal {
316
316
  peek() { return this._value; }
317
317
 
318
318
  _notify() {
319
- // Snapshot subscribers before iterating — a subscriber might modify
319
+ if (Signal._batching) {
320
+ Signal._batchQueue.add(this);
321
+ return;
322
+ }
323
+ // Snapshot subscribers before iterating - a subscriber might modify
320
324
  // the set (e.g., an effect re-running, adding itself back)
321
325
  const subs = [...this._subscribers];
322
326
  for (let i = 0; i < subs.length; i++) {
@@ -337,10 +341,13 @@ class Signal {
337
341
 
338
342
  // Active effect tracking
339
343
  Signal._activeEffect = null;
344
+ // Batch state
345
+ Signal._batching = false;
346
+ Signal._batchQueue = new Set();
340
347
 
341
348
  /**
342
349
  * Create a signal
343
- * @param {*} initial initial value
350
+ * @param {*} initial - initial value
344
351
  * @returns {Signal}
345
352
  */
346
353
  function signal(initial) {
@@ -349,7 +356,7 @@ function signal(initial) {
349
356
 
350
357
  /**
351
358
  * Create a computed signal (derived from other signals)
352
- * @param {Function} fn computation function
359
+ * @param {Function} fn - computation function
353
360
  * @returns {Signal}
354
361
  */
355
362
  function computed(fn) {
@@ -367,10 +374,10 @@ function computed(fn) {
367
374
  /**
368
375
  * Create a side-effect that auto-tracks signal dependencies.
369
376
  * Returns a dispose function that removes the effect from all
370
- * signals it subscribed to prevents memory leaks.
377
+ * signals it subscribed to - prevents memory leaks.
371
378
  *
372
- * @param {Function} fn effect function
373
- * @returns {Function} dispose function
379
+ * @param {Function} fn - effect function
380
+ * @returns {Function} - dispose function
374
381
  */
375
382
  function effect(fn) {
376
383
  const execute = () => {
@@ -403,13 +410,72 @@ function effect(fn) {
403
410
  }
404
411
  execute._deps.clear();
405
412
  }
406
- // Don't clobber _activeEffect another effect may be running
413
+ // Don't clobber _activeEffect - another effect may be running
407
414
  };
415
+ }
416
+
417
+
418
+ // ---------------------------------------------------------------------------
419
+ // batch() - defer signal notifications until the batch completes
420
+ // ---------------------------------------------------------------------------
421
+
422
+ /**
423
+ * Batch multiple signal writes - subscribers and effects fire once at the end.
424
+ * @param {Function} fn - function that performs signal writes
425
+ */
426
+ function batch(fn) {
427
+ if (Signal._batching) {
428
+ // Already inside a batch, just run
429
+ fn();
430
+ return;
431
+ }
432
+ Signal._batching = true;
433
+ Signal._batchQueue.clear();
434
+ try {
435
+ fn();
436
+ } finally {
437
+ Signal._batching = false;
438
+ // Collect all unique subscribers across all queued signals
439
+ // so each subscriber/effect runs exactly once
440
+ const subs = new Set();
441
+ for (const sig of Signal._batchQueue) {
442
+ for (const sub of sig._subscribers) {
443
+ subs.add(sub);
444
+ }
445
+ }
446
+ Signal._batchQueue.clear();
447
+ for (const sub of subs) {
448
+ try { sub(); }
449
+ catch (err) {
450
+ reportError(ErrorCode.SIGNAL_CALLBACK, 'Signal subscriber threw', {}, err);
451
+ }
452
+ }
453
+ }
454
+ }
455
+
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // untracked() - read signals without creating dependencies
459
+ // ---------------------------------------------------------------------------
460
+
461
+ /**
462
+ * Execute a function without tracking signal reads as dependencies.
463
+ * @param {Function} fn - function to run
464
+ * @returns {*} the return value of fn
465
+ */
466
+ function untracked(fn) {
467
+ const prev = Signal._activeEffect;
468
+ Signal._activeEffect = null;
469
+ try {
470
+ return fn();
471
+ } finally {
472
+ Signal._activeEffect = prev;
473
+ }
408
474
  }
409
475
 
410
476
  // --- src/diff.js -------------------------------------------------
411
477
  /**
412
- * zQuery Diff Lightweight DOM morphing engine
478
+ * zQuery Diff - Lightweight DOM morphing engine
413
479
  *
414
480
  * Patches an existing DOM tree to match new HTML without destroying nodes
415
481
  * that haven't changed. Preserves focus, scroll positions, third-party
@@ -419,17 +485,17 @@ function effect(fn) {
419
485
  * Keyed elements (via `z-key`) get matched across position changes.
420
486
  *
421
487
  * Performance advantages over virtual DOM (React/Angular):
422
- * - No virtual tree allocation or diffing works directly on real DOM
488
+ * - No virtual tree allocation or diffing - works directly on real DOM
423
489
  * - Skips unchanged subtrees via fast isEqualNode() check
424
490
  * - z-skip attribute to opt out of diffing entire subtrees
425
491
  * - Reuses a single template element for HTML parsing (zero GC pressure)
426
492
  * - Keyed reconciliation uses LIS (Longest Increasing Subsequence) to
427
- * minimize DOM moves same algorithm as Vue 3 / ivi
493
+ * minimize DOM moves - same algorithm as Vue 3 / ivi
428
494
  * - Minimal attribute diffing with early bail-out
429
495
  */
430
496
 
431
497
  // ---------------------------------------------------------------------------
432
- // Reusable template element avoids per-call allocation
498
+ // Reusable template element - avoids per-call allocation
433
499
  // ---------------------------------------------------------------------------
434
500
  let _tpl = null;
435
501
 
@@ -439,15 +505,15 @@ function _getTemplate() {
439
505
  }
440
506
 
441
507
  // ---------------------------------------------------------------------------
442
- // morph(existingRoot, newHTML) patch existing DOM to match newHTML
508
+ // morph(existingRoot, newHTML) - patch existing DOM to match newHTML
443
509
  // ---------------------------------------------------------------------------
444
510
 
445
511
  /**
446
512
  * Morph an existing DOM element's children to match new HTML.
447
513
  * Only touches nodes that actually differ.
448
514
  *
449
- * @param {Element} rootEl The live DOM container to patch
450
- * @param {string} newHTML The desired HTML string
515
+ * @param {Element} rootEl - The live DOM container to patch
516
+ * @param {string} newHTML - The desired HTML string
451
517
  */
452
518
  function morph(rootEl, newHTML) {
453
519
  const start = typeof window !== 'undefined' && window.__zqMorphHook ? performance.now() : 0;
@@ -456,7 +522,7 @@ function morph(rootEl, newHTML) {
456
522
  const newRoot = tpl.content;
457
523
 
458
524
  // Move children into a wrapper for consistent handling.
459
- // We move (not clone) from the template cheaper than cloning.
525
+ // We move (not clone) from the template - cheaper than cloning.
460
526
  const tempDiv = document.createElement('div');
461
527
  while (newRoot.firstChild) tempDiv.appendChild(newRoot.firstChild);
462
528
 
@@ -466,16 +532,16 @@ function morph(rootEl, newHTML) {
466
532
  }
467
533
 
468
534
  /**
469
- * Morph a single element in place diffs attributes and children
535
+ * Morph a single element in place - diffs attributes and children
470
536
  * without replacing the node reference. Useful for replaceWith-style
471
537
  * updates where you want to keep the element identity when the tag
472
538
  * name matches.
473
539
  *
474
540
  * If the new HTML produces a different tag, falls back to native replace.
475
541
  *
476
- * @param {Element} oldEl The live DOM element to patch
477
- * @param {string} newHTML HTML string for the replacement element
478
- * @returns {Element} The resulting element (same ref if morphed, new if replaced)
542
+ * @param {Element} oldEl - The live DOM element to patch
543
+ * @param {string} newHTML - HTML string for the replacement element
544
+ * @returns {Element} - The resulting element (same ref if morphed, new if replaced)
479
545
  */
480
546
  function morphElement(oldEl, newHTML) {
481
547
  const start = typeof window !== 'undefined' && window.__zqMorphHook ? performance.now() : 0;
@@ -484,7 +550,7 @@ function morphElement(oldEl, newHTML) {
484
550
  const newEl = tpl.content.firstElementChild;
485
551
  if (!newEl) return oldEl;
486
552
 
487
- // Same tag morph in place (preserves identity, event listeners, refs)
553
+ // Same tag - morph in place (preserves identity, event listeners, refs)
488
554
  if (oldEl.nodeName === newEl.nodeName) {
489
555
  _morphAttributes(oldEl, newEl);
490
556
  _morphChildren(oldEl, newEl);
@@ -492,14 +558,14 @@ function morphElement(oldEl, newHTML) {
492
558
  return oldEl;
493
559
  }
494
560
 
495
- // Different tag must replace (can't morph <div> into <span>)
561
+ // Different tag - must replace (can't morph <div> into <span>)
496
562
  const clone = newEl.cloneNode(true);
497
563
  oldEl.parentNode.replaceChild(clone, oldEl);
498
564
  if (start) window.__zqMorphHook(clone, performance.now() - start);
499
565
  return clone;
500
566
  }
501
567
 
502
- // Aliases for the concat build core.js imports these as _morph / _morphElement,
568
+ // Aliases for the concat build - core.js imports these as _morph / _morphElement,
503
569
  // but the build strips `import … as` lines, so the aliases must exist at runtime.
504
570
  const _morph = morph;
505
571
  const _morphElement = morphElement;
@@ -507,11 +573,11 @@ const _morphElement = morphElement;
507
573
  /**
508
574
  * Reconcile children of `oldParent` to match `newParent`.
509
575
  *
510
- * @param {Element} oldParent live DOM parent
511
- * @param {Element} newParent desired state parent
576
+ * @param {Element} oldParent - live DOM parent
577
+ * @param {Element} newParent - desired state parent
512
578
  */
513
579
  function _morphChildren(oldParent, newParent) {
514
- // Snapshot live NodeLists into arrays childNodes is live and
580
+ // Snapshot live NodeLists into arrays - childNodes is live and
515
581
  // mutates during insertBefore/removeChild. Using a for loop to push
516
582
  // avoids spread operator overhead for large child lists.
517
583
  const oldCN = oldParent.childNodes;
@@ -523,7 +589,7 @@ function _morphChildren(oldParent, newParent) {
523
589
  for (let i = 0; i < oldLen; i++) oldChildren[i] = oldCN[i];
524
590
  for (let i = 0; i < newLen; i++) newChildren[i] = newCN[i];
525
591
 
526
- // Scan for keyed elements only build maps if keys exist
592
+ // Scan for keyed elements - only build maps if keys exist
527
593
  let hasKeys = false;
528
594
  let oldKeyMap, newKeyMap;
529
595
 
@@ -554,7 +620,7 @@ function _morphChildren(oldParent, newParent) {
554
620
  }
555
621
 
556
622
  /**
557
- * Unkeyed reconciliation positional matching.
623
+ * Unkeyed reconciliation - positional matching.
558
624
  */
559
625
  function _morphChildrenUnkeyed(oldParent, oldChildren, newChildren) {
560
626
  const oldLen = oldChildren.length;
@@ -582,7 +648,7 @@ function _morphChildrenUnkeyed(oldParent, oldChildren, newChildren) {
582
648
  }
583
649
 
584
650
  /**
585
- * Keyed reconciliation match by z-key, reorder with minimal moves
651
+ * Keyed reconciliation - match by z-key, reorder with minimal moves
586
652
  * using Longest Increasing Subsequence (LIS) to find the maximum set
587
653
  * of nodes that are already in the correct relative order, then only
588
654
  * move the remaining nodes.
@@ -616,7 +682,7 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
616
682
 
617
683
  // Step 3: Build index array for LIS of matched old indices.
618
684
  // This finds the largest set of keyed nodes already in order,
619
- // so we only need to move the rest O(n log n) instead of O(n²).
685
+ // so we only need to move the rest - O(n log n) instead of O(n²).
620
686
  const oldIndices = []; // Maps new-position → old-position (or -1)
621
687
  for (let i = 0; i < newLen; i++) {
622
688
  if (matched[i]) {
@@ -629,7 +695,7 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
629
695
 
630
696
  const lisSet = _lis(oldIndices);
631
697
 
632
- // Step 4: Insert / reorder / morph walk new children forward,
698
+ // Step 4: Insert / reorder / morph - walk new children forward,
633
699
  // using LIS to decide which nodes stay in place.
634
700
  let cursor = oldParent.firstChild;
635
701
  const unkeyedOld = [];
@@ -654,7 +720,7 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
654
720
  if (!lisSet.has(i)) {
655
721
  oldParent.insertBefore(oldNode, cursor);
656
722
  }
657
- // Capture next sibling BEFORE _morphNode if _morphNode calls
723
+ // Capture next sibling BEFORE _morphNode - if _morphNode calls
658
724
  // replaceChild, oldNode is removed and nextSibling becomes stale.
659
725
  const nextSib = oldNode.nextSibling;
660
726
  _morphNode(oldParent, oldNode, newNode);
@@ -694,10 +760,10 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
694
760
  * Returns a Set of positions (in the input) that form the LIS.
695
761
  * Entries with value -1 (unmatched) are excluded.
696
762
  *
697
- * O(n log n) same algorithm used by Vue 3 and ivi.
763
+ * O(n log n) - same algorithm used by Vue 3 and ivi.
698
764
  *
699
- * @param {number[]} arr array of old-tree indices (-1 = unmatched)
700
- * @returns {Set<number>} positions in arr belonging to the LIS
765
+ * @param {number[]} arr - array of old-tree indices (-1 = unmatched)
766
+ * @returns {Set<number>} - positions in arr belonging to the LIS
701
767
  */
702
768
  function _lis(arr) {
703
769
  const len = arr.length;
@@ -741,7 +807,7 @@ function _lis(arr) {
741
807
  * Morph a single node in place.
742
808
  */
743
809
  function _morphNode(parent, oldNode, newNode) {
744
- // Text / comment nodes just update content
810
+ // Text / comment nodes - just update content
745
811
  if (oldNode.nodeType === 3 || oldNode.nodeType === 8) {
746
812
  if (newNode.nodeType === oldNode.nodeType) {
747
813
  if (oldNode.nodeValue !== newNode.nodeValue) {
@@ -749,26 +815,26 @@ function _morphNode(parent, oldNode, newNode) {
749
815
  }
750
816
  return;
751
817
  }
752
- // Different node types replace
818
+ // Different node types - replace
753
819
  parent.replaceChild(newNode.cloneNode(true), oldNode);
754
820
  return;
755
821
  }
756
822
 
757
- // Different node types or tag names replace entirely
823
+ // Different node types or tag names - replace entirely
758
824
  if (oldNode.nodeType !== newNode.nodeType ||
759
825
  oldNode.nodeName !== newNode.nodeName) {
760
826
  parent.replaceChild(newNode.cloneNode(true), oldNode);
761
827
  return;
762
828
  }
763
829
 
764
- // Both are elements diff attributes then recurse children
830
+ // Both are elements - diff attributes then recurse children
765
831
  if (oldNode.nodeType === 1) {
766
- // z-skip: developer opt-out skip diffing this subtree entirely.
832
+ // z-skip: developer opt-out - skip diffing this subtree entirely.
767
833
  // Useful for third-party widgets, canvas, video, or large static content.
768
834
  if (oldNode.hasAttribute('z-skip')) return;
769
835
 
770
836
  // Fast bail-out: if the elements are identical, skip everything.
771
- // isEqualNode() is a native C++ comparison much faster than walking
837
+ // isEqualNode() is a native C++ comparison - much faster than walking
772
838
  // attributes + children in JS when trees haven't changed.
773
839
  if (oldNode.isEqualNode(newNode)) return;
774
840
 
@@ -794,7 +860,7 @@ function _morphNode(parent, oldNode, newNode) {
794
860
  return;
795
861
  }
796
862
 
797
- // Generic element recurse children
863
+ // Generic element - recurse children
798
864
  _morphChildren(oldNode, newNode);
799
865
  }
800
866
  }
@@ -836,7 +902,7 @@ function _morphAttributes(oldEl, newEl) {
836
902
  }
837
903
  }
838
904
 
839
- // Remove stale attributes snapshot names first because oldAttrs
905
+ // Remove stale attributes - snapshot names first because oldAttrs
840
906
  // is a live NamedNodeMap that mutates on removeAttribute().
841
907
  const oldNames = new Array(oldLen);
842
908
  for (let i = 0; i < oldLen; i++) oldNames[i] = oldAttrs[i].name;
@@ -852,7 +918,7 @@ function _morphAttributes(oldEl, newEl) {
852
918
  *
853
919
  * Only updates the value when the new HTML explicitly carries a `value`
854
920
  * attribute. Templates that use z-model manage values through reactive
855
- * state + _bindModels the morph engine should not interfere by wiping
921
+ * state + _bindModels - the morph engine should not interfere by wiping
856
922
  * a live input's content to '' just because the template has no `value`
857
923
  * attr. This prevents the wipe-then-restore cycle that resets cursor
858
924
  * position on every keystroke.
@@ -882,14 +948,14 @@ function _syncInputValue(oldEl, newEl) {
882
948
  *
883
949
  * This means the LIS-optimised keyed path activates automatically
884
950
  * whenever elements carry `id` or `data-id` / `data-key` attributes
885
- * no extra markup required.
951
+ * - no extra markup required.
886
952
  *
887
953
  * @returns {string|null}
888
954
  */
889
955
  function _getKey(node) {
890
956
  if (node.nodeType !== 1) return null;
891
957
 
892
- // Explicit z-key highest priority
958
+ // Explicit z-key - highest priority
893
959
  const zk = node.getAttribute('z-key');
894
960
  if (zk) return zk;
895
961
 
@@ -908,7 +974,7 @@ function _getKey(node) {
908
974
 
909
975
  // --- src/core.js -------------------------------------------------
910
976
  /**
911
- * zQuery Core Selector engine & chainable DOM collection
977
+ * zQuery Core - Selector engine & chainable DOM collection
912
978
  *
913
979
  * Extends the quick-ref pattern (Id, Class, Classes, Children)
914
980
  * into a full jQuery-like chainable wrapper with modern APIs.
@@ -916,7 +982,7 @@ function _getKey(node) {
916
982
 
917
983
 
918
984
  // ---------------------------------------------------------------------------
919
- // ZQueryCollection wraps an array of elements with chainable methods
985
+ // ZQueryCollection - wraps an array of elements with chainable methods
920
986
  // ---------------------------------------------------------------------------
921
987
  class ZQueryCollection {
922
988
  constructor(elements) {
@@ -1136,7 +1202,7 @@ class ZQueryCollection {
1136
1202
  // --- Classes -------------------------------------------------------------
1137
1203
 
1138
1204
  addClass(...names) {
1139
- // Fast path: single class, no spaces avoids flatMap + regex split allocation
1205
+ // Fast path: single class, no spaces - avoids flatMap + regex split allocation
1140
1206
  if (names.length === 1 && names[0].indexOf(' ') === -1) {
1141
1207
  const c = names[0];
1142
1208
  for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.add(c);
@@ -1298,7 +1364,7 @@ class ZQueryCollection {
1298
1364
  if (content === undefined) return this.first()?.innerHTML;
1299
1365
  // Auto-morph: if the element already has children, use the diff engine
1300
1366
  // to patch the DOM (preserves focus, scroll, state, keyed reorder via LIS).
1301
- // Empty elements get raw innerHTML for fast first-paint same strategy
1367
+ // Empty elements get raw innerHTML for fast first-paint - same strategy
1302
1368
  // the component system uses (first render = innerHTML, updates = morph).
1303
1369
  return this.each((_, el) => {
1304
1370
  if (el.childNodes.length > 0) {
@@ -1483,7 +1549,7 @@ class ZQueryCollection {
1483
1549
  if (typeof selectorOrHandler === 'function') {
1484
1550
  el.addEventListener(evt, selectorOrHandler);
1485
1551
  } else if (typeof selectorOrHandler === 'string') {
1486
- // Delegated event store wrapper so off() can remove it
1552
+ // Delegated event - store wrapper so off() can remove it
1487
1553
  const wrapper = (e) => {
1488
1554
  if (!e.target || typeof e.target.closest !== 'function') return;
1489
1555
  const target = e.target.closest(selectorOrHandler);
@@ -1545,7 +1611,7 @@ class ZQueryCollection {
1545
1611
  // --- Animation -----------------------------------------------------------
1546
1612
 
1547
1613
  animate(props, duration = 300, easing = 'ease') {
1548
- // Empty collection resolve immediately
1614
+ // Empty collection - resolve immediately
1549
1615
  if (this.length === 0) return Promise.resolve(this);
1550
1616
  return new Promise(resolve => {
1551
1617
  let resolved = false;
@@ -1671,7 +1737,7 @@ class ZQueryCollection {
1671
1737
 
1672
1738
 
1673
1739
  // ---------------------------------------------------------------------------
1674
- // Helper create document fragment from HTML string
1740
+ // Helper - create document fragment from HTML string
1675
1741
  // ---------------------------------------------------------------------------
1676
1742
  function createFragment(html) {
1677
1743
  const tpl = document.createElement('template');
@@ -1681,21 +1747,21 @@ function createFragment(html) {
1681
1747
 
1682
1748
 
1683
1749
  // ---------------------------------------------------------------------------
1684
- // $() main selector / creator (returns ZQueryCollection, like jQuery)
1750
+ // $() - main selector / creator (returns ZQueryCollection, like jQuery)
1685
1751
  // ---------------------------------------------------------------------------
1686
1752
  function query(selector, context) {
1687
1753
  // null / undefined
1688
1754
  if (!selector) return new ZQueryCollection([]);
1689
1755
 
1690
- // Already a collection return as-is
1756
+ // Already a collection - return as-is
1691
1757
  if (selector instanceof ZQueryCollection) return selector;
1692
1758
 
1693
- // DOM element or Window wrap in collection
1759
+ // DOM element or Window - wrap in collection
1694
1760
  if (selector instanceof Node || selector === window) {
1695
1761
  return new ZQueryCollection([selector]);
1696
1762
  }
1697
1763
 
1698
- // NodeList / HTMLCollection / Array wrap in collection
1764
+ // NodeList / HTMLCollection / Array - wrap in collection
1699
1765
  if (selector instanceof NodeList || selector instanceof HTMLCollection || Array.isArray(selector)) {
1700
1766
  return new ZQueryCollection(Array.from(selector));
1701
1767
  }
@@ -1719,7 +1785,7 @@ function query(selector, context) {
1719
1785
 
1720
1786
 
1721
1787
  // ---------------------------------------------------------------------------
1722
- // $.all() collection selector (returns ZQueryCollection for CSS selectors)
1788
+ // $.all() - collection selector (returns ZQueryCollection for CSS selectors)
1723
1789
  // ---------------------------------------------------------------------------
1724
1790
  function queryAll(selector, context) {
1725
1791
  // null / undefined
@@ -1774,7 +1840,7 @@ query.children = (parentId) => {
1774
1840
  query.qs = (sel, ctx = document) => ctx.querySelector(sel);
1775
1841
  query.qsa = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel));
1776
1842
 
1777
- // Create element shorthand returns ZQueryCollection for chaining
1843
+ // Create element shorthand - returns ZQueryCollection for chaining
1778
1844
  query.create = (tag, attrs = {}, ...children) => {
1779
1845
  const el = document.createElement(tag);
1780
1846
  for (const [k, v] of Object.entries(attrs)) {
@@ -1797,7 +1863,7 @@ query.ready = (fn) => {
1797
1863
  else document.addEventListener('DOMContentLoaded', fn);
1798
1864
  };
1799
1865
 
1800
- // Global event listeners supports direct, delegated, and target-bound forms
1866
+ // Global event listeners - supports direct, delegated, and target-bound forms
1801
1867
  // $.on('keydown', handler) → direct listener on document
1802
1868
  // $.on('click', '.btn', handler) → delegated via closest()
1803
1869
  // $.on('scroll', window, handler) → direct listener on target
@@ -1807,7 +1873,7 @@ query.on = (event, selectorOrHandler, handler) => {
1807
1873
  document.addEventListener(event, selectorOrHandler);
1808
1874
  return;
1809
1875
  }
1810
- // EventTarget (window, element, etc.) direct listener on target
1876
+ // EventTarget (window, element, etc.) - direct listener on target
1811
1877
  if (typeof selectorOrHandler === 'object' && typeof selectorOrHandler.addEventListener === 'function') {
1812
1878
  selectorOrHandler.addEventListener(event, handler);
1813
1879
  return;
@@ -1830,7 +1896,7 @@ query.fn = ZQueryCollection.prototype;
1830
1896
 
1831
1897
  // --- src/expression.js -------------------------------------------
1832
1898
  /**
1833
- * zQuery Expression Parser CSP-safe expression evaluator
1899
+ * zQuery Expression Parser - CSP-safe expression evaluator
1834
1900
  *
1835
1901
  * Replaces `new Function()` / `eval()` with a hand-written parser that
1836
1902
  * evaluates expressions safely without violating Content Security Policy.
@@ -2010,7 +2076,7 @@ function tokenize(expr) {
2010
2076
  i++; continue;
2011
2077
  }
2012
2078
 
2013
- // Unknown skip
2079
+ // Unknown - skip
2014
2080
  i++;
2015
2081
  }
2016
2082
 
@@ -2019,7 +2085,7 @@ function tokenize(expr) {
2019
2085
  }
2020
2086
 
2021
2087
  // ---------------------------------------------------------------------------
2022
- // Parser Pratt (precedence climbing)
2088
+ // Parser - Pratt (precedence climbing)
2023
2089
  // ---------------------------------------------------------------------------
2024
2090
  class Parser {
2025
2091
  constructor(tokens, scope) {
@@ -2251,7 +2317,7 @@ class Parser {
2251
2317
  let couldBeArrow = true;
2252
2318
 
2253
2319
  if (this.peek().t === T.PUNC && this.peek().v === ')') {
2254
- // () => ... no params
2320
+ // () => ... - no params
2255
2321
  } else {
2256
2322
  while (couldBeArrow) {
2257
2323
  const p = this.peek();
@@ -2277,7 +2343,7 @@ class Parser {
2277
2343
  }
2278
2344
  }
2279
2345
 
2280
- // Not an arrow restore and parse as grouping
2346
+ // Not an arrow - restore and parse as grouping
2281
2347
  this.pos = savedPos;
2282
2348
  this.next(); // consume (
2283
2349
  const expr = this.parseExpression(0);
@@ -2370,14 +2436,14 @@ class Parser {
2370
2436
  return { type: 'ident', name: tok.v };
2371
2437
  }
2372
2438
 
2373
- // Fallback return undefined for unparseable
2439
+ // Fallback - return undefined for unparseable
2374
2440
  this.next();
2375
2441
  return { type: 'literal', value: undefined };
2376
2442
  }
2377
2443
  }
2378
2444
 
2379
2445
  // ---------------------------------------------------------------------------
2380
- // Evaluator walks the AST, resolves against scope
2446
+ // Evaluator - walks the AST, resolves against scope
2381
2447
  // ---------------------------------------------------------------------------
2382
2448
 
2383
2449
  /** Safe property access whitelist for built-in prototypes */
@@ -2466,8 +2532,6 @@ function evaluate(node, scope) {
2466
2532
  if (name === 'console') return console;
2467
2533
  if (name === 'Map') return Map;
2468
2534
  if (name === 'Set') return Set;
2469
- if (name === 'RegExp') return RegExp;
2470
- if (name === 'Error') return Error;
2471
2535
  if (name === 'URL') return URL;
2472
2536
  if (name === 'URLSearchParams') return URLSearchParams;
2473
2537
  return undefined;
@@ -2510,7 +2574,7 @@ function evaluate(node, scope) {
2510
2574
  case 'optional_call': {
2511
2575
  const calleeNode = node.callee;
2512
2576
  const args = _evalArgs(node.args, scope);
2513
- // Method call: obj?.method() bind `this` to obj
2577
+ // Method call: obj?.method() - bind `this` to obj
2514
2578
  if (calleeNode.type === 'member' || calleeNode.type === 'optional_member') {
2515
2579
  const obj = evaluate(calleeNode.obj, scope);
2516
2580
  if (obj == null) return undefined;
@@ -2529,9 +2593,9 @@ function evaluate(node, scope) {
2529
2593
  case 'new': {
2530
2594
  const Ctor = evaluate(node.callee, scope);
2531
2595
  if (typeof Ctor !== 'function') return undefined;
2532
- // Only allow safe constructors
2596
+ // Only allow safe constructors (no RegExp - ReDoS risk, no Error - info leak)
2533
2597
  if (Ctor === Date || Ctor === Array || Ctor === Map || Ctor === Set ||
2534
- Ctor === RegExp || Ctor === Error || Ctor === URL || Ctor === URLSearchParams) {
2598
+ Ctor === URL || Ctor === URLSearchParams) {
2535
2599
  const args = _evalArgs(node.args, scope);
2536
2600
  return new Ctor(...args);
2537
2601
  }
@@ -2633,7 +2697,7 @@ function _resolveCall(node, scope) {
2633
2697
  const callee = node.callee;
2634
2698
  const args = _evalArgs(node.args, scope);
2635
2699
 
2636
- // Method call: obj.method() bind `this` to obj
2700
+ // Method call: obj.method() - bind `this` to obj
2637
2701
  if (callee.type === 'member' || callee.type === 'optional_member') {
2638
2702
  const obj = evaluate(callee.obj, scope);
2639
2703
  if (obj == null) return undefined;
@@ -2699,13 +2763,13 @@ function _evalBinary(node, scope) {
2699
2763
  /**
2700
2764
  * Safely evaluate a JS expression string against scope layers.
2701
2765
  *
2702
- * @param {string} expr expression string
2703
- * @param {object[]} scope array of scope objects, checked in order
2766
+ * @param {string} expr - expression string
2767
+ * @param {object[]} scope - array of scope objects, checked in order
2704
2768
  * Typical: [loopVars, state, { props, refs, $ }]
2705
- * @returns {*} evaluation result, or undefined on error
2769
+ * @returns {*} - evaluation result, or undefined on error
2706
2770
  */
2707
2771
 
2708
- // AST cache (LRU) avoids re-tokenizing and re-parsing the same expression.
2772
+ // AST cache (LRU) - avoids re-tokenizing and re-parsing the same expression.
2709
2773
  // Uses Map insertion-order: on hit, delete + re-set moves entry to the end.
2710
2774
  // Eviction removes the least-recently-used (first) entry when at capacity.
2711
2775
  const _astCache = new Map();
@@ -2756,7 +2820,7 @@ function safeEval(expr, scope) {
2756
2820
 
2757
2821
  // --- src/component.js --------------------------------------------
2758
2822
  /**
2759
- * zQuery Component Lightweight reactive component system
2823
+ * zQuery Component - Lightweight reactive component system
2760
2824
  *
2761
2825
  * Declarative components using template literals with directive support.
2762
2826
  * Proxy-based state triggers targeted re-renders via event delegation.
@@ -2772,7 +2836,7 @@ function safeEval(expr, scope) {
2772
2836
  * - Scoped styles (inline or via styleUrl)
2773
2837
  * - External templates via templateUrl (with {{expression}} interpolation)
2774
2838
  * - External styles via styleUrl (fetched & scoped automatically)
2775
- * - Relative path resolution templateUrl and styleUrl
2839
+ * - Relative path resolution - templateUrl and styleUrl
2776
2840
  * resolve relative to the component file automatically
2777
2841
  */
2778
2842
 
@@ -2780,6 +2844,7 @@ function safeEval(expr, scope) {
2780
2844
 
2781
2845
 
2782
2846
 
2847
+
2783
2848
  // ---------------------------------------------------------------------------
2784
2849
  // Component registry & external resource cache
2785
2850
  // ---------------------------------------------------------------------------
@@ -2804,7 +2869,7 @@ const _throttleTimers = new WeakMap();
2804
2869
 
2805
2870
  /**
2806
2871
  * Fetch and cache a text resource (HTML template or CSS file).
2807
- * @param {string} url URL to fetch
2872
+ * @param {string} url - URL to fetch
2808
2873
  * @returns {Promise<string>}
2809
2874
  */
2810
2875
  function _fetchResource(url) {
@@ -2848,23 +2913,23 @@ function _fetchResource(url) {
2848
2913
  * - If `base` is an absolute URL (http/https/file), resolve directly.
2849
2914
  * - If `base` is a relative path string, resolve it against the page root
2850
2915
  * (or <base href>) first, then resolve `url` against that.
2851
- * - If `base` is falsy, return `url` unchanged _fetchResource's own
2916
+ * - If `base` is falsy, return `url` unchanged - _fetchResource's own
2852
2917
  * fallback (page root / <base href>) handles it.
2853
2918
  *
2854
- * @param {string} url URL or relative path to resolve
2855
- * @param {string} [base] auto-detected caller URL or explicit base path
2919
+ * @param {string} url - URL or relative path to resolve
2920
+ * @param {string} [base] - auto-detected caller URL or explicit base path
2856
2921
  * @returns {string}
2857
2922
  */
2858
2923
  function _resolveUrl(url, base) {
2859
2924
  if (!base || !url || typeof url !== 'string') return url;
2860
- // Already absolute nothing to do
2925
+ // Already absolute - nothing to do
2861
2926
  if (url.startsWith('/') || url.includes('://') || url.startsWith('//')) return url;
2862
2927
  try {
2863
2928
  if (base.includes('://')) {
2864
2929
  // Absolute base (auto-detected module URL)
2865
2930
  return new URL(url, base).href;
2866
2931
  }
2867
- // Relative base string resolve against page root first
2932
+ // Relative base string - resolve against page root first
2868
2933
  const baseEl = document.querySelector('base');
2869
2934
  const root = baseEl ? baseEl.href : (window.location.origin + '/');
2870
2935
  const absBase = new URL(base.endsWith('/') ? base : base + '/', root).href;
@@ -2896,13 +2961,13 @@ function _detectCallerBase() {
2896
2961
  for (const raw of urls) {
2897
2962
  // Strip line:col suffixes e.g. ":3:5" or ":12:1"
2898
2963
  const url = raw.replace(/:\d+:\d+$/, '').replace(/:\d+$/, '');
2899
- // Skip the zQuery library itself by filename pattern and captured URL
2964
+ // Skip the zQuery library itself - by filename pattern and captured URL
2900
2965
  if (/zquery(\.min)?\.js$/i.test(url)) continue;
2901
2966
  if (_ownScriptUrl && url.replace(/[?#].*$/, '') === _ownScriptUrl) continue;
2902
2967
  // Return directory (strip filename, keep trailing slash)
2903
2968
  return url.replace(/\/[^/]*$/, '/');
2904
2969
  }
2905
- } catch { /* stack parsing unsupported fall back silently */ }
2970
+ } catch { /* stack parsing unsupported - fall back silently */ }
2906
2971
  return undefined;
2907
2972
  }
2908
2973
 
@@ -2981,7 +3046,7 @@ class Component {
2981
3046
  }
2982
3047
  });
2983
3048
 
2984
- // Computed properties lazy getters derived from state
3049
+ // Computed properties - lazy getters derived from state
2985
3050
  this.computed = {};
2986
3051
  if (definition.computed) {
2987
3052
  for (const [name, fn] of Object.entries(definition.computed)) {
@@ -3110,7 +3175,7 @@ class Component {
3110
3175
  this._loadExternals().then(() => {
3111
3176
  if (!this._destroyed) this._render();
3112
3177
  });
3113
- return; // Skip this render will re-render after load
3178
+ return; // Skip this render - will re-render after load
3114
3179
  }
3115
3180
 
3116
3181
  // Expose multi-template map on instance (if available)
@@ -3135,7 +3200,7 @@ class Component {
3135
3200
  this.state.__raw || this.state,
3136
3201
  { props: this.props, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
3137
3202
  ]);
3138
- return result != null ? result : '';
3203
+ return result != null ? escapeHtml(String(result)) : '';
3139
3204
  } catch { return ''; }
3140
3205
  });
3141
3206
  } else {
@@ -3181,13 +3246,13 @@ class Component {
3181
3246
  const trimmed = selector.trim();
3182
3247
  // Don't scope @-rules themselves
3183
3248
  if (trimmed.startsWith('@')) {
3184
- // @keyframes and @font-face contain non-selector content skip scoping inside them
3249
+ // @keyframes and @font-face contain non-selector content - skip scoping inside them
3185
3250
  if (/^@(keyframes|font-face)\b/.test(trimmed)) {
3186
3251
  noScopeDepth = braceDepth;
3187
3252
  }
3188
3253
  return match;
3189
3254
  }
3190
- // Inside @keyframes or @font-face don't scope inner rules
3255
+ // Inside @keyframes or @font-face - don't scope inner rules
3191
3256
  if (noScopeDepth > 0 && braceDepth > noScopeDepth) {
3192
3257
  return match;
3193
3258
  }
@@ -3240,7 +3305,7 @@ class Component {
3240
3305
  }
3241
3306
  }
3242
3307
 
3243
- // Update DOM via morphing (diffing) preserves unchanged nodes
3308
+ // Update DOM via morphing (diffing) - preserves unchanged nodes
3244
3309
  // First render uses innerHTML for speed; subsequent renders morph.
3245
3310
  const _renderStart = typeof window !== 'undefined' && (window.__zqMorphHook || window.__zqRenderHook) ? performance.now() : 0;
3246
3311
  if (!this._mounted) {
@@ -3259,8 +3324,8 @@ class Component {
3259
3324
  this._bindModels();
3260
3325
 
3261
3326
  // Restore focus if the morph replaced the focused element.
3262
- // Always restore selectionRange even when the element is still
3263
- // the activeElement because _bindModels or morph attribute syncing
3327
+ // Always restore selectionRange - even when the element is still
3328
+ // the activeElement - because _bindModels or morph attribute syncing
3264
3329
  // can alter the value and move the cursor.
3265
3330
  if (_focusInfo) {
3266
3331
  const el = this._el.querySelector(_focusInfo.selector);
@@ -3296,7 +3361,7 @@ class Component {
3296
3361
  // Optimization: on the FIRST render, we scan for event attributes, build
3297
3362
  // a delegated handler map, and attach one listener per event type to the
3298
3363
  // component root. On subsequent renders (re-bind), we only rebuild the
3299
- // internal binding map existing DOM listeners are reused since they
3364
+ // internal binding map - existing DOM listeners are reused since they
3300
3365
  // delegate to event.target.closest(selector) at fire time.
3301
3366
  _bindEvents() {
3302
3367
  // Always rebuild the binding map from current DOM
@@ -3337,11 +3402,11 @@ class Component {
3337
3402
  // Store binding map for the delegated handlers to reference
3338
3403
  this._eventBindings = eventMap;
3339
3404
 
3340
- // Only attach DOM listeners once reuse on subsequent renders.
3405
+ // Only attach DOM listeners once - reuse on subsequent renders.
3341
3406
  // The handlers close over `this` and read `this._eventBindings`
3342
3407
  // at fire time, so they always use the latest binding map.
3343
3408
  if (this._delegatedEvents) {
3344
- // Already attached just make sure new event types are covered
3409
+ // Already attached - just make sure new event types are covered
3345
3410
  for (const event of eventMap.keys()) {
3346
3411
  if (!this._delegatedEvents.has(event)) {
3347
3412
  this._attachDelegatedEvent(event, eventMap.get(event));
@@ -3367,7 +3432,7 @@ class Component {
3367
3432
  this._attachDelegatedEvent(event, bindings);
3368
3433
  }
3369
3434
 
3370
- // .outside attach a document-level listener for bindings that need
3435
+ // .outside - attach a document-level listener for bindings that need
3371
3436
  // to detect clicks/events outside their element.
3372
3437
  this._outsideListeners = this._outsideListeners || [];
3373
3438
  for (const [event, bindings] of eventMap) {
@@ -3395,7 +3460,7 @@ class Component {
3395
3460
  : false;
3396
3461
 
3397
3462
  const handler = (e) => {
3398
- // Read bindings from live map always up to date after re-renders
3463
+ // Read bindings from live map - always up to date after re-renders
3399
3464
  const currentBindings = this._eventBindings?.get(event) || [];
3400
3465
 
3401
3466
  // Collect matching bindings with their matched elements, then sort
@@ -3416,7 +3481,7 @@ class Component {
3416
3481
  for (const { selector, methodExpr, modifiers, el, matched } of hits) {
3417
3482
 
3418
3483
  // In delegated events, .stop should prevent ancestor bindings from
3419
- // firing stopPropagation alone only stops real DOM bubbling.
3484
+ // firing - stopPropagation alone only stops real DOM bubbling.
3420
3485
  if (stoppedAt) {
3421
3486
  let blocked = false;
3422
3487
  for (const stopped of stoppedAt) {
@@ -3425,15 +3490,15 @@ class Component {
3425
3490
  if (blocked) continue;
3426
3491
  }
3427
3492
 
3428
- // .self only fire if target is the element itself
3493
+ // .self - only fire if target is the element itself
3429
3494
  if (modifiers.includes('self') && e.target !== el) continue;
3430
3495
 
3431
- // .outside only fire if event target is OUTSIDE the element
3496
+ // .outside - only fire if event target is OUTSIDE the element
3432
3497
  if (modifiers.includes('outside')) {
3433
3498
  if (el.contains(e.target)) continue;
3434
3499
  }
3435
3500
 
3436
- // Key modifiers filter keyboard events by key
3501
+ // Key modifiers - filter keyboard events by key
3437
3502
  const _keyMap = { enter: 'Enter', escape: 'Escape', tab: 'Tab', space: ' ', delete: 'Delete|Backspace', up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight' };
3438
3503
  let keyFiltered = false;
3439
3504
  for (const mod of modifiers) {
@@ -3444,7 +3509,7 @@ class Component {
3444
3509
  }
3445
3510
  if (keyFiltered) continue;
3446
3511
 
3447
- // System key modifiers require modifier keys to be held
3512
+ // System key modifiers - require modifier keys to be held
3448
3513
  if (modifiers.includes('ctrl') && !e.ctrlKey) continue;
3449
3514
  if (modifiers.includes('shift') && !e.shiftKey) continue;
3450
3515
  if (modifiers.includes('alt') && !e.altKey) continue;
@@ -3484,7 +3549,7 @@ class Component {
3484
3549
  }
3485
3550
  };
3486
3551
 
3487
- // .debounce.{ms} delay invocation until idle
3552
+ // .debounce.{ms} - delay invocation until idle
3488
3553
  const debounceIdx = modifiers.indexOf('debounce');
3489
3554
  if (debounceIdx !== -1) {
3490
3555
  const ms = parseInt(modifiers[debounceIdx + 1], 10) || 250;
@@ -3495,7 +3560,7 @@ class Component {
3495
3560
  continue;
3496
3561
  }
3497
3562
 
3498
- // .throttle.{ms} fire at most once per interval
3563
+ // .throttle.{ms} - fire at most once per interval
3499
3564
  const throttleIdx = modifiers.indexOf('throttle');
3500
3565
  if (throttleIdx !== -1) {
3501
3566
  const ms = parseInt(modifiers[throttleIdx + 1], 10) || 250;
@@ -3507,7 +3572,7 @@ class Component {
3507
3572
  continue;
3508
3573
  }
3509
3574
 
3510
- // .once fire once then ignore
3575
+ // .once - fire once then ignore
3511
3576
  if (modifiers.includes('once')) {
3512
3577
  if (el.dataset.zqOnce === event) continue;
3513
3578
  el.dataset.zqOnce = event;
@@ -3535,12 +3600,12 @@ class Component {
3535
3600
  // textarea, select (single & multiple), contenteditable
3536
3601
  // Nested state keys: z-model="user.name" → this.state.user.name
3537
3602
  // Modifiers (boolean attributes on the same element):
3538
- // z-lazy listen on 'change' instead of 'input' (update on blur / commit)
3539
- // z-trim trim whitespace before writing to state
3540
- // z-number force Number() conversion regardless of input type
3541
- // z-debounce debounce state writes (default 250ms, or z-debounce="300")
3542
- // z-uppercase convert string to uppercase before writing to state
3543
- // z-lowercase convert string to lowercase before writing to state
3603
+ // z-lazy - listen on 'change' instead of 'input' (update on blur / commit)
3604
+ // z-trim - trim whitespace before writing to state
3605
+ // z-number - force Number() conversion regardless of input type
3606
+ // z-debounce - debounce state writes (default 250ms, or z-debounce="300")
3607
+ // z-uppercase - convert string to uppercase before writing to state
3608
+ // z-lowercase - convert string to lowercase before writing to state
3544
3609
  //
3545
3610
  // Writes to reactive state so the rest of the UI stays in sync.
3546
3611
  // Focus and cursor position are preserved in _render() via focusInfo.
@@ -3622,7 +3687,7 @@ class Component {
3622
3687
  }
3623
3688
 
3624
3689
  // ---------------------------------------------------------------------------
3625
- // Expression evaluator CSP-safe parser (no eval / new Function)
3690
+ // Expression evaluator - CSP-safe parser (no eval / new Function)
3626
3691
  // ---------------------------------------------------------------------------
3627
3692
  _evalExpr(expr) {
3628
3693
  return safeEval(expr, [
@@ -3632,7 +3697,7 @@ class Component {
3632
3697
  }
3633
3698
 
3634
3699
  // ---------------------------------------------------------------------------
3635
- // z-for Expand list-rendering directives (pre-innerHTML, string level)
3700
+ // z-for - Expand list-rendering directives (pre-innerHTML, string level)
3636
3701
  //
3637
3702
  // <li z-for="item in items">{{item.name}}</li>
3638
3703
  // <li z-for="(item, i) in items">{{i}}: {{item.name}}</li>
@@ -3698,7 +3763,7 @@ class Component {
3698
3763
  this.state.__raw || this.state,
3699
3764
  { props: this.props, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
3700
3765
  ]);
3701
- return result != null ? result : '';
3766
+ return result != null ? escapeHtml(String(result)) : '';
3702
3767
  } catch { return ''; }
3703
3768
  });
3704
3769
 
@@ -3721,7 +3786,7 @@ class Component {
3721
3786
  }
3722
3787
 
3723
3788
  // ---------------------------------------------------------------------------
3724
- // _expandContentDirectives Pre-morph z-html & z-text expansion
3789
+ // _expandContentDirectives - Pre-morph z-html & z-text expansion
3725
3790
  //
3726
3791
  // Evaluates z-html and z-text directives at the string level so the morph
3727
3792
  // engine receives HTML with the actual content inline. This lets the diff
@@ -3756,7 +3821,7 @@ class Component {
3756
3821
  }
3757
3822
 
3758
3823
  // ---------------------------------------------------------------------------
3759
- // _processDirectives Post-innerHTML DOM-level directive processing
3824
+ // _processDirectives - Post-innerHTML DOM-level directive processing
3760
3825
  // ---------------------------------------------------------------------------
3761
3826
  _processDirectives() {
3762
3827
  // z-pre: skip all directive processing on subtrees
@@ -3807,7 +3872,7 @@ class Component {
3807
3872
  });
3808
3873
 
3809
3874
  // -- z-bind:attr / :attr (dynamic attribute binding) -----------
3810
- // Use TreeWalker instead of querySelectorAll('*') avoids
3875
+ // Use TreeWalker instead of querySelectorAll('*') - avoids
3811
3876
  // creating a flat array of every single descendant element.
3812
3877
  // TreeWalker visits nodes lazily; FILTER_REJECT skips z-pre subtrees
3813
3878
  // at the walker level (faster than per-node closest('[z-pre]') checks).
@@ -3945,8 +4010,8 @@ const _reservedKeys = new Set([
3945
4010
 
3946
4011
  /**
3947
4012
  * Register a component
3948
- * @param {string} name tag name (must contain a hyphen, e.g. 'app-counter')
3949
- * @param {object} definition component definition
4013
+ * @param {string} name - tag name (must contain a hyphen, e.g. 'app-counter')
4014
+ * @param {object} definition - component definition
3950
4015
  */
3951
4016
  function component(name, definition) {
3952
4017
  if (!name || typeof name !== 'string') {
@@ -3971,9 +4036,9 @@ function component(name, definition) {
3971
4036
 
3972
4037
  /**
3973
4038
  * Mount a component into a target element
3974
- * @param {string|Element} target selector or element to mount into
3975
- * @param {string} componentName registered component name
3976
- * @param {object} props props to pass
4039
+ * @param {string|Element} target - selector or element to mount into
4040
+ * @param {string} componentName - registered component name
4041
+ * @param {object} props - props to pass
3977
4042
  * @returns {Component}
3978
4043
  */
3979
4044
  function mount(target, componentName, props = {}) {
@@ -3994,7 +4059,7 @@ function mount(target, componentName, props = {}) {
3994
4059
 
3995
4060
  /**
3996
4061
  * Scan a container for custom component tags and auto-mount them
3997
- * @param {Element} root root element to scan (default: document.body)
4062
+ * @param {Element} root - root element to scan (default: document.body)
3998
4063
  */
3999
4064
  function mountAll(root = document.body) {
4000
4065
  for (const [name, def] of _registry) {
@@ -4019,7 +4084,7 @@ function mountAll(root = document.body) {
4019
4084
  [...tag.attributes].forEach(attr => {
4020
4085
  if (attr.name.startsWith('@') || attr.name.startsWith('z-')) return;
4021
4086
 
4022
- // Dynamic prop: :propName="expression" evaluate in parent context
4087
+ // Dynamic prop: :propName="expression" - evaluate in parent context
4023
4088
  if (attr.name.startsWith(':')) {
4024
4089
  const propName = attr.name.slice(1);
4025
4090
  if (parentInstance) {
@@ -4028,7 +4093,7 @@ function mountAll(root = document.body) {
4028
4093
  { props: parentInstance.props, refs: parentInstance.refs, computed: parentInstance.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
4029
4094
  ]);
4030
4095
  } else {
4031
- // No parent try JSON parse
4096
+ // No parent - try JSON parse
4032
4097
  try { props[propName] = JSON.parse(attr.value); }
4033
4098
  catch { props[propName] = attr.value; }
4034
4099
  }
@@ -4077,8 +4142,8 @@ function getRegistry() {
4077
4142
  /**
4078
4143
  * Pre-load a component's external templates and styles so the next mount
4079
4144
  * renders synchronously (no blank flash while fetching).
4080
- * Safe to call multiple times skips if already loaded.
4081
- * @param {string} name registered component name
4145
+ * Safe to call multiple times - skips if already loaded.
4146
+ * @param {string} name - registered component name
4082
4147
  * @returns {Promise<void>}
4083
4148
  */
4084
4149
  async function prefetch(name) {
@@ -4106,27 +4171,27 @@ const _globalStyles = new Map(); // url → <link> element
4106
4171
  *
4107
4172
  * $.style('app.css') // critical by default
4108
4173
  * $.style(['app.css', 'theme.css']) // multiple files
4109
- * $.style('/assets/global.css') // absolute used as-is
4174
+ * $.style('/assets/global.css') // absolute - used as-is
4110
4175
  * $.style('app.css', { critical: false }) // opt out of FOUC prevention
4111
4176
  *
4112
4177
  * Options:
4113
- * critical (boolean, default true) When true, zQuery injects a tiny
4178
+ * critical - (boolean, default true) When true, zQuery injects a tiny
4114
4179
  * inline style that hides the page (`visibility: hidden`) and
4115
4180
  * removes it once the stylesheet has loaded. This prevents
4116
- * FOUC (Flash of Unstyled Content) entirely no special
4181
+ * FOUC (Flash of Unstyled Content) entirely - no special
4117
4182
  * markup needed in the HTML file. Set to false to load
4118
4183
  * the stylesheet without blocking paint.
4119
- * bg (string, default '#0d1117') Background color applied while
4184
+ * bg - (string, default '#0d1117') Background color applied while
4120
4185
  * the page is hidden during critical load. Prevents a white
4121
4186
  * flash on dark-themed apps. Only used when critical is true.
4122
4187
  *
4123
4188
  * Duplicate URLs are ignored (idempotent).
4124
4189
  *
4125
- * @param {string|string[]} urls stylesheet URL(s) to load
4126
- * @param {object} [opts] options
4127
- * @param {boolean} [opts.critical=true] hide page until loaded (prevents FOUC)
4128
- * @param {string} [opts.bg] background color while hidden (default '#0d1117')
4129
- * @returns {{ remove: Function, ready: Promise }} .remove() to unload, .ready resolves when loaded
4190
+ * @param {string|string[]} urls - stylesheet URL(s) to load
4191
+ * @param {object} [opts] - options
4192
+ * @param {boolean} [opts.critical=true] - hide page until loaded (prevents FOUC)
4193
+ * @param {string} [opts.bg] - background color while hidden (default '#0d1117')
4194
+ * @returns {{ remove: Function, ready: Promise }} - .remove() to unload, .ready resolves when loaded
4130
4195
  */
4131
4196
  function style(urls, opts = {}) {
4132
4197
  const callerBase = _detectCallerBase();
@@ -4135,7 +4200,7 @@ function style(urls, opts = {}) {
4135
4200
  const loadPromises = [];
4136
4201
 
4137
4202
  // Critical mode (default: true): inject a tiny inline <style> that hides the
4138
- // page and sets a background color. Fully self-contained no markup needed
4203
+ // page and sets a background color. Fully self-contained - no markup needed
4139
4204
  // in the HTML file. The style is removed once the sheet loads.
4140
4205
  let _criticalStyle = null;
4141
4206
  if (opts.critical !== false) {
@@ -4195,16 +4260,15 @@ function style(urls, opts = {}) {
4195
4260
 
4196
4261
  // --- src/router.js -----------------------------------------------
4197
4262
  /**
4198
- * zQuery Router Client-side SPA router
4263
+ * zQuery Router - Client-side SPA router
4199
4264
  *
4200
4265
  * Supports hash mode (#/path) and history mode (/path).
4201
4266
  * Route params, query strings, navigation guards, and lazy loading.
4202
4267
  * Sub-route history substates for in-page UI changes (modals, tabs, etc.).
4203
4268
  *
4204
4269
  * Usage:
4270
+ * // HTML: <z-outlet></z-outlet>
4205
4271
  * $.router({
4206
- * el: '#app',
4207
- * mode: 'hash',
4208
4272
  * routes: [
4209
4273
  * { path: '/', component: 'home-page' },
4210
4274
  * { path: '/user/:id', component: 'user-profile' },
@@ -4239,7 +4303,7 @@ function _shallowEqual(a, b) {
4239
4303
  class Router {
4240
4304
  constructor(config = {}) {
4241
4305
  this._el = null;
4242
- // file:// protocol can't use pushState always force hash mode
4306
+ // file:// protocol can't use pushState - always force hash mode
4243
4307
  const isFile = typeof location !== 'undefined' && location.protocol === 'file:';
4244
4308
  this._mode = isFile ? 'hash' : (config.mode || 'history');
4245
4309
 
@@ -4274,8 +4338,30 @@ class Router {
4274
4338
  this._inSubstate = false; // true while substate entries are in the history stack
4275
4339
 
4276
4340
  // Set outlet element
4341
+ // Priority: explicit config.el → <z-outlet> tag in the DOM
4277
4342
  if (config.el) {
4278
4343
  this._el = typeof config.el === 'string' ? document.querySelector(config.el) : config.el;
4344
+ } else if (typeof document !== 'undefined') {
4345
+ const outlet = document.querySelector('z-outlet');
4346
+ if (outlet) {
4347
+ this._el = outlet;
4348
+ // Read inline attribute overrides from <z-outlet> (config takes priority)
4349
+ if (!config.fallback && outlet.getAttribute('fallback')) {
4350
+ this._fallback = outlet.getAttribute('fallback');
4351
+ }
4352
+ if (!config.mode && outlet.getAttribute('mode')) {
4353
+ const attrMode = outlet.getAttribute('mode');
4354
+ if (attrMode === 'hash' || attrMode === 'history') {
4355
+ this._mode = isFile ? 'hash' : attrMode;
4356
+ }
4357
+ }
4358
+ if (config.base == null && outlet.getAttribute('base')) {
4359
+ let ob = outlet.getAttribute('base');
4360
+ ob = String(ob).replace(/\/+$/, '');
4361
+ if (ob && !ob.startsWith('/')) ob = '/' + ob;
4362
+ this._base = ob;
4363
+ }
4364
+ }
4279
4365
  }
4280
4366
 
4281
4367
  // Register routes
@@ -4283,21 +4369,43 @@ class Router {
4283
4369
  config.routes.forEach(r => this.add(r));
4284
4370
  }
4285
4371
 
4286
- // Listen for navigation store handler references for cleanup in destroy()
4372
+ // Listen for navigation - store handler references for cleanup in destroy()
4287
4373
  if (this._mode === 'hash') {
4288
4374
  this._onNavEvent = () => this._resolve();
4289
4375
  window.addEventListener('hashchange', this._onNavEvent);
4376
+ // Hash mode also needs popstate for substates (pushSubstate uses pushState)
4377
+ this._onPopState = (e) => {
4378
+ const st = e.state;
4379
+ if (st && st[_ZQ_STATE_KEY] === 'substate') {
4380
+ const handled = this._fireSubstate(st.key, st.data, 'pop');
4381
+ if (handled) return;
4382
+ this._resolve().then(() => {
4383
+ this._fireSubstate(st.key, st.data, 'pop');
4384
+ });
4385
+ return;
4386
+ } else if (this._inSubstate) {
4387
+ this._inSubstate = false;
4388
+ this._fireSubstate(null, null, 'reset');
4389
+ }
4390
+ };
4391
+ window.addEventListener('popstate', this._onPopState);
4290
4392
  } else {
4291
4393
  this._onNavEvent = (e) => {
4292
- // Check for substate pop first if a listener handles it, don't route
4394
+ // Check for substate pop first - if a listener handles it, don't route
4293
4395
  const st = e.state;
4294
4396
  if (st && st[_ZQ_STATE_KEY] === 'substate') {
4295
4397
  const handled = this._fireSubstate(st.key, st.data, 'pop');
4296
4398
  if (handled) return;
4297
- // Unhandled substate — fall through to route resolve
4298
- // _inSubstate stays true so the next non-substate pop triggers reset
4399
+ // Unhandled substate — the owning component was likely destroyed
4400
+ // (e.g. user navigated away then pressed back). Resolve the route
4401
+ // first (which may mount a fresh component that registers a listener),
4402
+ // then retry the substate so the new listener can restore the UI.
4403
+ this._resolve().then(() => {
4404
+ this._fireSubstate(st.key, st.data, 'pop');
4405
+ });
4406
+ return;
4299
4407
  } else if (this._inSubstate) {
4300
- // Popped past all substates notify listeners to reset to defaults
4408
+ // Popped past all substates - notify listeners to reset to defaults
4301
4409
  this._inSubstate = false;
4302
4410
  this._fireSubstate(null, null, 'reset');
4303
4411
  }
@@ -4315,13 +4423,17 @@ class Router {
4315
4423
  if (link.getAttribute('target') === '_blank') return;
4316
4424
  e.preventDefault();
4317
4425
  let href = link.getAttribute('z-link');
4426
+ // Reject absolute URLs and dangerous protocols — z-link is for internal routes only
4427
+ if (href && /^[a-z][a-z0-9+.-]*:/i.test(href)) return;
4318
4428
  // Support z-link-params for dynamic :param interpolation
4319
4429
  const paramsAttr = link.getAttribute('z-link-params');
4320
4430
  if (paramsAttr) {
4321
4431
  try {
4322
4432
  const params = JSON.parse(paramsAttr);
4323
4433
  href = this._interpolateParams(href, params);
4324
- } catch { /* ignore malformed JSON */ }
4434
+ } catch (err) {
4435
+ reportError(ErrorCode.ROUTER_RESOLVE, 'Malformed JSON in z-link-params', { href, paramsAttr }, err);
4436
+ }
4325
4437
  }
4326
4438
  this.navigate(href);
4327
4439
  // z-to-top modifier: scroll to top after navigation
@@ -4375,8 +4487,8 @@ class Router {
4375
4487
 
4376
4488
  /**
4377
4489
  * Interpolate :param placeholders in a path with the given values.
4378
- * @param {string} path e.g. '/user/:id/posts/:pid'
4379
- * @param {Object} params e.g. { id: 42, pid: 7 }
4490
+ * @param {string} path - e.g. '/user/:id/posts/:pid'
4491
+ * @param {Object} params - e.g. { id: 42, pid: 7 }
4380
4492
  * @returns {string}
4381
4493
  */
4382
4494
  _interpolateParams(path, params) {
@@ -4420,7 +4532,7 @@ class Router {
4420
4532
  const currentURL = (window.location.pathname || '/') + (window.location.hash || '');
4421
4533
 
4422
4534
  if (targetURL === currentURL && !options.force) {
4423
- // Same full URL (path + hash) don't push duplicate entry.
4535
+ // Same full URL (path + hash) - don't push duplicate entry.
4424
4536
  // If only the hash changed to a fragment target, scroll to it.
4425
4537
  if (fragment) {
4426
4538
  const el = document.getElementById(fragment);
@@ -4429,7 +4541,7 @@ class Router {
4429
4541
  return this;
4430
4542
  }
4431
4543
 
4432
- // Same route path but different hash fragment use replaceState
4544
+ // Same route path but different hash fragment - use replaceState
4433
4545
  // so back goes to the previous *route*, not the previous scroll position.
4434
4546
  const targetPathOnly = this._base + normalized;
4435
4547
  const currentPathOnly = window.location.pathname || '/';
@@ -4444,7 +4556,7 @@ class Router {
4444
4556
  const el = document.getElementById(fragment);
4445
4557
  if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
4446
4558
  }
4447
- // Don't re-resolve same route, just a hash change
4559
+ // Don't re-resolve - same route, just a hash change
4448
4560
  return this;
4449
4561
  }
4450
4562
 
@@ -4480,8 +4592,8 @@ class Router {
4480
4592
 
4481
4593
  /**
4482
4594
  * Normalize an app-relative path and guard against double base-prefixing.
4483
- * @param {string} path e.g. '/docs', 'docs', or '/app/docs' when base is '/app'
4484
- * @returns {string} always starts with '/'
4595
+ * @param {string} path - e.g. '/docs', 'docs', or '/app/docs' when base is '/app'
4596
+ * @returns {string} - always starts with '/'
4485
4597
  */
4486
4598
  _normalizePath(path) {
4487
4599
  let p = path && path.startsWith('/') ? path : (path ? `/${path}` : '/');
@@ -4531,12 +4643,12 @@ class Router {
4531
4643
 
4532
4644
  /**
4533
4645
  * Push a lightweight history entry for in-component UI state.
4534
- * The URL path does NOT change only a history entry is added so the
4646
+ * The URL path does NOT change - only a history entry is added so the
4535
4647
  * back button can undo the UI change (close modal, revert tab, etc.)
4536
4648
  * before navigating away.
4537
4649
  *
4538
- * @param {string} key identifier (e.g. 'modal', 'tab', 'panel')
4539
- * @param {*} data arbitrary state (serializable)
4650
+ * @param {string} key - identifier (e.g. 'modal', 'tab', 'panel')
4651
+ * @param {*} data - arbitrary state (serializable)
4540
4652
  * @returns {Router}
4541
4653
  *
4542
4654
  * @example
@@ -4547,7 +4659,7 @@ class Router {
4547
4659
  pushSubstate(key, data) {
4548
4660
  this._inSubstate = true;
4549
4661
  if (this._mode === 'hash') {
4550
- // Hash mode: stash the substate in a global hashchange will check.
4662
+ // Hash mode: stash the substate in a global - hashchange will check.
4551
4663
  // We still push a history entry via a sentinel hash suffix.
4552
4664
  const current = window.location.hash || '#/';
4553
4665
  window.history.pushState(
@@ -4663,12 +4775,12 @@ class Router {
4663
4775
  async __resolve() {
4664
4776
  // Check if we're landing on a substate entry (e.g. page refresh on a
4665
4777
  // substate bookmark, or hash-mode popstate). Fire listeners and bail
4666
- // if handled the URL hasn't changed so there's no route to resolve.
4778
+ // if handled - the URL hasn't changed so there's no route to resolve.
4667
4779
  const histState = window.history.state;
4668
4780
  if (histState && histState[_ZQ_STATE_KEY] === 'substate') {
4669
4781
  const handled = this._fireSubstate(histState.key, histState.data, 'resolve');
4670
4782
  if (handled) return;
4671
- // No listener handled it fall through to normal routing
4783
+ // No listener handled it - fall through to normal routing
4672
4784
  }
4673
4785
 
4674
4786
  const fullPath = this.path;
@@ -4705,7 +4817,7 @@ class Router {
4705
4817
  const sameParams = _shallowEqual(params, from.params);
4706
4818
  const sameQuery = _shallowEqual(query, from.query);
4707
4819
  if (sameParams && sameQuery) {
4708
- // Identical navigation nothing to do
4820
+ // Identical navigation - nothing to do
4709
4821
  return;
4710
4822
  }
4711
4823
  }
@@ -4793,6 +4905,9 @@ class Router {
4793
4905
  }
4794
4906
  }
4795
4907
 
4908
+ // Update z-active-route elements
4909
+ this._updateActiveRoutes(path);
4910
+
4796
4911
  // Run after guards
4797
4912
  for (const guard of this._guards.after) {
4798
4913
  await guard(to, from);
@@ -4802,6 +4917,32 @@ class Router {
4802
4917
  this._listeners.forEach(fn => fn(to, from));
4803
4918
  }
4804
4919
 
4920
+ // --- Active route class management ----------------------------------------
4921
+
4922
+ /**
4923
+ * Update all elements with z-active-route to toggle their active class
4924
+ * based on the current path.
4925
+ *
4926
+ * Usage:
4927
+ * <a z-link="/docs" z-active-route="/docs">Docs</a>
4928
+ * <a z-link="/about" z-active-route="/about" z-active-class="selected">About</a>
4929
+ * <a z-link="/" z-active-route="/" z-active-exact>Home</a>
4930
+ */
4931
+ _updateActiveRoutes(currentPath) {
4932
+ if (typeof document === 'undefined') return;
4933
+ const els = document.querySelectorAll('[z-active-route]');
4934
+ for (let i = 0; i < els.length; i++) {
4935
+ const el = els[i];
4936
+ const route = el.getAttribute('z-active-route');
4937
+ const cls = el.getAttribute('z-active-class') || 'active';
4938
+ const exact = el.hasAttribute('z-active-exact');
4939
+ const isActive = exact
4940
+ ? currentPath === route
4941
+ : (route === '/' ? currentPath === '/' : currentPath.startsWith(route));
4942
+ el.classList.toggle(cls, isActive);
4943
+ }
4944
+ }
4945
+
4805
4946
  // --- Destroy -------------------------------------------------------------
4806
4947
 
4807
4948
  destroy() {
@@ -4810,6 +4951,10 @@ class Router {
4810
4951
  window.removeEventListener(this._mode === 'hash' ? 'hashchange' : 'popstate', this._onNavEvent);
4811
4952
  this._onNavEvent = null;
4812
4953
  }
4954
+ if (this._onPopState) {
4955
+ window.removeEventListener('popstate', this._onPopState);
4956
+ this._onPopState = null;
4957
+ }
4813
4958
  if (this._onLinkClick) {
4814
4959
  document.removeEventListener('click', this._onLinkClick);
4815
4960
  this._onLinkClick = null;
@@ -4840,7 +4985,7 @@ function getRouter() {
4840
4985
 
4841
4986
  // --- src/store.js ------------------------------------------------
4842
4987
  /**
4843
- * zQuery Store Global reactive state management
4988
+ * zQuery Store - Global reactive state management
4844
4989
  *
4845
4990
  * A lightweight Redux/Vuex-inspired store with:
4846
4991
  * - Reactive state via Proxy
@@ -4878,22 +5023,22 @@ class Store {
4878
5023
  this._history = []; // action log
4879
5024
  this._maxHistory = config.maxHistory || 1000;
4880
5025
  this._debug = config.debug || false;
5026
+ this._batching = false;
5027
+ this._batchQueue = []; // pending notifications during batch
5028
+ this._undoStack = [];
5029
+ this._redoStack = [];
5030
+ this._maxUndo = config.maxUndo || 50;
4881
5031
 
4882
- // Create reactive state
5032
+ // Store initial state for reset
4883
5033
  const initial = typeof config.state === 'function' ? config.state() : { ...(config.state || {}) };
5034
+ this._initialState = JSON.parse(JSON.stringify(initial));
4884
5035
 
4885
5036
  this.state = reactive(initial, (key, value, old) => {
4886
- // Notify key-specific subscribers
4887
- const subs = this._subscribers.get(key);
4888
- if (subs) subs.forEach(fn => {
4889
- try { fn(value, old, key); }
4890
- catch (err) { reportError(ErrorCode.STORE_SUBSCRIBE, `Subscriber for "${key}" threw`, { key }, err); }
4891
- });
4892
- // Notify wildcard subscribers
4893
- this._wildcards.forEach(fn => {
4894
- try { fn(key, value, old); }
4895
- catch (err) { reportError(ErrorCode.STORE_SUBSCRIBE, 'Wildcard subscriber threw', { key }, err); }
4896
- });
5037
+ if (this._batching) {
5038
+ this._batchQueue.push({ key, value, old });
5039
+ return;
5040
+ }
5041
+ this._notifySubscribers(key, value, old);
4897
5042
  });
4898
5043
 
4899
5044
  // Build getters as computed properties
@@ -4906,10 +5051,90 @@ class Store {
4906
5051
  }
4907
5052
  }
4908
5053
 
5054
+ /** @private Notify key-specific and wildcard subscribers */
5055
+ _notifySubscribers(key, value, old) {
5056
+ const subs = this._subscribers.get(key);
5057
+ if (subs) subs.forEach(fn => {
5058
+ try { fn(key, value, old); }
5059
+ catch (err) { reportError(ErrorCode.STORE_SUBSCRIBE, `Subscriber for "${key}" threw`, { key }, err); }
5060
+ });
5061
+ this._wildcards.forEach(fn => {
5062
+ try { fn(key, value, old); }
5063
+ catch (err) { reportError(ErrorCode.STORE_SUBSCRIBE, 'Wildcard subscriber threw', { key }, err); }
5064
+ });
5065
+ }
5066
+
5067
+ /**
5068
+ * Batch multiple state changes - subscribers fire once at the end
5069
+ * with only the latest value per key.
5070
+ */
5071
+ batch(fn) {
5072
+ this._batching = true;
5073
+ this._batchQueue = [];
5074
+ try {
5075
+ fn(this.state);
5076
+ } finally {
5077
+ this._batching = false;
5078
+ // Deduplicate: keep only the last change per key
5079
+ const last = new Map();
5080
+ for (const entry of this._batchQueue) {
5081
+ last.set(entry.key, entry);
5082
+ }
5083
+ this._batchQueue = [];
5084
+ for (const { key, value, old } of last.values()) {
5085
+ this._notifySubscribers(key, value, old);
5086
+ }
5087
+ }
5088
+ }
5089
+
5090
+ /**
5091
+ * Save a snapshot for undo. Call before making changes you want to be undoable.
5092
+ */
5093
+ checkpoint() {
5094
+ const snap = JSON.parse(JSON.stringify(this.state.__raw || this.state));
5095
+ this._undoStack.push(snap);
5096
+ if (this._undoStack.length > this._maxUndo) {
5097
+ this._undoStack.splice(0, this._undoStack.length - this._maxUndo);
5098
+ }
5099
+ this._redoStack = [];
5100
+ }
5101
+
5102
+ /**
5103
+ * Undo to the last checkpoint
5104
+ * @returns {boolean} true if undo was performed
5105
+ */
5106
+ undo() {
5107
+ if (this._undoStack.length === 0) return false;
5108
+ const current = JSON.parse(JSON.stringify(this.state.__raw || this.state));
5109
+ this._redoStack.push(current);
5110
+ const prev = this._undoStack.pop();
5111
+ this.replaceState(prev);
5112
+ return true;
5113
+ }
5114
+
5115
+ /**
5116
+ * Redo the last undone state change
5117
+ * @returns {boolean} true if redo was performed
5118
+ */
5119
+ redo() {
5120
+ if (this._redoStack.length === 0) return false;
5121
+ const current = JSON.parse(JSON.stringify(this.state.__raw || this.state));
5122
+ this._undoStack.push(current);
5123
+ const next = this._redoStack.pop();
5124
+ this.replaceState(next);
5125
+ return true;
5126
+ }
5127
+
5128
+ /** Check if undo is available */
5129
+ get canUndo() { return this._undoStack.length > 0; }
5130
+
5131
+ /** Check if redo is available */
5132
+ get canRedo() { return this._redoStack.length > 0; }
5133
+
4909
5134
  /**
4910
5135
  * Dispatch a named action
4911
- * @param {string} name action name
4912
- * @param {...any} args payload
5136
+ * @param {string} name - action name
5137
+ * @param {...any} args - payload
4913
5138
  */
4914
5139
  dispatch(name, ...args) {
4915
5140
  const action = this._actions[name];
@@ -4948,13 +5173,13 @@ class Store {
4948
5173
 
4949
5174
  /**
4950
5175
  * Subscribe to changes on a specific state key
4951
- * @param {string|Function} keyOrFn state key, or function for all changes
4952
- * @param {Function} [fn] callback (value, oldValue, key)
4953
- * @returns {Function} unsubscribe
5176
+ * @param {string|Function} keyOrFn - state key, or function for all changes
5177
+ * @param {Function} [fn] - callback (key, value, oldValue)
5178
+ * @returns {Function} - unsubscribe
4954
5179
  */
4955
5180
  subscribe(keyOrFn, fn) {
4956
5181
  if (typeof keyOrFn === 'function') {
4957
- // Wildcard listen to all changes
5182
+ // Wildcard - listen to all changes
4958
5183
  this._wildcards.add(keyOrFn);
4959
5184
  return () => this._wildcards.delete(keyOrFn);
4960
5185
  }
@@ -5000,11 +5225,13 @@ class Store {
5000
5225
  }
5001
5226
 
5002
5227
  /**
5003
- * Reset state to initial values
5228
+ * Reset state to initial values. If no argument, resets to the original state.
5004
5229
  */
5005
5230
  reset(initialState) {
5006
- this.replaceState(initialState);
5231
+ this.replaceState(initialState || JSON.parse(JSON.stringify(this._initialState)));
5007
5232
  this._history = [];
5233
+ this._undoStack = [];
5234
+ this._redoStack = [];
5008
5235
  }
5009
5236
  }
5010
5237
 
@@ -5031,7 +5258,7 @@ function getStore(name = 'default') {
5031
5258
 
5032
5259
  // --- src/http.js -------------------------------------------------
5033
5260
  /**
5034
- * zQuery HTTP Lightweight fetch wrapper
5261
+ * zQuery HTTP - Lightweight fetch wrapper
5035
5262
  *
5036
5263
  * Clean API for GET/POST/PUT/PATCH/DELETE with:
5037
5264
  * - Auto JSON serialization/deserialization
@@ -5221,7 +5448,7 @@ const http = {
5221
5448
 
5222
5449
  /**
5223
5450
  * Add request interceptor
5224
- * @param {Function} fn (fetchOpts, url) → void | false | { url, options }
5451
+ * @param {Function} fn - (fetchOpts, url) → void | false | { url, options }
5225
5452
  * @returns {Function} unsubscribe function
5226
5453
  */
5227
5454
  onRequest(fn) {
@@ -5234,7 +5461,7 @@ const http = {
5234
5461
 
5235
5462
  /**
5236
5463
  * Add response interceptor
5237
- * @param {Function} fn (result) → void
5464
+ * @param {Function} fn - (result) → void
5238
5465
  * @returns {Function} unsubscribe function
5239
5466
  */
5240
5467
  onResponse(fn) {
@@ -5246,7 +5473,7 @@ const http = {
5246
5473
  },
5247
5474
 
5248
5475
  /**
5249
- * Clear interceptors all, or just 'request' / 'response'
5476
+ * Clear interceptors - all, or just 'request' / 'response'
5250
5477
  */
5251
5478
  clearInterceptors(type) {
5252
5479
  if (!type || type === 'request') _interceptors.request.length = 0;
@@ -5275,7 +5502,7 @@ const http = {
5275
5502
 
5276
5503
  // --- src/utils.js ------------------------------------------------
5277
5504
  /**
5278
- * zQuery Utils Common utility functions
5505
+ * zQuery Utils - Common utility functions
5279
5506
  *
5280
5507
  * Quality-of-life helpers that every frontend project needs.
5281
5508
  * Attached to $ namespace for convenience.
@@ -5286,7 +5513,7 @@ const http = {
5286
5513
  // ---------------------------------------------------------------------------
5287
5514
 
5288
5515
  /**
5289
- * Debounce delays execution until after `ms` of inactivity
5516
+ * Debounce - delays execution until after `ms` of inactivity
5290
5517
  */
5291
5518
  function debounce(fn, ms = 250) {
5292
5519
  let timer;
@@ -5299,7 +5526,7 @@ function debounce(fn, ms = 250) {
5299
5526
  }
5300
5527
 
5301
5528
  /**
5302
- * Throttle limits execution to once per `ms`
5529
+ * Throttle - limits execution to once per `ms`
5303
5530
  */
5304
5531
  function throttle(fn, ms = 250) {
5305
5532
  let last = 0;
@@ -5318,14 +5545,14 @@ function throttle(fn, ms = 250) {
5318
5545
  }
5319
5546
 
5320
5547
  /**
5321
- * Pipe compose functions left-to-right
5548
+ * Pipe - compose functions left-to-right
5322
5549
  */
5323
5550
  function pipe(...fns) {
5324
5551
  return (input) => fns.reduce((val, fn) => fn(val), input);
5325
5552
  }
5326
5553
 
5327
5554
  /**
5328
- * Once function that only runs once
5555
+ * Once - function that only runs once
5329
5556
  */
5330
5557
  function once(fn) {
5331
5558
  let called = false, result;
@@ -5336,7 +5563,7 @@ function once(fn) {
5336
5563
  }
5337
5564
 
5338
5565
  /**
5339
- * Sleep promise-based delay
5566
+ * Sleep - promise-based delay
5340
5567
  */
5341
5568
  function sleep(ms) {
5342
5569
  return new Promise(resolve => setTimeout(resolve, ms));
@@ -5387,8 +5614,12 @@ function trust(htmlStr) {
5387
5614
  * Generate UUID v4
5388
5615
  */
5389
5616
  function uuid() {
5390
- return crypto?.randomUUID?.() || 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
5391
- const r = Math.random() * 16 | 0;
5617
+ if (crypto?.randomUUID) return crypto.randomUUID();
5618
+ // Fallback using crypto.getRandomValues (wider support than randomUUID)
5619
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
5620
+ const buf = new Uint8Array(1);
5621
+ crypto.getRandomValues(buf);
5622
+ const r = buf[0] & 15;
5392
5623
  return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
5393
5624
  });
5394
5625
  }
@@ -5416,13 +5647,50 @@ function kebabCase(str) {
5416
5647
  // ---------------------------------------------------------------------------
5417
5648
 
5418
5649
  /**
5419
- * Deep clone
5650
+ * Deep clone via structuredClone (handles circular refs, Dates, etc.).
5651
+ * Falls back to a manual deep clone that preserves Date, RegExp, Map, Set,
5652
+ * ArrayBuffer, TypedArrays, undefined values, and circular references.
5420
5653
  */
5421
5654
  function deepClone(obj) {
5422
5655
  if (typeof structuredClone === 'function') return structuredClone(obj);
5423
- return JSON.parse(JSON.stringify(obj));
5656
+
5657
+ const seen = new Map();
5658
+ function clone(val) {
5659
+ if (val === null || typeof val !== 'object') return val;
5660
+ if (seen.has(val)) return seen.get(val);
5661
+ if (val instanceof Date) return new Date(val.getTime());
5662
+ if (val instanceof RegExp) return new RegExp(val.source, val.flags);
5663
+ if (val instanceof Map) {
5664
+ const m = new Map();
5665
+ seen.set(val, m);
5666
+ val.forEach((v, k) => m.set(clone(k), clone(v)));
5667
+ return m;
5668
+ }
5669
+ if (val instanceof Set) {
5670
+ const s = new Set();
5671
+ seen.set(val, s);
5672
+ val.forEach(v => s.add(clone(v)));
5673
+ return s;
5674
+ }
5675
+ if (ArrayBuffer.isView(val)) return new val.constructor(val.buffer.slice(0));
5676
+ if (val instanceof ArrayBuffer) return val.slice(0);
5677
+ if (Array.isArray(val)) {
5678
+ const arr = [];
5679
+ seen.set(val, arr);
5680
+ for (let i = 0; i < val.length; i++) arr[i] = clone(val[i]);
5681
+ return arr;
5682
+ }
5683
+ const result = Object.create(Object.getPrototypeOf(val));
5684
+ seen.set(val, result);
5685
+ for (const key of Object.keys(val)) result[key] = clone(val[key]);
5686
+ return result;
5687
+ }
5688
+ return clone(obj);
5424
5689
  }
5425
5690
 
5691
+ // Keys that must never be written through data-merge or path-set operations
5692
+ const _UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
5693
+
5426
5694
  /**
5427
5695
  * Deep merge objects
5428
5696
  */
@@ -5432,6 +5700,7 @@ function deepMerge(target, ...sources) {
5432
5700
  if (seen.has(src)) return tgt;
5433
5701
  seen.add(src);
5434
5702
  for (const key of Object.keys(src)) {
5703
+ if (_UNSAFE_KEYS.has(key)) continue;
5435
5704
  if (src[key] && typeof src[key] === 'object' && !Array.isArray(src[key])) {
5436
5705
  if (!tgt[key] || typeof tgt[key] !== 'object') tgt[key] = {};
5437
5706
  merge(tgt[key], src[key]);
@@ -5638,10 +5907,13 @@ function setPath(obj, path, value) {
5638
5907
  let cur = obj;
5639
5908
  for (let i = 0; i < keys.length - 1; i++) {
5640
5909
  const k = keys[i];
5910
+ if (_UNSAFE_KEYS.has(k)) return obj;
5641
5911
  if (cur[k] == null || typeof cur[k] !== 'object') cur[k] = {};
5642
5912
  cur = cur[k];
5643
5913
  }
5644
- cur[keys[keys.length - 1]] = value;
5914
+ const lastKey = keys[keys.length - 1];
5915
+ if (_UNSAFE_KEYS.has(lastKey)) return obj;
5916
+ cur[lastKey] = value;
5645
5917
  return obj;
5646
5918
  }
5647
5919
 
@@ -5692,9 +5964,16 @@ function memoize(fn, keyFnOrOpts) {
5692
5964
 
5693
5965
  const memoized = (...args) => {
5694
5966
  const key = keyFn ? keyFn(...args) : args[0];
5695
- if (cache.has(key)) return cache.get(key);
5967
+ if (cache.has(key)) {
5968
+ // LRU: promote to newest by re-inserting
5969
+ const value = cache.get(key);
5970
+ cache.delete(key);
5971
+ cache.set(key, value);
5972
+ return value;
5973
+ }
5696
5974
  const result = fn(...args);
5697
5975
  cache.set(key, result);
5976
+ // LRU eviction: drop the least-recently-used entry
5698
5977
  if (maxSize > 0 && cache.size > maxSize) {
5699
5978
  cache.delete(cache.keys().next().value);
5700
5979
  }
@@ -5741,7 +6020,7 @@ function timeout(promise, ms, message) {
5741
6020
  // --- index.js (assembly) ------------------------------------------
5742
6021
  /**
5743
6022
  * ┌---------------------------------------------------------┐
5744
- * │ zQuery (zeroQuery) Lightweight Frontend Library │
6023
+ * │ zQuery (zeroQuery) - Lightweight Frontend Library │
5745
6024
  * │ │
5746
6025
  * │ jQuery-like selectors · Reactive components │
5747
6026
  * │ SPA router · State management · Zero dependencies │
@@ -5761,11 +6040,11 @@ function timeout(promise, ms, message) {
5761
6040
 
5762
6041
 
5763
6042
  // ---------------------------------------------------------------------------
5764
- // $ The main function & namespace
6043
+ // $ - The main function & namespace
5765
6044
  // ---------------------------------------------------------------------------
5766
6045
 
5767
6046
  /**
5768
- * Main selector function always returns a ZQueryCollection (like jQuery).
6047
+ * Main selector function - always returns a ZQueryCollection (like jQuery).
5769
6048
  *
5770
6049
  * $('selector') → ZQueryCollection (querySelectorAll)
5771
6050
  * $('<div>hello</div>') → ZQueryCollection from created elements
@@ -5828,6 +6107,8 @@ $.Signal = Signal;
5828
6107
  $.signal = signal;
5829
6108
  $.computed = computed;
5830
6109
  $.effect = effect;
6110
+ $.batch = batch;
6111
+ $.untracked = untracked;
5831
6112
 
5832
6113
  // --- Components ------------------------------------------------------------
5833
6114
  $.component = component;
@@ -5908,9 +6189,9 @@ $.validate = validate;
5908
6189
  $.formatError = formatError;
5909
6190
 
5910
6191
  // --- Meta ------------------------------------------------------------------
5911
- $.version = '0.9.9';
5912
- $.libSize = '~101 KB';
5913
- $.unitTests = {"passed":1808,"failed":0,"total":1808,"suites":493,"duration":2896,"ok":true};
6192
+ $.version = '1.0.0';
6193
+ $.libSize = '~106 KB';
6194
+ $.unitTests = {"passed":1882,"failed":0,"total":1882,"suites":508,"duration":3548,"ok":true};
5914
6195
  $.meta = {}; // populated at build time by CLI bundler
5915
6196
 
5916
6197
  $.noConflict = () => {