zero-query 0.9.9 → 1.0.1
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.
- package/README.md +34 -33
- package/cli/args.js +1 -1
- package/cli/commands/build.js +2 -2
- package/cli/commands/bundle.js +21 -18
- package/cli/commands/create.js +9 -2
- package/cli/commands/dev/devtools/index.js +1 -1
- package/cli/commands/dev/devtools/js/core.js +14 -14
- package/cli/commands/dev/devtools/js/elements.js +4 -4
- package/cli/commands/dev/devtools/js/stats.js +1 -1
- package/cli/commands/dev/devtools/styles.css +2 -2
- package/cli/commands/dev/index.js +2 -2
- package/cli/commands/dev/logger.js +1 -1
- package/cli/commands/dev/overlay.js +21 -14
- package/cli/commands/dev/server.js +5 -5
- package/cli/commands/dev/validator.js +7 -7
- package/cli/commands/dev/watcher.js +6 -6
- package/cli/help.js +3 -3
- package/cli/index.js +1 -1
- package/cli/scaffold/default/app/app.js +17 -18
- package/cli/scaffold/default/app/components/about.js +9 -9
- package/cli/scaffold/default/app/components/api-demo.js +6 -6
- package/cli/scaffold/default/app/components/contact-card.js +4 -4
- package/cli/scaffold/default/app/components/contacts/contacts.css +2 -2
- package/cli/scaffold/default/app/components/contacts/contacts.html +3 -3
- package/cli/scaffold/default/app/components/contacts/contacts.js +11 -11
- package/cli/scaffold/default/app/components/counter.js +8 -8
- package/cli/scaffold/default/app/components/home.js +13 -13
- package/cli/scaffold/default/app/components/not-found.js +1 -1
- package/cli/scaffold/default/app/components/playground/playground.css +1 -1
- package/cli/scaffold/default/app/components/playground/playground.html +11 -11
- package/cli/scaffold/default/app/components/playground/playground.js +11 -11
- package/cli/scaffold/default/app/components/todos.js +8 -8
- package/cli/scaffold/default/app/components/toolkit/toolkit.css +1 -1
- package/cli/scaffold/default/app/components/toolkit/toolkit.html +4 -4
- package/cli/scaffold/default/app/components/toolkit/toolkit.js +7 -7
- package/cli/scaffold/default/app/routes.js +1 -1
- package/cli/scaffold/default/app/store.js +1 -1
- package/cli/scaffold/default/global.css +2 -2
- package/cli/scaffold/default/index.html +2 -2
- package/cli/scaffold/minimal/app/app.js +6 -7
- package/cli/scaffold/minimal/app/components/about.js +5 -5
- package/cli/scaffold/minimal/app/components/counter.js +6 -6
- package/cli/scaffold/minimal/app/components/home.js +8 -8
- package/cli/scaffold/minimal/app/components/not-found.js +1 -1
- package/cli/scaffold/minimal/app/routes.js +1 -1
- package/cli/scaffold/minimal/app/store.js +1 -1
- package/cli/scaffold/minimal/global.css +2 -2
- package/cli/scaffold/minimal/index.html +1 -1
- package/cli/scaffold/ssr/app/app.js +1 -2
- package/cli/scaffold/ssr/app/components/about.js +5 -5
- package/cli/scaffold/ssr/app/components/home.js +2 -2
- package/cli/scaffold/ssr/app/components/not-found.js +2 -2
- package/cli/scaffold/ssr/app/routes.js +1 -1
- package/cli/scaffold/ssr/global.css +3 -4
- package/cli/scaffold/ssr/index.html +2 -2
- package/cli/scaffold/ssr/server/index.js +26 -25
- package/cli/utils.js +6 -6
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +508 -227
- package/dist/zquery.min.js +2 -2
- package/index.d.ts +16 -13
- package/index.js +7 -5
- package/package.json +3 -3
- package/src/component.js +64 -63
- package/src/core.js +15 -15
- package/src/diff.js +38 -38
- package/src/errors.js +17 -17
- package/src/expression.js +15 -17
- package/src/http.js +4 -4
- package/src/reactive.js +75 -9
- package/src/router.js +104 -24
- package/src/ssr.js +28 -28
- package/src/store.js +103 -21
- package/src/utils.js +64 -12
- package/tests/audit.test.js +143 -15
- package/tests/cli.test.js +20 -20
- package/tests/component.test.js +121 -121
- package/tests/core.test.js +56 -56
- package/tests/diff.test.js +42 -42
- package/tests/errors.test.js +5 -5
- package/tests/expression.test.js +58 -53
- package/tests/http.test.js +20 -20
- package/tests/reactive.test.js +185 -24
- package/tests/router.test.js +501 -74
- package/tests/ssr.test.js +15 -13
- package/tests/store.test.js +264 -23
- package/tests/test-minifier.js +153 -0
- package/tests/test-ssr.js +27 -0
- package/tests/utils.test.js +163 -26
- package/types/collection.d.ts +2 -2
- package/types/component.d.ts +5 -5
- package/types/errors.d.ts +3 -3
- package/types/http.d.ts +3 -3
- package/types/misc.d.ts +9 -9
- package/types/reactive.d.ts +25 -3
- package/types/router.d.ts +10 -6
- package/types/ssr.d.ts +2 -2
- package/types/store.d.ts +40 -5
- package/types/utils.d.ts +1 -1
package/dist/zquery.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* zQuery (zeroQuery)
|
|
2
|
+
* zQuery (zeroQuery) v1.0.1
|
|
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
|
|
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
|
|
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
|
|
74
|
+
// ZQueryError - custom error class
|
|
75
75
|
// ---------------------------------------------------------------------------
|
|
76
76
|
class ZQueryError extends Error {
|
|
77
77
|
/**
|
|
78
|
-
* @param {string} code
|
|
79
|
-
* @param {string} message
|
|
80
|
-
* @param {object} [context]
|
|
81
|
-
* @param {Error} [cause]
|
|
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
|
|
101
|
+
* Multiple handlers are supported - each receives the error.
|
|
102
102
|
* Pass `null` to clear all handlers.
|
|
103
103
|
*
|
|
104
|
-
* @param {Function|null} handler
|
|
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
|
|
122
|
+
* Non-throwing - used for recoverable errors in callbacks, lifecycle hooks, etc.
|
|
123
123
|
*
|
|
124
|
-
* @param {string} code
|
|
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
|
|
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
|
|
168
|
-
* @param {string} expectedType
|
|
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
|
|
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
|
|
207
|
-
* @param {string} code
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
377
|
+
* signals it subscribed to - prevents memory leaks.
|
|
371
378
|
*
|
|
372
|
-
* @param {Function} fn
|
|
373
|
-
* @returns {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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
450
|
-
* @param {string} newHTML
|
|
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
|
|
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
|
|
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
|
|
477
|
-
* @param {string} newHTML
|
|
478
|
-
* @returns {Element}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
511
|
-
* @param {Element} newParent
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
763
|
+
* O(n log n) - same algorithm used by Vue 3 and ivi.
|
|
698
764
|
*
|
|
699
|
-
* @param {number[]} arr
|
|
700
|
-
* @returns {Set<number>}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
830
|
+
// Both are elements - diff attributes then recurse children
|
|
765
831
|
if (oldNode.nodeType === 1) {
|
|
766
|
-
// z-skip: developer opt-out
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
// $()
|
|
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
|
|
1756
|
+
// Already a collection - return as-is
|
|
1691
1757
|
if (selector instanceof ZQueryCollection) return selector;
|
|
1692
1758
|
|
|
1693
|
-
// DOM element or Window
|
|
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
|
|
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()
|
|
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
|
|
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
|
|
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.)
|
|
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
|
|
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
|
|
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
|
|
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
|
-
// () => ...
|
|
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
|
|
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
|
|
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
|
|
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()
|
|
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 ===
|
|
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()
|
|
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
|
|
2703
|
-
* @param {object[]} scope
|
|
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 {*}
|
|
2769
|
+
* @returns {*} - evaluation result, or undefined on error
|
|
2706
2770
|
*/
|
|
2707
2771
|
|
|
2708
|
-
// AST cache (LRU)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2855
|
-
* @param {string} [base]
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
3263
|
-
// the activeElement
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3493
|
+
// .self - only fire if target is the element itself
|
|
3429
3494
|
if (modifiers.includes('self') && e.target !== el) continue;
|
|
3430
3495
|
|
|
3431
|
-
// .outside
|
|
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
|
|
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
|
|
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}
|
|
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}
|
|
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
|
|
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
|
|
3539
|
-
// z-trim
|
|
3540
|
-
// z-number
|
|
3541
|
-
// z-debounce
|
|
3542
|
-
// z-uppercase
|
|
3543
|
-
// z-lowercase
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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('*')
|
|
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
|
|
3949
|
-
* @param {object} 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
|
|
3975
|
-
* @param {string} componentName
|
|
3976
|
-
* @param {object} props
|
|
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
|
|
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"
|
|
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
|
|
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
|
|
4081
|
-
* @param {string} 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
4126
|
-
* @param {object} [opts]
|
|
4127
|
-
* @param {boolean} [opts.critical=true]
|
|
4128
|
-
* @param {string} [opts.bg]
|
|
4129
|
-
* @returns {{ remove: Function, ready: Promise }}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 —
|
|
4298
|
-
//
|
|
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
|
|
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 {
|
|
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
|
|
4379
|
-
* @param {Object} params
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
4484
|
-
* @returns {string}
|
|
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
|
|
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
|
|
4539
|
-
* @param {*} data
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
|
|
4890
|
-
|
|
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
|
|
4912
|
-
* @param {...any} args
|
|
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
|
|
4952
|
-
* @param {Function} [fn]
|
|
4953
|
-
* @returns {Function}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
5391
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
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)
|
|
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
|
-
// $
|
|
6043
|
+
// $ - The main function & namespace
|
|
5765
6044
|
// ---------------------------------------------------------------------------
|
|
5766
6045
|
|
|
5767
6046
|
/**
|
|
5768
|
-
* Main selector function
|
|
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.
|
|
5912
|
-
$.libSize = '~
|
|
5913
|
-
$.unitTests = {"passed":
|
|
6192
|
+
$.version = '1.0.1';
|
|
6193
|
+
$.libSize = '~106 KB';
|
|
6194
|
+
$.unitTests = {"passed":1882,"failed":0,"total":1882,"suites":508,"duration":3350,"ok":true};
|
|
5914
6195
|
$.meta = {}; // populated at build time by CLI bundler
|
|
5915
6196
|
|
|
5916
6197
|
$.noConflict = () => {
|