zero-query 0.9.8 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -31
- package/cli/args.js +1 -1
- package/cli/commands/build.js +2 -2
- package/cli/commands/bundle.js +15 -15
- package/cli/commands/create.js +41 -7
- 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 +4 -2
- package/cli/index.js +2 -2
- 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 +29 -0
- package/cli/scaffold/ssr/app/components/about.js +28 -0
- package/cli/scaffold/ssr/app/components/home.js +37 -0
- package/cli/scaffold/ssr/app/components/not-found.js +15 -0
- package/cli/scaffold/ssr/app/routes.js +6 -0
- package/cli/scaffold/ssr/global.css +113 -0
- package/cli/scaffold/ssr/index.html +31 -0
- package/cli/scaffold/ssr/package.json +8 -0
- package/cli/scaffold/ssr/server/index.js +118 -0
- package/cli/utils.js +6 -6
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +565 -228
- package/dist/zquery.min.js +2 -2
- package/index.d.ts +25 -12
- package/index.js +11 -7
- package/package.json +9 -3
- package/src/component.js +64 -63
- package/src/core.js +15 -15
- package/src/diff.js +38 -38
- package/src/errors.js +72 -18
- package/src/expression.js +15 -17
- package/src/http.js +4 -4
- package/src/package.json +1 -0
- package/src/reactive.js +75 -9
- package/src/router.js +104 -24
- package/src/ssr.js +133 -39
- 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 +425 -147
- 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 +444 -10
- package/tests/store.test.js +264 -23
- 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 +36 -4
- 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 +22 -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.0
|
|
3
3
|
* Lightweight Frontend Library
|
|
4
4
|
* https://github.com/tonywied17/zero-query
|
|
5
5
|
* (c) 2026 Anthony Wiedman - MIT License
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
// --- src/errors.js -----------------------------------------------
|
|
11
11
|
/**
|
|
12
|
-
* zQuery Errors
|
|
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
|
|
@@ -59,20 +59,26 @@ const ErrorCode = Object.freeze({
|
|
|
59
59
|
HTTP_INTERCEPTOR: 'ZQ_HTTP_INTERCEPTOR',
|
|
60
60
|
HTTP_PARSE: 'ZQ_HTTP_PARSE',
|
|
61
61
|
|
|
62
|
+
// SSR
|
|
63
|
+
SSR_RENDER: 'ZQ_SSR_RENDER',
|
|
64
|
+
SSR_COMPONENT: 'ZQ_SSR_COMPONENT',
|
|
65
|
+
SSR_HYDRATION: 'ZQ_SSR_HYDRATION',
|
|
66
|
+
SSR_PAGE: 'ZQ_SSR_PAGE',
|
|
67
|
+
|
|
62
68
|
// General
|
|
63
69
|
INVALID_ARGUMENT: 'ZQ_INVALID_ARGUMENT',
|
|
64
70
|
});
|
|
65
71
|
|
|
66
72
|
|
|
67
73
|
// ---------------------------------------------------------------------------
|
|
68
|
-
// ZQueryError
|
|
74
|
+
// ZQueryError - custom error class
|
|
69
75
|
// ---------------------------------------------------------------------------
|
|
70
76
|
class ZQueryError extends Error {
|
|
71
77
|
/**
|
|
72
|
-
* @param {string} code
|
|
73
|
-
* @param {string} message
|
|
74
|
-
* @param {object} [context]
|
|
75
|
-
* @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
|
|
76
82
|
*/
|
|
77
83
|
constructor(code, message, context = {}, cause) {
|
|
78
84
|
super(message);
|
|
@@ -87,23 +93,35 @@ class ZQueryError extends Error {
|
|
|
87
93
|
// ---------------------------------------------------------------------------
|
|
88
94
|
// Global error handler
|
|
89
95
|
// ---------------------------------------------------------------------------
|
|
90
|
-
let
|
|
96
|
+
let _errorHandlers = [];
|
|
91
97
|
|
|
92
98
|
/**
|
|
93
99
|
* Register a global error handler.
|
|
94
100
|
* Called whenever zQuery catches an error internally.
|
|
101
|
+
* Multiple handlers are supported - each receives the error.
|
|
102
|
+
* Pass `null` to clear all handlers.
|
|
95
103
|
*
|
|
96
|
-
* @param {Function|null} handler
|
|
104
|
+
* @param {Function|null} handler - (error: ZQueryError) => void
|
|
105
|
+
* @returns {Function} unsubscribe function to remove this handler
|
|
97
106
|
*/
|
|
98
107
|
function onError(handler) {
|
|
99
|
-
|
|
108
|
+
if (handler === null) {
|
|
109
|
+
_errorHandlers = [];
|
|
110
|
+
return () => {};
|
|
111
|
+
}
|
|
112
|
+
if (typeof handler !== 'function') return () => {};
|
|
113
|
+
_errorHandlers.push(handler);
|
|
114
|
+
return () => {
|
|
115
|
+
const idx = _errorHandlers.indexOf(handler);
|
|
116
|
+
if (idx !== -1) _errorHandlers.splice(idx, 1);
|
|
117
|
+
};
|
|
100
118
|
}
|
|
101
119
|
|
|
102
120
|
/**
|
|
103
121
|
* Report an error through the global handler and console.
|
|
104
|
-
* Non-throwing
|
|
122
|
+
* Non-throwing - used for recoverable errors in callbacks, lifecycle hooks, etc.
|
|
105
123
|
*
|
|
106
|
-
* @param {string} code
|
|
124
|
+
* @param {string} code - ErrorCode
|
|
107
125
|
* @param {string} message
|
|
108
126
|
* @param {object} [context]
|
|
109
127
|
* @param {Error} [cause]
|
|
@@ -113,9 +131,9 @@ function reportError(code, message, context = {}, cause) {
|
|
|
113
131
|
? cause
|
|
114
132
|
: new ZQueryError(code, message, context, cause);
|
|
115
133
|
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
try {
|
|
134
|
+
// Notify all registered handlers
|
|
135
|
+
for (const handler of _errorHandlers) {
|
|
136
|
+
try { handler(err); } catch { /* prevent handler from crashing framework */ }
|
|
119
137
|
}
|
|
120
138
|
|
|
121
139
|
// Always log for developer visibility
|
|
@@ -127,7 +145,7 @@ function reportError(code, message, context = {}, cause) {
|
|
|
127
145
|
* the current execution context.
|
|
128
146
|
*
|
|
129
147
|
* @param {Function} fn
|
|
130
|
-
* @param {string} code
|
|
148
|
+
* @param {string} code - ErrorCode to use if the callback throws
|
|
131
149
|
* @param {object} [context]
|
|
132
150
|
* @returns {Function}
|
|
133
151
|
*/
|
|
@@ -146,8 +164,8 @@ function guardCallback(fn, code, context = {}) {
|
|
|
146
164
|
* Throws ZQueryError on failure (for fast-fail at API boundaries).
|
|
147
165
|
*
|
|
148
166
|
* @param {*} value
|
|
149
|
-
* @param {string} name
|
|
150
|
-
* @param {string} expectedType
|
|
167
|
+
* @param {string} name - parameter name for error message
|
|
168
|
+
* @param {string} expectedType - 'string', 'function', 'object', etc.
|
|
151
169
|
*/
|
|
152
170
|
function validate(value, name, expectedType) {
|
|
153
171
|
if (value === undefined || value === null) {
|
|
@@ -162,11 +180,47 @@ function validate(value, name, expectedType) {
|
|
|
162
180
|
`"${name}" must be a ${expectedType}, got ${typeof value}`
|
|
163
181
|
);
|
|
164
182
|
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Format a ZQueryError into a structured object suitable for overlays/logging.
|
|
187
|
+
* @param {ZQueryError|Error} err
|
|
188
|
+
* @returns {{ code: string, type: string, message: string, context: object, stack: string }}
|
|
189
|
+
*/
|
|
190
|
+
function formatError(err) {
|
|
191
|
+
const isZQ = err instanceof ZQueryError;
|
|
192
|
+
return {
|
|
193
|
+
code: isZQ ? err.code : '',
|
|
194
|
+
type: isZQ ? 'ZQueryError' : (err.name || 'Error'),
|
|
195
|
+
message: err.message || 'Unknown error',
|
|
196
|
+
context: isZQ ? err.context : {},
|
|
197
|
+
stack: err.stack || '',
|
|
198
|
+
cause: err.cause ? formatError(err.cause) : null,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Async version of guardCallback - wraps an async function so that
|
|
204
|
+
* rejections are caught, reported, and don't crash execution.
|
|
205
|
+
*
|
|
206
|
+
* @param {Function} fn - async function
|
|
207
|
+
* @param {string} code - ErrorCode to use
|
|
208
|
+
* @param {object} [context]
|
|
209
|
+
* @returns {Function}
|
|
210
|
+
*/
|
|
211
|
+
function guardAsync(fn, code, context = {}) {
|
|
212
|
+
return async (...args) => {
|
|
213
|
+
try {
|
|
214
|
+
return await fn(...args);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
reportError(code, err.message || 'Async callback error', context, err);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
165
219
|
}
|
|
166
220
|
|
|
167
221
|
// --- src/reactive.js ---------------------------------------------
|
|
168
222
|
/**
|
|
169
|
-
* zQuery Reactive
|
|
223
|
+
* zQuery Reactive - Proxy-based deep reactivity system
|
|
170
224
|
*
|
|
171
225
|
* Creates observable objects that trigger callbacks on mutation.
|
|
172
226
|
* Used internally by components and store for auto-updates.
|
|
@@ -233,7 +287,7 @@ function reactive(target, onChange, _path = '') {
|
|
|
233
287
|
|
|
234
288
|
|
|
235
289
|
// ---------------------------------------------------------------------------
|
|
236
|
-
// Signal
|
|
290
|
+
// Signal - lightweight reactive primitive (inspired by Solid/Preact signals)
|
|
237
291
|
// ---------------------------------------------------------------------------
|
|
238
292
|
class Signal {
|
|
239
293
|
constructor(value) {
|
|
@@ -262,7 +316,11 @@ class Signal {
|
|
|
262
316
|
peek() { return this._value; }
|
|
263
317
|
|
|
264
318
|
_notify() {
|
|
265
|
-
|
|
319
|
+
if (Signal._batching) {
|
|
320
|
+
Signal._batchQueue.add(this);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
// Snapshot subscribers before iterating - a subscriber might modify
|
|
266
324
|
// the set (e.g., an effect re-running, adding itself back)
|
|
267
325
|
const subs = [...this._subscribers];
|
|
268
326
|
for (let i = 0; i < subs.length; i++) {
|
|
@@ -283,10 +341,13 @@ class Signal {
|
|
|
283
341
|
|
|
284
342
|
// Active effect tracking
|
|
285
343
|
Signal._activeEffect = null;
|
|
344
|
+
// Batch state
|
|
345
|
+
Signal._batching = false;
|
|
346
|
+
Signal._batchQueue = new Set();
|
|
286
347
|
|
|
287
348
|
/**
|
|
288
349
|
* Create a signal
|
|
289
|
-
* @param {*} initial
|
|
350
|
+
* @param {*} initial - initial value
|
|
290
351
|
* @returns {Signal}
|
|
291
352
|
*/
|
|
292
353
|
function signal(initial) {
|
|
@@ -295,7 +356,7 @@ function signal(initial) {
|
|
|
295
356
|
|
|
296
357
|
/**
|
|
297
358
|
* Create a computed signal (derived from other signals)
|
|
298
|
-
* @param {Function} fn
|
|
359
|
+
* @param {Function} fn - computation function
|
|
299
360
|
* @returns {Signal}
|
|
300
361
|
*/
|
|
301
362
|
function computed(fn) {
|
|
@@ -313,10 +374,10 @@ function computed(fn) {
|
|
|
313
374
|
/**
|
|
314
375
|
* Create a side-effect that auto-tracks signal dependencies.
|
|
315
376
|
* Returns a dispose function that removes the effect from all
|
|
316
|
-
* signals it subscribed to
|
|
377
|
+
* signals it subscribed to - prevents memory leaks.
|
|
317
378
|
*
|
|
318
|
-
* @param {Function} fn
|
|
319
|
-
* @returns {Function}
|
|
379
|
+
* @param {Function} fn - effect function
|
|
380
|
+
* @returns {Function} - dispose function
|
|
320
381
|
*/
|
|
321
382
|
function effect(fn) {
|
|
322
383
|
const execute = () => {
|
|
@@ -349,13 +410,72 @@ function effect(fn) {
|
|
|
349
410
|
}
|
|
350
411
|
execute._deps.clear();
|
|
351
412
|
}
|
|
352
|
-
// Don't clobber _activeEffect
|
|
413
|
+
// Don't clobber _activeEffect - another effect may be running
|
|
353
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
|
+
}
|
|
354
474
|
}
|
|
355
475
|
|
|
356
476
|
// --- src/diff.js -------------------------------------------------
|
|
357
477
|
/**
|
|
358
|
-
* zQuery Diff
|
|
478
|
+
* zQuery Diff - Lightweight DOM morphing engine
|
|
359
479
|
*
|
|
360
480
|
* Patches an existing DOM tree to match new HTML without destroying nodes
|
|
361
481
|
* that haven't changed. Preserves focus, scroll positions, third-party
|
|
@@ -365,17 +485,17 @@ function effect(fn) {
|
|
|
365
485
|
* Keyed elements (via `z-key`) get matched across position changes.
|
|
366
486
|
*
|
|
367
487
|
* Performance advantages over virtual DOM (React/Angular):
|
|
368
|
-
* - No virtual tree allocation or diffing
|
|
488
|
+
* - No virtual tree allocation or diffing - works directly on real DOM
|
|
369
489
|
* - Skips unchanged subtrees via fast isEqualNode() check
|
|
370
490
|
* - z-skip attribute to opt out of diffing entire subtrees
|
|
371
491
|
* - Reuses a single template element for HTML parsing (zero GC pressure)
|
|
372
492
|
* - Keyed reconciliation uses LIS (Longest Increasing Subsequence) to
|
|
373
|
-
* minimize DOM moves
|
|
493
|
+
* minimize DOM moves - same algorithm as Vue 3 / ivi
|
|
374
494
|
* - Minimal attribute diffing with early bail-out
|
|
375
495
|
*/
|
|
376
496
|
|
|
377
497
|
// ---------------------------------------------------------------------------
|
|
378
|
-
// Reusable template element
|
|
498
|
+
// Reusable template element - avoids per-call allocation
|
|
379
499
|
// ---------------------------------------------------------------------------
|
|
380
500
|
let _tpl = null;
|
|
381
501
|
|
|
@@ -385,15 +505,15 @@ function _getTemplate() {
|
|
|
385
505
|
}
|
|
386
506
|
|
|
387
507
|
// ---------------------------------------------------------------------------
|
|
388
|
-
// morph(existingRoot, newHTML)
|
|
508
|
+
// morph(existingRoot, newHTML) - patch existing DOM to match newHTML
|
|
389
509
|
// ---------------------------------------------------------------------------
|
|
390
510
|
|
|
391
511
|
/**
|
|
392
512
|
* Morph an existing DOM element's children to match new HTML.
|
|
393
513
|
* Only touches nodes that actually differ.
|
|
394
514
|
*
|
|
395
|
-
* @param {Element} rootEl
|
|
396
|
-
* @param {string} newHTML
|
|
515
|
+
* @param {Element} rootEl - The live DOM container to patch
|
|
516
|
+
* @param {string} newHTML - The desired HTML string
|
|
397
517
|
*/
|
|
398
518
|
function morph(rootEl, newHTML) {
|
|
399
519
|
const start = typeof window !== 'undefined' && window.__zqMorphHook ? performance.now() : 0;
|
|
@@ -402,7 +522,7 @@ function morph(rootEl, newHTML) {
|
|
|
402
522
|
const newRoot = tpl.content;
|
|
403
523
|
|
|
404
524
|
// Move children into a wrapper for consistent handling.
|
|
405
|
-
// We move (not clone) from the template
|
|
525
|
+
// We move (not clone) from the template - cheaper than cloning.
|
|
406
526
|
const tempDiv = document.createElement('div');
|
|
407
527
|
while (newRoot.firstChild) tempDiv.appendChild(newRoot.firstChild);
|
|
408
528
|
|
|
@@ -412,16 +532,16 @@ function morph(rootEl, newHTML) {
|
|
|
412
532
|
}
|
|
413
533
|
|
|
414
534
|
/**
|
|
415
|
-
* Morph a single element in place
|
|
535
|
+
* Morph a single element in place - diffs attributes and children
|
|
416
536
|
* without replacing the node reference. Useful for replaceWith-style
|
|
417
537
|
* updates where you want to keep the element identity when the tag
|
|
418
538
|
* name matches.
|
|
419
539
|
*
|
|
420
540
|
* If the new HTML produces a different tag, falls back to native replace.
|
|
421
541
|
*
|
|
422
|
-
* @param {Element} oldEl
|
|
423
|
-
* @param {string} newHTML
|
|
424
|
-
* @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)
|
|
425
545
|
*/
|
|
426
546
|
function morphElement(oldEl, newHTML) {
|
|
427
547
|
const start = typeof window !== 'undefined' && window.__zqMorphHook ? performance.now() : 0;
|
|
@@ -430,7 +550,7 @@ function morphElement(oldEl, newHTML) {
|
|
|
430
550
|
const newEl = tpl.content.firstElementChild;
|
|
431
551
|
if (!newEl) return oldEl;
|
|
432
552
|
|
|
433
|
-
// Same tag
|
|
553
|
+
// Same tag - morph in place (preserves identity, event listeners, refs)
|
|
434
554
|
if (oldEl.nodeName === newEl.nodeName) {
|
|
435
555
|
_morphAttributes(oldEl, newEl);
|
|
436
556
|
_morphChildren(oldEl, newEl);
|
|
@@ -438,14 +558,14 @@ function morphElement(oldEl, newHTML) {
|
|
|
438
558
|
return oldEl;
|
|
439
559
|
}
|
|
440
560
|
|
|
441
|
-
// Different tag
|
|
561
|
+
// Different tag - must replace (can't morph <div> into <span>)
|
|
442
562
|
const clone = newEl.cloneNode(true);
|
|
443
563
|
oldEl.parentNode.replaceChild(clone, oldEl);
|
|
444
564
|
if (start) window.__zqMorphHook(clone, performance.now() - start);
|
|
445
565
|
return clone;
|
|
446
566
|
}
|
|
447
567
|
|
|
448
|
-
// Aliases for the concat build
|
|
568
|
+
// Aliases for the concat build - core.js imports these as _morph / _morphElement,
|
|
449
569
|
// but the build strips `import … as` lines, so the aliases must exist at runtime.
|
|
450
570
|
const _morph = morph;
|
|
451
571
|
const _morphElement = morphElement;
|
|
@@ -453,11 +573,11 @@ const _morphElement = morphElement;
|
|
|
453
573
|
/**
|
|
454
574
|
* Reconcile children of `oldParent` to match `newParent`.
|
|
455
575
|
*
|
|
456
|
-
* @param {Element} oldParent
|
|
457
|
-
* @param {Element} newParent
|
|
576
|
+
* @param {Element} oldParent - live DOM parent
|
|
577
|
+
* @param {Element} newParent - desired state parent
|
|
458
578
|
*/
|
|
459
579
|
function _morphChildren(oldParent, newParent) {
|
|
460
|
-
// Snapshot live NodeLists into arrays
|
|
580
|
+
// Snapshot live NodeLists into arrays - childNodes is live and
|
|
461
581
|
// mutates during insertBefore/removeChild. Using a for loop to push
|
|
462
582
|
// avoids spread operator overhead for large child lists.
|
|
463
583
|
const oldCN = oldParent.childNodes;
|
|
@@ -469,7 +589,7 @@ function _morphChildren(oldParent, newParent) {
|
|
|
469
589
|
for (let i = 0; i < oldLen; i++) oldChildren[i] = oldCN[i];
|
|
470
590
|
for (let i = 0; i < newLen; i++) newChildren[i] = newCN[i];
|
|
471
591
|
|
|
472
|
-
// Scan for keyed elements
|
|
592
|
+
// Scan for keyed elements - only build maps if keys exist
|
|
473
593
|
let hasKeys = false;
|
|
474
594
|
let oldKeyMap, newKeyMap;
|
|
475
595
|
|
|
@@ -500,7 +620,7 @@ function _morphChildren(oldParent, newParent) {
|
|
|
500
620
|
}
|
|
501
621
|
|
|
502
622
|
/**
|
|
503
|
-
* Unkeyed reconciliation
|
|
623
|
+
* Unkeyed reconciliation - positional matching.
|
|
504
624
|
*/
|
|
505
625
|
function _morphChildrenUnkeyed(oldParent, oldChildren, newChildren) {
|
|
506
626
|
const oldLen = oldChildren.length;
|
|
@@ -528,7 +648,7 @@ function _morphChildrenUnkeyed(oldParent, oldChildren, newChildren) {
|
|
|
528
648
|
}
|
|
529
649
|
|
|
530
650
|
/**
|
|
531
|
-
* Keyed reconciliation
|
|
651
|
+
* Keyed reconciliation - match by z-key, reorder with minimal moves
|
|
532
652
|
* using Longest Increasing Subsequence (LIS) to find the maximum set
|
|
533
653
|
* of nodes that are already in the correct relative order, then only
|
|
534
654
|
* move the remaining nodes.
|
|
@@ -562,7 +682,7 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
|
|
|
562
682
|
|
|
563
683
|
// Step 3: Build index array for LIS of matched old indices.
|
|
564
684
|
// This finds the largest set of keyed nodes already in order,
|
|
565
|
-
// 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²).
|
|
566
686
|
const oldIndices = []; // Maps new-position → old-position (or -1)
|
|
567
687
|
for (let i = 0; i < newLen; i++) {
|
|
568
688
|
if (matched[i]) {
|
|
@@ -575,7 +695,7 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
|
|
|
575
695
|
|
|
576
696
|
const lisSet = _lis(oldIndices);
|
|
577
697
|
|
|
578
|
-
// Step 4: Insert / reorder / morph
|
|
698
|
+
// Step 4: Insert / reorder / morph - walk new children forward,
|
|
579
699
|
// using LIS to decide which nodes stay in place.
|
|
580
700
|
let cursor = oldParent.firstChild;
|
|
581
701
|
const unkeyedOld = [];
|
|
@@ -600,7 +720,7 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
|
|
|
600
720
|
if (!lisSet.has(i)) {
|
|
601
721
|
oldParent.insertBefore(oldNode, cursor);
|
|
602
722
|
}
|
|
603
|
-
// Capture next sibling BEFORE _morphNode
|
|
723
|
+
// Capture next sibling BEFORE _morphNode - if _morphNode calls
|
|
604
724
|
// replaceChild, oldNode is removed and nextSibling becomes stale.
|
|
605
725
|
const nextSib = oldNode.nextSibling;
|
|
606
726
|
_morphNode(oldParent, oldNode, newNode);
|
|
@@ -640,10 +760,10 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
|
|
|
640
760
|
* Returns a Set of positions (in the input) that form the LIS.
|
|
641
761
|
* Entries with value -1 (unmatched) are excluded.
|
|
642
762
|
*
|
|
643
|
-
* O(n log n)
|
|
763
|
+
* O(n log n) - same algorithm used by Vue 3 and ivi.
|
|
644
764
|
*
|
|
645
|
-
* @param {number[]} arr
|
|
646
|
-
* @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
|
|
647
767
|
*/
|
|
648
768
|
function _lis(arr) {
|
|
649
769
|
const len = arr.length;
|
|
@@ -687,7 +807,7 @@ function _lis(arr) {
|
|
|
687
807
|
* Morph a single node in place.
|
|
688
808
|
*/
|
|
689
809
|
function _morphNode(parent, oldNode, newNode) {
|
|
690
|
-
// Text / comment nodes
|
|
810
|
+
// Text / comment nodes - just update content
|
|
691
811
|
if (oldNode.nodeType === 3 || oldNode.nodeType === 8) {
|
|
692
812
|
if (newNode.nodeType === oldNode.nodeType) {
|
|
693
813
|
if (oldNode.nodeValue !== newNode.nodeValue) {
|
|
@@ -695,26 +815,26 @@ function _morphNode(parent, oldNode, newNode) {
|
|
|
695
815
|
}
|
|
696
816
|
return;
|
|
697
817
|
}
|
|
698
|
-
// Different node types
|
|
818
|
+
// Different node types - replace
|
|
699
819
|
parent.replaceChild(newNode.cloneNode(true), oldNode);
|
|
700
820
|
return;
|
|
701
821
|
}
|
|
702
822
|
|
|
703
|
-
// Different node types or tag names
|
|
823
|
+
// Different node types or tag names - replace entirely
|
|
704
824
|
if (oldNode.nodeType !== newNode.nodeType ||
|
|
705
825
|
oldNode.nodeName !== newNode.nodeName) {
|
|
706
826
|
parent.replaceChild(newNode.cloneNode(true), oldNode);
|
|
707
827
|
return;
|
|
708
828
|
}
|
|
709
829
|
|
|
710
|
-
// Both are elements
|
|
830
|
+
// Both are elements - diff attributes then recurse children
|
|
711
831
|
if (oldNode.nodeType === 1) {
|
|
712
|
-
// z-skip: developer opt-out
|
|
832
|
+
// z-skip: developer opt-out - skip diffing this subtree entirely.
|
|
713
833
|
// Useful for third-party widgets, canvas, video, or large static content.
|
|
714
834
|
if (oldNode.hasAttribute('z-skip')) return;
|
|
715
835
|
|
|
716
836
|
// Fast bail-out: if the elements are identical, skip everything.
|
|
717
|
-
// isEqualNode() is a native C++ comparison
|
|
837
|
+
// isEqualNode() is a native C++ comparison - much faster than walking
|
|
718
838
|
// attributes + children in JS when trees haven't changed.
|
|
719
839
|
if (oldNode.isEqualNode(newNode)) return;
|
|
720
840
|
|
|
@@ -740,7 +860,7 @@ function _morphNode(parent, oldNode, newNode) {
|
|
|
740
860
|
return;
|
|
741
861
|
}
|
|
742
862
|
|
|
743
|
-
// Generic element
|
|
863
|
+
// Generic element - recurse children
|
|
744
864
|
_morphChildren(oldNode, newNode);
|
|
745
865
|
}
|
|
746
866
|
}
|
|
@@ -782,7 +902,7 @@ function _morphAttributes(oldEl, newEl) {
|
|
|
782
902
|
}
|
|
783
903
|
}
|
|
784
904
|
|
|
785
|
-
// Remove stale attributes
|
|
905
|
+
// Remove stale attributes - snapshot names first because oldAttrs
|
|
786
906
|
// is a live NamedNodeMap that mutates on removeAttribute().
|
|
787
907
|
const oldNames = new Array(oldLen);
|
|
788
908
|
for (let i = 0; i < oldLen; i++) oldNames[i] = oldAttrs[i].name;
|
|
@@ -798,7 +918,7 @@ function _morphAttributes(oldEl, newEl) {
|
|
|
798
918
|
*
|
|
799
919
|
* Only updates the value when the new HTML explicitly carries a `value`
|
|
800
920
|
* attribute. Templates that use z-model manage values through reactive
|
|
801
|
-
* state + _bindModels
|
|
921
|
+
* state + _bindModels - the morph engine should not interfere by wiping
|
|
802
922
|
* a live input's content to '' just because the template has no `value`
|
|
803
923
|
* attr. This prevents the wipe-then-restore cycle that resets cursor
|
|
804
924
|
* position on every keystroke.
|
|
@@ -828,14 +948,14 @@ function _syncInputValue(oldEl, newEl) {
|
|
|
828
948
|
*
|
|
829
949
|
* This means the LIS-optimised keyed path activates automatically
|
|
830
950
|
* whenever elements carry `id` or `data-id` / `data-key` attributes
|
|
831
|
-
*
|
|
951
|
+
* - no extra markup required.
|
|
832
952
|
*
|
|
833
953
|
* @returns {string|null}
|
|
834
954
|
*/
|
|
835
955
|
function _getKey(node) {
|
|
836
956
|
if (node.nodeType !== 1) return null;
|
|
837
957
|
|
|
838
|
-
// Explicit z-key
|
|
958
|
+
// Explicit z-key - highest priority
|
|
839
959
|
const zk = node.getAttribute('z-key');
|
|
840
960
|
if (zk) return zk;
|
|
841
961
|
|
|
@@ -854,7 +974,7 @@ function _getKey(node) {
|
|
|
854
974
|
|
|
855
975
|
// --- src/core.js -------------------------------------------------
|
|
856
976
|
/**
|
|
857
|
-
* zQuery Core
|
|
977
|
+
* zQuery Core - Selector engine & chainable DOM collection
|
|
858
978
|
*
|
|
859
979
|
* Extends the quick-ref pattern (Id, Class, Classes, Children)
|
|
860
980
|
* into a full jQuery-like chainable wrapper with modern APIs.
|
|
@@ -862,7 +982,7 @@ function _getKey(node) {
|
|
|
862
982
|
|
|
863
983
|
|
|
864
984
|
// ---------------------------------------------------------------------------
|
|
865
|
-
// ZQueryCollection
|
|
985
|
+
// ZQueryCollection - wraps an array of elements with chainable methods
|
|
866
986
|
// ---------------------------------------------------------------------------
|
|
867
987
|
class ZQueryCollection {
|
|
868
988
|
constructor(elements) {
|
|
@@ -1082,7 +1202,7 @@ class ZQueryCollection {
|
|
|
1082
1202
|
// --- Classes -------------------------------------------------------------
|
|
1083
1203
|
|
|
1084
1204
|
addClass(...names) {
|
|
1085
|
-
// Fast path: single class, no spaces
|
|
1205
|
+
// Fast path: single class, no spaces - avoids flatMap + regex split allocation
|
|
1086
1206
|
if (names.length === 1 && names[0].indexOf(' ') === -1) {
|
|
1087
1207
|
const c = names[0];
|
|
1088
1208
|
for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.add(c);
|
|
@@ -1244,7 +1364,7 @@ class ZQueryCollection {
|
|
|
1244
1364
|
if (content === undefined) return this.first()?.innerHTML;
|
|
1245
1365
|
// Auto-morph: if the element already has children, use the diff engine
|
|
1246
1366
|
// to patch the DOM (preserves focus, scroll, state, keyed reorder via LIS).
|
|
1247
|
-
// Empty elements get raw innerHTML for fast first-paint
|
|
1367
|
+
// Empty elements get raw innerHTML for fast first-paint - same strategy
|
|
1248
1368
|
// the component system uses (first render = innerHTML, updates = morph).
|
|
1249
1369
|
return this.each((_, el) => {
|
|
1250
1370
|
if (el.childNodes.length > 0) {
|
|
@@ -1429,7 +1549,7 @@ class ZQueryCollection {
|
|
|
1429
1549
|
if (typeof selectorOrHandler === 'function') {
|
|
1430
1550
|
el.addEventListener(evt, selectorOrHandler);
|
|
1431
1551
|
} else if (typeof selectorOrHandler === 'string') {
|
|
1432
|
-
// Delegated event
|
|
1552
|
+
// Delegated event - store wrapper so off() can remove it
|
|
1433
1553
|
const wrapper = (e) => {
|
|
1434
1554
|
if (!e.target || typeof e.target.closest !== 'function') return;
|
|
1435
1555
|
const target = e.target.closest(selectorOrHandler);
|
|
@@ -1491,7 +1611,7 @@ class ZQueryCollection {
|
|
|
1491
1611
|
// --- Animation -----------------------------------------------------------
|
|
1492
1612
|
|
|
1493
1613
|
animate(props, duration = 300, easing = 'ease') {
|
|
1494
|
-
// Empty collection
|
|
1614
|
+
// Empty collection - resolve immediately
|
|
1495
1615
|
if (this.length === 0) return Promise.resolve(this);
|
|
1496
1616
|
return new Promise(resolve => {
|
|
1497
1617
|
let resolved = false;
|
|
@@ -1617,7 +1737,7 @@ class ZQueryCollection {
|
|
|
1617
1737
|
|
|
1618
1738
|
|
|
1619
1739
|
// ---------------------------------------------------------------------------
|
|
1620
|
-
// Helper
|
|
1740
|
+
// Helper - create document fragment from HTML string
|
|
1621
1741
|
// ---------------------------------------------------------------------------
|
|
1622
1742
|
function createFragment(html) {
|
|
1623
1743
|
const tpl = document.createElement('template');
|
|
@@ -1627,21 +1747,21 @@ function createFragment(html) {
|
|
|
1627
1747
|
|
|
1628
1748
|
|
|
1629
1749
|
// ---------------------------------------------------------------------------
|
|
1630
|
-
// $()
|
|
1750
|
+
// $() - main selector / creator (returns ZQueryCollection, like jQuery)
|
|
1631
1751
|
// ---------------------------------------------------------------------------
|
|
1632
1752
|
function query(selector, context) {
|
|
1633
1753
|
// null / undefined
|
|
1634
1754
|
if (!selector) return new ZQueryCollection([]);
|
|
1635
1755
|
|
|
1636
|
-
// Already a collection
|
|
1756
|
+
// Already a collection - return as-is
|
|
1637
1757
|
if (selector instanceof ZQueryCollection) return selector;
|
|
1638
1758
|
|
|
1639
|
-
// DOM element or Window
|
|
1759
|
+
// DOM element or Window - wrap in collection
|
|
1640
1760
|
if (selector instanceof Node || selector === window) {
|
|
1641
1761
|
return new ZQueryCollection([selector]);
|
|
1642
1762
|
}
|
|
1643
1763
|
|
|
1644
|
-
// NodeList / HTMLCollection / Array
|
|
1764
|
+
// NodeList / HTMLCollection / Array - wrap in collection
|
|
1645
1765
|
if (selector instanceof NodeList || selector instanceof HTMLCollection || Array.isArray(selector)) {
|
|
1646
1766
|
return new ZQueryCollection(Array.from(selector));
|
|
1647
1767
|
}
|
|
@@ -1665,7 +1785,7 @@ function query(selector, context) {
|
|
|
1665
1785
|
|
|
1666
1786
|
|
|
1667
1787
|
// ---------------------------------------------------------------------------
|
|
1668
|
-
// $.all()
|
|
1788
|
+
// $.all() - collection selector (returns ZQueryCollection for CSS selectors)
|
|
1669
1789
|
// ---------------------------------------------------------------------------
|
|
1670
1790
|
function queryAll(selector, context) {
|
|
1671
1791
|
// null / undefined
|
|
@@ -1720,7 +1840,7 @@ query.children = (parentId) => {
|
|
|
1720
1840
|
query.qs = (sel, ctx = document) => ctx.querySelector(sel);
|
|
1721
1841
|
query.qsa = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel));
|
|
1722
1842
|
|
|
1723
|
-
// Create element shorthand
|
|
1843
|
+
// Create element shorthand - returns ZQueryCollection for chaining
|
|
1724
1844
|
query.create = (tag, attrs = {}, ...children) => {
|
|
1725
1845
|
const el = document.createElement(tag);
|
|
1726
1846
|
for (const [k, v] of Object.entries(attrs)) {
|
|
@@ -1743,7 +1863,7 @@ query.ready = (fn) => {
|
|
|
1743
1863
|
else document.addEventListener('DOMContentLoaded', fn);
|
|
1744
1864
|
};
|
|
1745
1865
|
|
|
1746
|
-
// Global event listeners
|
|
1866
|
+
// Global event listeners - supports direct, delegated, and target-bound forms
|
|
1747
1867
|
// $.on('keydown', handler) → direct listener on document
|
|
1748
1868
|
// $.on('click', '.btn', handler) → delegated via closest()
|
|
1749
1869
|
// $.on('scroll', window, handler) → direct listener on target
|
|
@@ -1753,7 +1873,7 @@ query.on = (event, selectorOrHandler, handler) => {
|
|
|
1753
1873
|
document.addEventListener(event, selectorOrHandler);
|
|
1754
1874
|
return;
|
|
1755
1875
|
}
|
|
1756
|
-
// EventTarget (window, element, etc.)
|
|
1876
|
+
// EventTarget (window, element, etc.) - direct listener on target
|
|
1757
1877
|
if (typeof selectorOrHandler === 'object' && typeof selectorOrHandler.addEventListener === 'function') {
|
|
1758
1878
|
selectorOrHandler.addEventListener(event, handler);
|
|
1759
1879
|
return;
|
|
@@ -1776,7 +1896,7 @@ query.fn = ZQueryCollection.prototype;
|
|
|
1776
1896
|
|
|
1777
1897
|
// --- src/expression.js -------------------------------------------
|
|
1778
1898
|
/**
|
|
1779
|
-
* zQuery Expression Parser
|
|
1899
|
+
* zQuery Expression Parser - CSP-safe expression evaluator
|
|
1780
1900
|
*
|
|
1781
1901
|
* Replaces `new Function()` / `eval()` with a hand-written parser that
|
|
1782
1902
|
* evaluates expressions safely without violating Content Security Policy.
|
|
@@ -1956,7 +2076,7 @@ function tokenize(expr) {
|
|
|
1956
2076
|
i++; continue;
|
|
1957
2077
|
}
|
|
1958
2078
|
|
|
1959
|
-
// Unknown
|
|
2079
|
+
// Unknown - skip
|
|
1960
2080
|
i++;
|
|
1961
2081
|
}
|
|
1962
2082
|
|
|
@@ -1965,7 +2085,7 @@ function tokenize(expr) {
|
|
|
1965
2085
|
}
|
|
1966
2086
|
|
|
1967
2087
|
// ---------------------------------------------------------------------------
|
|
1968
|
-
// Parser
|
|
2088
|
+
// Parser - Pratt (precedence climbing)
|
|
1969
2089
|
// ---------------------------------------------------------------------------
|
|
1970
2090
|
class Parser {
|
|
1971
2091
|
constructor(tokens, scope) {
|
|
@@ -2197,7 +2317,7 @@ class Parser {
|
|
|
2197
2317
|
let couldBeArrow = true;
|
|
2198
2318
|
|
|
2199
2319
|
if (this.peek().t === T.PUNC && this.peek().v === ')') {
|
|
2200
|
-
// () => ...
|
|
2320
|
+
// () => ... - no params
|
|
2201
2321
|
} else {
|
|
2202
2322
|
while (couldBeArrow) {
|
|
2203
2323
|
const p = this.peek();
|
|
@@ -2223,7 +2343,7 @@ class Parser {
|
|
|
2223
2343
|
}
|
|
2224
2344
|
}
|
|
2225
2345
|
|
|
2226
|
-
// Not an arrow
|
|
2346
|
+
// Not an arrow - restore and parse as grouping
|
|
2227
2347
|
this.pos = savedPos;
|
|
2228
2348
|
this.next(); // consume (
|
|
2229
2349
|
const expr = this.parseExpression(0);
|
|
@@ -2316,14 +2436,14 @@ class Parser {
|
|
|
2316
2436
|
return { type: 'ident', name: tok.v };
|
|
2317
2437
|
}
|
|
2318
2438
|
|
|
2319
|
-
// Fallback
|
|
2439
|
+
// Fallback - return undefined for unparseable
|
|
2320
2440
|
this.next();
|
|
2321
2441
|
return { type: 'literal', value: undefined };
|
|
2322
2442
|
}
|
|
2323
2443
|
}
|
|
2324
2444
|
|
|
2325
2445
|
// ---------------------------------------------------------------------------
|
|
2326
|
-
// Evaluator
|
|
2446
|
+
// Evaluator - walks the AST, resolves against scope
|
|
2327
2447
|
// ---------------------------------------------------------------------------
|
|
2328
2448
|
|
|
2329
2449
|
/** Safe property access whitelist for built-in prototypes */
|
|
@@ -2412,8 +2532,6 @@ function evaluate(node, scope) {
|
|
|
2412
2532
|
if (name === 'console') return console;
|
|
2413
2533
|
if (name === 'Map') return Map;
|
|
2414
2534
|
if (name === 'Set') return Set;
|
|
2415
|
-
if (name === 'RegExp') return RegExp;
|
|
2416
|
-
if (name === 'Error') return Error;
|
|
2417
2535
|
if (name === 'URL') return URL;
|
|
2418
2536
|
if (name === 'URLSearchParams') return URLSearchParams;
|
|
2419
2537
|
return undefined;
|
|
@@ -2456,7 +2574,7 @@ function evaluate(node, scope) {
|
|
|
2456
2574
|
case 'optional_call': {
|
|
2457
2575
|
const calleeNode = node.callee;
|
|
2458
2576
|
const args = _evalArgs(node.args, scope);
|
|
2459
|
-
// Method call: obj?.method()
|
|
2577
|
+
// Method call: obj?.method() - bind `this` to obj
|
|
2460
2578
|
if (calleeNode.type === 'member' || calleeNode.type === 'optional_member') {
|
|
2461
2579
|
const obj = evaluate(calleeNode.obj, scope);
|
|
2462
2580
|
if (obj == null) return undefined;
|
|
@@ -2475,9 +2593,9 @@ function evaluate(node, scope) {
|
|
|
2475
2593
|
case 'new': {
|
|
2476
2594
|
const Ctor = evaluate(node.callee, scope);
|
|
2477
2595
|
if (typeof Ctor !== 'function') return undefined;
|
|
2478
|
-
// Only allow safe constructors
|
|
2596
|
+
// Only allow safe constructors (no RegExp - ReDoS risk, no Error - info leak)
|
|
2479
2597
|
if (Ctor === Date || Ctor === Array || Ctor === Map || Ctor === Set ||
|
|
2480
|
-
Ctor ===
|
|
2598
|
+
Ctor === URL || Ctor === URLSearchParams) {
|
|
2481
2599
|
const args = _evalArgs(node.args, scope);
|
|
2482
2600
|
return new Ctor(...args);
|
|
2483
2601
|
}
|
|
@@ -2579,7 +2697,7 @@ function _resolveCall(node, scope) {
|
|
|
2579
2697
|
const callee = node.callee;
|
|
2580
2698
|
const args = _evalArgs(node.args, scope);
|
|
2581
2699
|
|
|
2582
|
-
// Method call: obj.method()
|
|
2700
|
+
// Method call: obj.method() - bind `this` to obj
|
|
2583
2701
|
if (callee.type === 'member' || callee.type === 'optional_member') {
|
|
2584
2702
|
const obj = evaluate(callee.obj, scope);
|
|
2585
2703
|
if (obj == null) return undefined;
|
|
@@ -2645,13 +2763,13 @@ function _evalBinary(node, scope) {
|
|
|
2645
2763
|
/**
|
|
2646
2764
|
* Safely evaluate a JS expression string against scope layers.
|
|
2647
2765
|
*
|
|
2648
|
-
* @param {string} expr
|
|
2649
|
-
* @param {object[]} scope
|
|
2766
|
+
* @param {string} expr - expression string
|
|
2767
|
+
* @param {object[]} scope - array of scope objects, checked in order
|
|
2650
2768
|
* Typical: [loopVars, state, { props, refs, $ }]
|
|
2651
|
-
* @returns {*}
|
|
2769
|
+
* @returns {*} - evaluation result, or undefined on error
|
|
2652
2770
|
*/
|
|
2653
2771
|
|
|
2654
|
-
// AST cache (LRU)
|
|
2772
|
+
// AST cache (LRU) - avoids re-tokenizing and re-parsing the same expression.
|
|
2655
2773
|
// Uses Map insertion-order: on hit, delete + re-set moves entry to the end.
|
|
2656
2774
|
// Eviction removes the least-recently-used (first) entry when at capacity.
|
|
2657
2775
|
const _astCache = new Map();
|
|
@@ -2702,7 +2820,7 @@ function safeEval(expr, scope) {
|
|
|
2702
2820
|
|
|
2703
2821
|
// --- src/component.js --------------------------------------------
|
|
2704
2822
|
/**
|
|
2705
|
-
* zQuery Component
|
|
2823
|
+
* zQuery Component - Lightweight reactive component system
|
|
2706
2824
|
*
|
|
2707
2825
|
* Declarative components using template literals with directive support.
|
|
2708
2826
|
* Proxy-based state triggers targeted re-renders via event delegation.
|
|
@@ -2718,7 +2836,7 @@ function safeEval(expr, scope) {
|
|
|
2718
2836
|
* - Scoped styles (inline or via styleUrl)
|
|
2719
2837
|
* - External templates via templateUrl (with {{expression}} interpolation)
|
|
2720
2838
|
* - External styles via styleUrl (fetched & scoped automatically)
|
|
2721
|
-
* - Relative path resolution
|
|
2839
|
+
* - Relative path resolution - templateUrl and styleUrl
|
|
2722
2840
|
* resolve relative to the component file automatically
|
|
2723
2841
|
*/
|
|
2724
2842
|
|
|
@@ -2726,6 +2844,7 @@ function safeEval(expr, scope) {
|
|
|
2726
2844
|
|
|
2727
2845
|
|
|
2728
2846
|
|
|
2847
|
+
|
|
2729
2848
|
// ---------------------------------------------------------------------------
|
|
2730
2849
|
// Component registry & external resource cache
|
|
2731
2850
|
// ---------------------------------------------------------------------------
|
|
@@ -2750,7 +2869,7 @@ const _throttleTimers = new WeakMap();
|
|
|
2750
2869
|
|
|
2751
2870
|
/**
|
|
2752
2871
|
* Fetch and cache a text resource (HTML template or CSS file).
|
|
2753
|
-
* @param {string} url
|
|
2872
|
+
* @param {string} url - URL to fetch
|
|
2754
2873
|
* @returns {Promise<string>}
|
|
2755
2874
|
*/
|
|
2756
2875
|
function _fetchResource(url) {
|
|
@@ -2794,23 +2913,23 @@ function _fetchResource(url) {
|
|
|
2794
2913
|
* - If `base` is an absolute URL (http/https/file), resolve directly.
|
|
2795
2914
|
* - If `base` is a relative path string, resolve it against the page root
|
|
2796
2915
|
* (or <base href>) first, then resolve `url` against that.
|
|
2797
|
-
* - If `base` is falsy, return `url` unchanged
|
|
2916
|
+
* - If `base` is falsy, return `url` unchanged - _fetchResource's own
|
|
2798
2917
|
* fallback (page root / <base href>) handles it.
|
|
2799
2918
|
*
|
|
2800
|
-
* @param {string} url
|
|
2801
|
-
* @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
|
|
2802
2921
|
* @returns {string}
|
|
2803
2922
|
*/
|
|
2804
2923
|
function _resolveUrl(url, base) {
|
|
2805
2924
|
if (!base || !url || typeof url !== 'string') return url;
|
|
2806
|
-
// Already absolute
|
|
2925
|
+
// Already absolute - nothing to do
|
|
2807
2926
|
if (url.startsWith('/') || url.includes('://') || url.startsWith('//')) return url;
|
|
2808
2927
|
try {
|
|
2809
2928
|
if (base.includes('://')) {
|
|
2810
2929
|
// Absolute base (auto-detected module URL)
|
|
2811
2930
|
return new URL(url, base).href;
|
|
2812
2931
|
}
|
|
2813
|
-
// Relative base string
|
|
2932
|
+
// Relative base string - resolve against page root first
|
|
2814
2933
|
const baseEl = document.querySelector('base');
|
|
2815
2934
|
const root = baseEl ? baseEl.href : (window.location.origin + '/');
|
|
2816
2935
|
const absBase = new URL(base.endsWith('/') ? base : base + '/', root).href;
|
|
@@ -2842,13 +2961,13 @@ function _detectCallerBase() {
|
|
|
2842
2961
|
for (const raw of urls) {
|
|
2843
2962
|
// Strip line:col suffixes e.g. ":3:5" or ":12:1"
|
|
2844
2963
|
const url = raw.replace(/:\d+:\d+$/, '').replace(/:\d+$/, '');
|
|
2845
|
-
// Skip the zQuery library itself
|
|
2964
|
+
// Skip the zQuery library itself - by filename pattern and captured URL
|
|
2846
2965
|
if (/zquery(\.min)?\.js$/i.test(url)) continue;
|
|
2847
2966
|
if (_ownScriptUrl && url.replace(/[?#].*$/, '') === _ownScriptUrl) continue;
|
|
2848
2967
|
// Return directory (strip filename, keep trailing slash)
|
|
2849
2968
|
return url.replace(/\/[^/]*$/, '/');
|
|
2850
2969
|
}
|
|
2851
|
-
} catch { /* stack parsing unsupported
|
|
2970
|
+
} catch { /* stack parsing unsupported - fall back silently */ }
|
|
2852
2971
|
return undefined;
|
|
2853
2972
|
}
|
|
2854
2973
|
|
|
@@ -2927,7 +3046,7 @@ class Component {
|
|
|
2927
3046
|
}
|
|
2928
3047
|
});
|
|
2929
3048
|
|
|
2930
|
-
// Computed properties
|
|
3049
|
+
// Computed properties - lazy getters derived from state
|
|
2931
3050
|
this.computed = {};
|
|
2932
3051
|
if (definition.computed) {
|
|
2933
3052
|
for (const [name, fn] of Object.entries(definition.computed)) {
|
|
@@ -3056,7 +3175,7 @@ class Component {
|
|
|
3056
3175
|
this._loadExternals().then(() => {
|
|
3057
3176
|
if (!this._destroyed) this._render();
|
|
3058
3177
|
});
|
|
3059
|
-
return; // Skip this render
|
|
3178
|
+
return; // Skip this render - will re-render after load
|
|
3060
3179
|
}
|
|
3061
3180
|
|
|
3062
3181
|
// Expose multi-template map on instance (if available)
|
|
@@ -3081,7 +3200,7 @@ class Component {
|
|
|
3081
3200
|
this.state.__raw || this.state,
|
|
3082
3201
|
{ props: this.props, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
|
|
3083
3202
|
]);
|
|
3084
|
-
return result != null ? result : '';
|
|
3203
|
+
return result != null ? escapeHtml(String(result)) : '';
|
|
3085
3204
|
} catch { return ''; }
|
|
3086
3205
|
});
|
|
3087
3206
|
} else {
|
|
@@ -3127,13 +3246,13 @@ class Component {
|
|
|
3127
3246
|
const trimmed = selector.trim();
|
|
3128
3247
|
// Don't scope @-rules themselves
|
|
3129
3248
|
if (trimmed.startsWith('@')) {
|
|
3130
|
-
// @keyframes and @font-face contain non-selector content
|
|
3249
|
+
// @keyframes and @font-face contain non-selector content - skip scoping inside them
|
|
3131
3250
|
if (/^@(keyframes|font-face)\b/.test(trimmed)) {
|
|
3132
3251
|
noScopeDepth = braceDepth;
|
|
3133
3252
|
}
|
|
3134
3253
|
return match;
|
|
3135
3254
|
}
|
|
3136
|
-
// Inside @keyframes or @font-face
|
|
3255
|
+
// Inside @keyframes or @font-face - don't scope inner rules
|
|
3137
3256
|
if (noScopeDepth > 0 && braceDepth > noScopeDepth) {
|
|
3138
3257
|
return match;
|
|
3139
3258
|
}
|
|
@@ -3186,7 +3305,7 @@ class Component {
|
|
|
3186
3305
|
}
|
|
3187
3306
|
}
|
|
3188
3307
|
|
|
3189
|
-
// Update DOM via morphing (diffing)
|
|
3308
|
+
// Update DOM via morphing (diffing) - preserves unchanged nodes
|
|
3190
3309
|
// First render uses innerHTML for speed; subsequent renders morph.
|
|
3191
3310
|
const _renderStart = typeof window !== 'undefined' && (window.__zqMorphHook || window.__zqRenderHook) ? performance.now() : 0;
|
|
3192
3311
|
if (!this._mounted) {
|
|
@@ -3205,8 +3324,8 @@ class Component {
|
|
|
3205
3324
|
this._bindModels();
|
|
3206
3325
|
|
|
3207
3326
|
// Restore focus if the morph replaced the focused element.
|
|
3208
|
-
// Always restore selectionRange
|
|
3209
|
-
// the activeElement
|
|
3327
|
+
// Always restore selectionRange - even when the element is still
|
|
3328
|
+
// the activeElement - because _bindModels or morph attribute syncing
|
|
3210
3329
|
// can alter the value and move the cursor.
|
|
3211
3330
|
if (_focusInfo) {
|
|
3212
3331
|
const el = this._el.querySelector(_focusInfo.selector);
|
|
@@ -3242,7 +3361,7 @@ class Component {
|
|
|
3242
3361
|
// Optimization: on the FIRST render, we scan for event attributes, build
|
|
3243
3362
|
// a delegated handler map, and attach one listener per event type to the
|
|
3244
3363
|
// component root. On subsequent renders (re-bind), we only rebuild the
|
|
3245
|
-
// internal binding map
|
|
3364
|
+
// internal binding map - existing DOM listeners are reused since they
|
|
3246
3365
|
// delegate to event.target.closest(selector) at fire time.
|
|
3247
3366
|
_bindEvents() {
|
|
3248
3367
|
// Always rebuild the binding map from current DOM
|
|
@@ -3283,11 +3402,11 @@ class Component {
|
|
|
3283
3402
|
// Store binding map for the delegated handlers to reference
|
|
3284
3403
|
this._eventBindings = eventMap;
|
|
3285
3404
|
|
|
3286
|
-
// Only attach DOM listeners once
|
|
3405
|
+
// Only attach DOM listeners once - reuse on subsequent renders.
|
|
3287
3406
|
// The handlers close over `this` and read `this._eventBindings`
|
|
3288
3407
|
// at fire time, so they always use the latest binding map.
|
|
3289
3408
|
if (this._delegatedEvents) {
|
|
3290
|
-
// Already attached
|
|
3409
|
+
// Already attached - just make sure new event types are covered
|
|
3291
3410
|
for (const event of eventMap.keys()) {
|
|
3292
3411
|
if (!this._delegatedEvents.has(event)) {
|
|
3293
3412
|
this._attachDelegatedEvent(event, eventMap.get(event));
|
|
@@ -3313,7 +3432,7 @@ class Component {
|
|
|
3313
3432
|
this._attachDelegatedEvent(event, bindings);
|
|
3314
3433
|
}
|
|
3315
3434
|
|
|
3316
|
-
// .outside
|
|
3435
|
+
// .outside - attach a document-level listener for bindings that need
|
|
3317
3436
|
// to detect clicks/events outside their element.
|
|
3318
3437
|
this._outsideListeners = this._outsideListeners || [];
|
|
3319
3438
|
for (const [event, bindings] of eventMap) {
|
|
@@ -3341,7 +3460,7 @@ class Component {
|
|
|
3341
3460
|
: false;
|
|
3342
3461
|
|
|
3343
3462
|
const handler = (e) => {
|
|
3344
|
-
// Read bindings from live map
|
|
3463
|
+
// Read bindings from live map - always up to date after re-renders
|
|
3345
3464
|
const currentBindings = this._eventBindings?.get(event) || [];
|
|
3346
3465
|
|
|
3347
3466
|
// Collect matching bindings with their matched elements, then sort
|
|
@@ -3362,7 +3481,7 @@ class Component {
|
|
|
3362
3481
|
for (const { selector, methodExpr, modifiers, el, matched } of hits) {
|
|
3363
3482
|
|
|
3364
3483
|
// In delegated events, .stop should prevent ancestor bindings from
|
|
3365
|
-
// firing
|
|
3484
|
+
// firing - stopPropagation alone only stops real DOM bubbling.
|
|
3366
3485
|
if (stoppedAt) {
|
|
3367
3486
|
let blocked = false;
|
|
3368
3487
|
for (const stopped of stoppedAt) {
|
|
@@ -3371,15 +3490,15 @@ class Component {
|
|
|
3371
3490
|
if (blocked) continue;
|
|
3372
3491
|
}
|
|
3373
3492
|
|
|
3374
|
-
// .self
|
|
3493
|
+
// .self - only fire if target is the element itself
|
|
3375
3494
|
if (modifiers.includes('self') && e.target !== el) continue;
|
|
3376
3495
|
|
|
3377
|
-
// .outside
|
|
3496
|
+
// .outside - only fire if event target is OUTSIDE the element
|
|
3378
3497
|
if (modifiers.includes('outside')) {
|
|
3379
3498
|
if (el.contains(e.target)) continue;
|
|
3380
3499
|
}
|
|
3381
3500
|
|
|
3382
|
-
// Key modifiers
|
|
3501
|
+
// Key modifiers - filter keyboard events by key
|
|
3383
3502
|
const _keyMap = { enter: 'Enter', escape: 'Escape', tab: 'Tab', space: ' ', delete: 'Delete|Backspace', up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight' };
|
|
3384
3503
|
let keyFiltered = false;
|
|
3385
3504
|
for (const mod of modifiers) {
|
|
@@ -3390,7 +3509,7 @@ class Component {
|
|
|
3390
3509
|
}
|
|
3391
3510
|
if (keyFiltered) continue;
|
|
3392
3511
|
|
|
3393
|
-
// System key modifiers
|
|
3512
|
+
// System key modifiers - require modifier keys to be held
|
|
3394
3513
|
if (modifiers.includes('ctrl') && !e.ctrlKey) continue;
|
|
3395
3514
|
if (modifiers.includes('shift') && !e.shiftKey) continue;
|
|
3396
3515
|
if (modifiers.includes('alt') && !e.altKey) continue;
|
|
@@ -3430,7 +3549,7 @@ class Component {
|
|
|
3430
3549
|
}
|
|
3431
3550
|
};
|
|
3432
3551
|
|
|
3433
|
-
// .debounce.{ms}
|
|
3552
|
+
// .debounce.{ms} - delay invocation until idle
|
|
3434
3553
|
const debounceIdx = modifiers.indexOf('debounce');
|
|
3435
3554
|
if (debounceIdx !== -1) {
|
|
3436
3555
|
const ms = parseInt(modifiers[debounceIdx + 1], 10) || 250;
|
|
@@ -3441,7 +3560,7 @@ class Component {
|
|
|
3441
3560
|
continue;
|
|
3442
3561
|
}
|
|
3443
3562
|
|
|
3444
|
-
// .throttle.{ms}
|
|
3563
|
+
// .throttle.{ms} - fire at most once per interval
|
|
3445
3564
|
const throttleIdx = modifiers.indexOf('throttle');
|
|
3446
3565
|
if (throttleIdx !== -1) {
|
|
3447
3566
|
const ms = parseInt(modifiers[throttleIdx + 1], 10) || 250;
|
|
@@ -3453,7 +3572,7 @@ class Component {
|
|
|
3453
3572
|
continue;
|
|
3454
3573
|
}
|
|
3455
3574
|
|
|
3456
|
-
// .once
|
|
3575
|
+
// .once - fire once then ignore
|
|
3457
3576
|
if (modifiers.includes('once')) {
|
|
3458
3577
|
if (el.dataset.zqOnce === event) continue;
|
|
3459
3578
|
el.dataset.zqOnce = event;
|
|
@@ -3481,12 +3600,12 @@ class Component {
|
|
|
3481
3600
|
// textarea, select (single & multiple), contenteditable
|
|
3482
3601
|
// Nested state keys: z-model="user.name" → this.state.user.name
|
|
3483
3602
|
// Modifiers (boolean attributes on the same element):
|
|
3484
|
-
// z-lazy
|
|
3485
|
-
// z-trim
|
|
3486
|
-
// z-number
|
|
3487
|
-
// z-debounce
|
|
3488
|
-
// z-uppercase
|
|
3489
|
-
// 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
|
|
3490
3609
|
//
|
|
3491
3610
|
// Writes to reactive state so the rest of the UI stays in sync.
|
|
3492
3611
|
// Focus and cursor position are preserved in _render() via focusInfo.
|
|
@@ -3568,7 +3687,7 @@ class Component {
|
|
|
3568
3687
|
}
|
|
3569
3688
|
|
|
3570
3689
|
// ---------------------------------------------------------------------------
|
|
3571
|
-
// Expression evaluator
|
|
3690
|
+
// Expression evaluator - CSP-safe parser (no eval / new Function)
|
|
3572
3691
|
// ---------------------------------------------------------------------------
|
|
3573
3692
|
_evalExpr(expr) {
|
|
3574
3693
|
return safeEval(expr, [
|
|
@@ -3578,7 +3697,7 @@ class Component {
|
|
|
3578
3697
|
}
|
|
3579
3698
|
|
|
3580
3699
|
// ---------------------------------------------------------------------------
|
|
3581
|
-
// z-for
|
|
3700
|
+
// z-for - Expand list-rendering directives (pre-innerHTML, string level)
|
|
3582
3701
|
//
|
|
3583
3702
|
// <li z-for="item in items">{{item.name}}</li>
|
|
3584
3703
|
// <li z-for="(item, i) in items">{{i}}: {{item.name}}</li>
|
|
@@ -3644,7 +3763,7 @@ class Component {
|
|
|
3644
3763
|
this.state.__raw || this.state,
|
|
3645
3764
|
{ props: this.props, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
|
|
3646
3765
|
]);
|
|
3647
|
-
return result != null ? result : '';
|
|
3766
|
+
return result != null ? escapeHtml(String(result)) : '';
|
|
3648
3767
|
} catch { return ''; }
|
|
3649
3768
|
});
|
|
3650
3769
|
|
|
@@ -3667,7 +3786,7 @@ class Component {
|
|
|
3667
3786
|
}
|
|
3668
3787
|
|
|
3669
3788
|
// ---------------------------------------------------------------------------
|
|
3670
|
-
// _expandContentDirectives
|
|
3789
|
+
// _expandContentDirectives - Pre-morph z-html & z-text expansion
|
|
3671
3790
|
//
|
|
3672
3791
|
// Evaluates z-html and z-text directives at the string level so the morph
|
|
3673
3792
|
// engine receives HTML with the actual content inline. This lets the diff
|
|
@@ -3702,7 +3821,7 @@ class Component {
|
|
|
3702
3821
|
}
|
|
3703
3822
|
|
|
3704
3823
|
// ---------------------------------------------------------------------------
|
|
3705
|
-
// _processDirectives
|
|
3824
|
+
// _processDirectives - Post-innerHTML DOM-level directive processing
|
|
3706
3825
|
// ---------------------------------------------------------------------------
|
|
3707
3826
|
_processDirectives() {
|
|
3708
3827
|
// z-pre: skip all directive processing on subtrees
|
|
@@ -3753,7 +3872,7 @@ class Component {
|
|
|
3753
3872
|
});
|
|
3754
3873
|
|
|
3755
3874
|
// -- z-bind:attr / :attr (dynamic attribute binding) -----------
|
|
3756
|
-
// Use TreeWalker instead of querySelectorAll('*')
|
|
3875
|
+
// Use TreeWalker instead of querySelectorAll('*') - avoids
|
|
3757
3876
|
// creating a flat array of every single descendant element.
|
|
3758
3877
|
// TreeWalker visits nodes lazily; FILTER_REJECT skips z-pre subtrees
|
|
3759
3878
|
// at the walker level (faster than per-node closest('[z-pre]') checks).
|
|
@@ -3891,8 +4010,8 @@ const _reservedKeys = new Set([
|
|
|
3891
4010
|
|
|
3892
4011
|
/**
|
|
3893
4012
|
* Register a component
|
|
3894
|
-
* @param {string} name
|
|
3895
|
-
* @param {object} definition
|
|
4013
|
+
* @param {string} name - tag name (must contain a hyphen, e.g. 'app-counter')
|
|
4014
|
+
* @param {object} definition - component definition
|
|
3896
4015
|
*/
|
|
3897
4016
|
function component(name, definition) {
|
|
3898
4017
|
if (!name || typeof name !== 'string') {
|
|
@@ -3917,9 +4036,9 @@ function component(name, definition) {
|
|
|
3917
4036
|
|
|
3918
4037
|
/**
|
|
3919
4038
|
* Mount a component into a target element
|
|
3920
|
-
* @param {string|Element} target
|
|
3921
|
-
* @param {string} componentName
|
|
3922
|
-
* @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
|
|
3923
4042
|
* @returns {Component}
|
|
3924
4043
|
*/
|
|
3925
4044
|
function mount(target, componentName, props = {}) {
|
|
@@ -3940,7 +4059,7 @@ function mount(target, componentName, props = {}) {
|
|
|
3940
4059
|
|
|
3941
4060
|
/**
|
|
3942
4061
|
* Scan a container for custom component tags and auto-mount them
|
|
3943
|
-
* @param {Element} root
|
|
4062
|
+
* @param {Element} root - root element to scan (default: document.body)
|
|
3944
4063
|
*/
|
|
3945
4064
|
function mountAll(root = document.body) {
|
|
3946
4065
|
for (const [name, def] of _registry) {
|
|
@@ -3965,7 +4084,7 @@ function mountAll(root = document.body) {
|
|
|
3965
4084
|
[...tag.attributes].forEach(attr => {
|
|
3966
4085
|
if (attr.name.startsWith('@') || attr.name.startsWith('z-')) return;
|
|
3967
4086
|
|
|
3968
|
-
// Dynamic prop: :propName="expression"
|
|
4087
|
+
// Dynamic prop: :propName="expression" - evaluate in parent context
|
|
3969
4088
|
if (attr.name.startsWith(':')) {
|
|
3970
4089
|
const propName = attr.name.slice(1);
|
|
3971
4090
|
if (parentInstance) {
|
|
@@ -3974,7 +4093,7 @@ function mountAll(root = document.body) {
|
|
|
3974
4093
|
{ props: parentInstance.props, refs: parentInstance.refs, computed: parentInstance.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
|
|
3975
4094
|
]);
|
|
3976
4095
|
} else {
|
|
3977
|
-
// No parent
|
|
4096
|
+
// No parent - try JSON parse
|
|
3978
4097
|
try { props[propName] = JSON.parse(attr.value); }
|
|
3979
4098
|
catch { props[propName] = attr.value; }
|
|
3980
4099
|
}
|
|
@@ -4023,8 +4142,8 @@ function getRegistry() {
|
|
|
4023
4142
|
/**
|
|
4024
4143
|
* Pre-load a component's external templates and styles so the next mount
|
|
4025
4144
|
* renders synchronously (no blank flash while fetching).
|
|
4026
|
-
* Safe to call multiple times
|
|
4027
|
-
* @param {string} name
|
|
4145
|
+
* Safe to call multiple times - skips if already loaded.
|
|
4146
|
+
* @param {string} name - registered component name
|
|
4028
4147
|
* @returns {Promise<void>}
|
|
4029
4148
|
*/
|
|
4030
4149
|
async function prefetch(name) {
|
|
@@ -4052,27 +4171,27 @@ const _globalStyles = new Map(); // url → <link> element
|
|
|
4052
4171
|
*
|
|
4053
4172
|
* $.style('app.css') // critical by default
|
|
4054
4173
|
* $.style(['app.css', 'theme.css']) // multiple files
|
|
4055
|
-
* $.style('/assets/global.css') // absolute
|
|
4174
|
+
* $.style('/assets/global.css') // absolute - used as-is
|
|
4056
4175
|
* $.style('app.css', { critical: false }) // opt out of FOUC prevention
|
|
4057
4176
|
*
|
|
4058
4177
|
* Options:
|
|
4059
|
-
* critical
|
|
4178
|
+
* critical - (boolean, default true) When true, zQuery injects a tiny
|
|
4060
4179
|
* inline style that hides the page (`visibility: hidden`) and
|
|
4061
4180
|
* removes it once the stylesheet has loaded. This prevents
|
|
4062
|
-
* FOUC (Flash of Unstyled Content) entirely
|
|
4181
|
+
* FOUC (Flash of Unstyled Content) entirely - no special
|
|
4063
4182
|
* markup needed in the HTML file. Set to false to load
|
|
4064
4183
|
* the stylesheet without blocking paint.
|
|
4065
|
-
* bg
|
|
4184
|
+
* bg - (string, default '#0d1117') Background color applied while
|
|
4066
4185
|
* the page is hidden during critical load. Prevents a white
|
|
4067
4186
|
* flash on dark-themed apps. Only used when critical is true.
|
|
4068
4187
|
*
|
|
4069
4188
|
* Duplicate URLs are ignored (idempotent).
|
|
4070
4189
|
*
|
|
4071
|
-
* @param {string|string[]} urls
|
|
4072
|
-
* @param {object} [opts]
|
|
4073
|
-
* @param {boolean} [opts.critical=true]
|
|
4074
|
-
* @param {string} [opts.bg]
|
|
4075
|
-
* @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
|
|
4076
4195
|
*/
|
|
4077
4196
|
function style(urls, opts = {}) {
|
|
4078
4197
|
const callerBase = _detectCallerBase();
|
|
@@ -4081,7 +4200,7 @@ function style(urls, opts = {}) {
|
|
|
4081
4200
|
const loadPromises = [];
|
|
4082
4201
|
|
|
4083
4202
|
// Critical mode (default: true): inject a tiny inline <style> that hides the
|
|
4084
|
-
// page and sets a background color. Fully self-contained
|
|
4203
|
+
// page and sets a background color. Fully self-contained - no markup needed
|
|
4085
4204
|
// in the HTML file. The style is removed once the sheet loads.
|
|
4086
4205
|
let _criticalStyle = null;
|
|
4087
4206
|
if (opts.critical !== false) {
|
|
@@ -4141,16 +4260,15 @@ function style(urls, opts = {}) {
|
|
|
4141
4260
|
|
|
4142
4261
|
// --- src/router.js -----------------------------------------------
|
|
4143
4262
|
/**
|
|
4144
|
-
* zQuery Router
|
|
4263
|
+
* zQuery Router - Client-side SPA router
|
|
4145
4264
|
*
|
|
4146
4265
|
* Supports hash mode (#/path) and history mode (/path).
|
|
4147
4266
|
* Route params, query strings, navigation guards, and lazy loading.
|
|
4148
4267
|
* Sub-route history substates for in-page UI changes (modals, tabs, etc.).
|
|
4149
4268
|
*
|
|
4150
4269
|
* Usage:
|
|
4270
|
+
* // HTML: <z-outlet></z-outlet>
|
|
4151
4271
|
* $.router({
|
|
4152
|
-
* el: '#app',
|
|
4153
|
-
* mode: 'hash',
|
|
4154
4272
|
* routes: [
|
|
4155
4273
|
* { path: '/', component: 'home-page' },
|
|
4156
4274
|
* { path: '/user/:id', component: 'user-profile' },
|
|
@@ -4185,7 +4303,7 @@ function _shallowEqual(a, b) {
|
|
|
4185
4303
|
class Router {
|
|
4186
4304
|
constructor(config = {}) {
|
|
4187
4305
|
this._el = null;
|
|
4188
|
-
// file:// protocol can't use pushState
|
|
4306
|
+
// file:// protocol can't use pushState - always force hash mode
|
|
4189
4307
|
const isFile = typeof location !== 'undefined' && location.protocol === 'file:';
|
|
4190
4308
|
this._mode = isFile ? 'hash' : (config.mode || 'history');
|
|
4191
4309
|
|
|
@@ -4220,8 +4338,30 @@ class Router {
|
|
|
4220
4338
|
this._inSubstate = false; // true while substate entries are in the history stack
|
|
4221
4339
|
|
|
4222
4340
|
// Set outlet element
|
|
4341
|
+
// Priority: explicit config.el → <z-outlet> tag in the DOM
|
|
4223
4342
|
if (config.el) {
|
|
4224
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
|
+
}
|
|
4225
4365
|
}
|
|
4226
4366
|
|
|
4227
4367
|
// Register routes
|
|
@@ -4229,21 +4369,43 @@ class Router {
|
|
|
4229
4369
|
config.routes.forEach(r => this.add(r));
|
|
4230
4370
|
}
|
|
4231
4371
|
|
|
4232
|
-
// Listen for navigation
|
|
4372
|
+
// Listen for navigation - store handler references for cleanup in destroy()
|
|
4233
4373
|
if (this._mode === 'hash') {
|
|
4234
4374
|
this._onNavEvent = () => this._resolve();
|
|
4235
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);
|
|
4236
4392
|
} else {
|
|
4237
4393
|
this._onNavEvent = (e) => {
|
|
4238
|
-
// Check for substate pop first
|
|
4394
|
+
// Check for substate pop first - if a listener handles it, don't route
|
|
4239
4395
|
const st = e.state;
|
|
4240
4396
|
if (st && st[_ZQ_STATE_KEY] === 'substate') {
|
|
4241
4397
|
const handled = this._fireSubstate(st.key, st.data, 'pop');
|
|
4242
4398
|
if (handled) return;
|
|
4243
|
-
// Unhandled substate —
|
|
4244
|
-
//
|
|
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;
|
|
4245
4407
|
} else if (this._inSubstate) {
|
|
4246
|
-
// Popped past all substates
|
|
4408
|
+
// Popped past all substates - notify listeners to reset to defaults
|
|
4247
4409
|
this._inSubstate = false;
|
|
4248
4410
|
this._fireSubstate(null, null, 'reset');
|
|
4249
4411
|
}
|
|
@@ -4261,13 +4423,17 @@ class Router {
|
|
|
4261
4423
|
if (link.getAttribute('target') === '_blank') return;
|
|
4262
4424
|
e.preventDefault();
|
|
4263
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;
|
|
4264
4428
|
// Support z-link-params for dynamic :param interpolation
|
|
4265
4429
|
const paramsAttr = link.getAttribute('z-link-params');
|
|
4266
4430
|
if (paramsAttr) {
|
|
4267
4431
|
try {
|
|
4268
4432
|
const params = JSON.parse(paramsAttr);
|
|
4269
4433
|
href = this._interpolateParams(href, params);
|
|
4270
|
-
} catch {
|
|
4434
|
+
} catch (err) {
|
|
4435
|
+
reportError(ErrorCode.ROUTER_RESOLVE, 'Malformed JSON in z-link-params', { href, paramsAttr }, err);
|
|
4436
|
+
}
|
|
4271
4437
|
}
|
|
4272
4438
|
this.navigate(href);
|
|
4273
4439
|
// z-to-top modifier: scroll to top after navigation
|
|
@@ -4321,8 +4487,8 @@ class Router {
|
|
|
4321
4487
|
|
|
4322
4488
|
/**
|
|
4323
4489
|
* Interpolate :param placeholders in a path with the given values.
|
|
4324
|
-
* @param {string} path
|
|
4325
|
-
* @param {Object} params
|
|
4490
|
+
* @param {string} path - e.g. '/user/:id/posts/:pid'
|
|
4491
|
+
* @param {Object} params - e.g. { id: 42, pid: 7 }
|
|
4326
4492
|
* @returns {string}
|
|
4327
4493
|
*/
|
|
4328
4494
|
_interpolateParams(path, params) {
|
|
@@ -4366,7 +4532,7 @@ class Router {
|
|
|
4366
4532
|
const currentURL = (window.location.pathname || '/') + (window.location.hash || '');
|
|
4367
4533
|
|
|
4368
4534
|
if (targetURL === currentURL && !options.force) {
|
|
4369
|
-
// Same full URL (path + hash)
|
|
4535
|
+
// Same full URL (path + hash) - don't push duplicate entry.
|
|
4370
4536
|
// If only the hash changed to a fragment target, scroll to it.
|
|
4371
4537
|
if (fragment) {
|
|
4372
4538
|
const el = document.getElementById(fragment);
|
|
@@ -4375,7 +4541,7 @@ class Router {
|
|
|
4375
4541
|
return this;
|
|
4376
4542
|
}
|
|
4377
4543
|
|
|
4378
|
-
// Same route path but different hash fragment
|
|
4544
|
+
// Same route path but different hash fragment - use replaceState
|
|
4379
4545
|
// so back goes to the previous *route*, not the previous scroll position.
|
|
4380
4546
|
const targetPathOnly = this._base + normalized;
|
|
4381
4547
|
const currentPathOnly = window.location.pathname || '/';
|
|
@@ -4390,7 +4556,7 @@ class Router {
|
|
|
4390
4556
|
const el = document.getElementById(fragment);
|
|
4391
4557
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
4392
4558
|
}
|
|
4393
|
-
// Don't re-resolve
|
|
4559
|
+
// Don't re-resolve - same route, just a hash change
|
|
4394
4560
|
return this;
|
|
4395
4561
|
}
|
|
4396
4562
|
|
|
@@ -4426,8 +4592,8 @@ class Router {
|
|
|
4426
4592
|
|
|
4427
4593
|
/**
|
|
4428
4594
|
* Normalize an app-relative path and guard against double base-prefixing.
|
|
4429
|
-
* @param {string} path
|
|
4430
|
-
* @returns {string}
|
|
4595
|
+
* @param {string} path - e.g. '/docs', 'docs', or '/app/docs' when base is '/app'
|
|
4596
|
+
* @returns {string} - always starts with '/'
|
|
4431
4597
|
*/
|
|
4432
4598
|
_normalizePath(path) {
|
|
4433
4599
|
let p = path && path.startsWith('/') ? path : (path ? `/${path}` : '/');
|
|
@@ -4477,12 +4643,12 @@ class Router {
|
|
|
4477
4643
|
|
|
4478
4644
|
/**
|
|
4479
4645
|
* Push a lightweight history entry for in-component UI state.
|
|
4480
|
-
* The URL path does NOT change
|
|
4646
|
+
* The URL path does NOT change - only a history entry is added so the
|
|
4481
4647
|
* back button can undo the UI change (close modal, revert tab, etc.)
|
|
4482
4648
|
* before navigating away.
|
|
4483
4649
|
*
|
|
4484
|
-
* @param {string} key
|
|
4485
|
-
* @param {*} data
|
|
4650
|
+
* @param {string} key - identifier (e.g. 'modal', 'tab', 'panel')
|
|
4651
|
+
* @param {*} data - arbitrary state (serializable)
|
|
4486
4652
|
* @returns {Router}
|
|
4487
4653
|
*
|
|
4488
4654
|
* @example
|
|
@@ -4493,7 +4659,7 @@ class Router {
|
|
|
4493
4659
|
pushSubstate(key, data) {
|
|
4494
4660
|
this._inSubstate = true;
|
|
4495
4661
|
if (this._mode === 'hash') {
|
|
4496
|
-
// Hash mode: stash the substate in a global
|
|
4662
|
+
// Hash mode: stash the substate in a global - hashchange will check.
|
|
4497
4663
|
// We still push a history entry via a sentinel hash suffix.
|
|
4498
4664
|
const current = window.location.hash || '#/';
|
|
4499
4665
|
window.history.pushState(
|
|
@@ -4609,12 +4775,12 @@ class Router {
|
|
|
4609
4775
|
async __resolve() {
|
|
4610
4776
|
// Check if we're landing on a substate entry (e.g. page refresh on a
|
|
4611
4777
|
// substate bookmark, or hash-mode popstate). Fire listeners and bail
|
|
4612
|
-
// if handled
|
|
4778
|
+
// if handled - the URL hasn't changed so there's no route to resolve.
|
|
4613
4779
|
const histState = window.history.state;
|
|
4614
4780
|
if (histState && histState[_ZQ_STATE_KEY] === 'substate') {
|
|
4615
4781
|
const handled = this._fireSubstate(histState.key, histState.data, 'resolve');
|
|
4616
4782
|
if (handled) return;
|
|
4617
|
-
// No listener handled it
|
|
4783
|
+
// No listener handled it - fall through to normal routing
|
|
4618
4784
|
}
|
|
4619
4785
|
|
|
4620
4786
|
const fullPath = this.path;
|
|
@@ -4651,7 +4817,7 @@ class Router {
|
|
|
4651
4817
|
const sameParams = _shallowEqual(params, from.params);
|
|
4652
4818
|
const sameQuery = _shallowEqual(query, from.query);
|
|
4653
4819
|
if (sameParams && sameQuery) {
|
|
4654
|
-
// Identical navigation
|
|
4820
|
+
// Identical navigation - nothing to do
|
|
4655
4821
|
return;
|
|
4656
4822
|
}
|
|
4657
4823
|
}
|
|
@@ -4739,6 +4905,9 @@ class Router {
|
|
|
4739
4905
|
}
|
|
4740
4906
|
}
|
|
4741
4907
|
|
|
4908
|
+
// Update z-active-route elements
|
|
4909
|
+
this._updateActiveRoutes(path);
|
|
4910
|
+
|
|
4742
4911
|
// Run after guards
|
|
4743
4912
|
for (const guard of this._guards.after) {
|
|
4744
4913
|
await guard(to, from);
|
|
@@ -4748,6 +4917,32 @@ class Router {
|
|
|
4748
4917
|
this._listeners.forEach(fn => fn(to, from));
|
|
4749
4918
|
}
|
|
4750
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
|
+
|
|
4751
4946
|
// --- Destroy -------------------------------------------------------------
|
|
4752
4947
|
|
|
4753
4948
|
destroy() {
|
|
@@ -4756,6 +4951,10 @@ class Router {
|
|
|
4756
4951
|
window.removeEventListener(this._mode === 'hash' ? 'hashchange' : 'popstate', this._onNavEvent);
|
|
4757
4952
|
this._onNavEvent = null;
|
|
4758
4953
|
}
|
|
4954
|
+
if (this._onPopState) {
|
|
4955
|
+
window.removeEventListener('popstate', this._onPopState);
|
|
4956
|
+
this._onPopState = null;
|
|
4957
|
+
}
|
|
4759
4958
|
if (this._onLinkClick) {
|
|
4760
4959
|
document.removeEventListener('click', this._onLinkClick);
|
|
4761
4960
|
this._onLinkClick = null;
|
|
@@ -4786,7 +4985,7 @@ function getRouter() {
|
|
|
4786
4985
|
|
|
4787
4986
|
// --- src/store.js ------------------------------------------------
|
|
4788
4987
|
/**
|
|
4789
|
-
* zQuery Store
|
|
4988
|
+
* zQuery Store - Global reactive state management
|
|
4790
4989
|
*
|
|
4791
4990
|
* A lightweight Redux/Vuex-inspired store with:
|
|
4792
4991
|
* - Reactive state via Proxy
|
|
@@ -4824,22 +5023,22 @@ class Store {
|
|
|
4824
5023
|
this._history = []; // action log
|
|
4825
5024
|
this._maxHistory = config.maxHistory || 1000;
|
|
4826
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;
|
|
4827
5031
|
|
|
4828
|
-
//
|
|
5032
|
+
// Store initial state for reset
|
|
4829
5033
|
const initial = typeof config.state === 'function' ? config.state() : { ...(config.state || {}) };
|
|
5034
|
+
this._initialState = JSON.parse(JSON.stringify(initial));
|
|
4830
5035
|
|
|
4831
5036
|
this.state = reactive(initial, (key, value, old) => {
|
|
4832
|
-
|
|
4833
|
-
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
});
|
|
4838
|
-
// Notify wildcard subscribers
|
|
4839
|
-
this._wildcards.forEach(fn => {
|
|
4840
|
-
try { fn(key, value, old); }
|
|
4841
|
-
catch (err) { reportError(ErrorCode.STORE_SUBSCRIBE, 'Wildcard subscriber threw', { key }, err); }
|
|
4842
|
-
});
|
|
5037
|
+
if (this._batching) {
|
|
5038
|
+
this._batchQueue.push({ key, value, old });
|
|
5039
|
+
return;
|
|
5040
|
+
}
|
|
5041
|
+
this._notifySubscribers(key, value, old);
|
|
4843
5042
|
});
|
|
4844
5043
|
|
|
4845
5044
|
// Build getters as computed properties
|
|
@@ -4852,10 +5051,90 @@ class Store {
|
|
|
4852
5051
|
}
|
|
4853
5052
|
}
|
|
4854
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
|
+
|
|
4855
5134
|
/**
|
|
4856
5135
|
* Dispatch a named action
|
|
4857
|
-
* @param {string} name
|
|
4858
|
-
* @param {...any} args
|
|
5136
|
+
* @param {string} name - action name
|
|
5137
|
+
* @param {...any} args - payload
|
|
4859
5138
|
*/
|
|
4860
5139
|
dispatch(name, ...args) {
|
|
4861
5140
|
const action = this._actions[name];
|
|
@@ -4894,13 +5173,13 @@ class Store {
|
|
|
4894
5173
|
|
|
4895
5174
|
/**
|
|
4896
5175
|
* Subscribe to changes on a specific state key
|
|
4897
|
-
* @param {string|Function} keyOrFn
|
|
4898
|
-
* @param {Function} [fn]
|
|
4899
|
-
* @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
|
|
4900
5179
|
*/
|
|
4901
5180
|
subscribe(keyOrFn, fn) {
|
|
4902
5181
|
if (typeof keyOrFn === 'function') {
|
|
4903
|
-
// Wildcard
|
|
5182
|
+
// Wildcard - listen to all changes
|
|
4904
5183
|
this._wildcards.add(keyOrFn);
|
|
4905
5184
|
return () => this._wildcards.delete(keyOrFn);
|
|
4906
5185
|
}
|
|
@@ -4946,11 +5225,13 @@ class Store {
|
|
|
4946
5225
|
}
|
|
4947
5226
|
|
|
4948
5227
|
/**
|
|
4949
|
-
* Reset state to initial values
|
|
5228
|
+
* Reset state to initial values. If no argument, resets to the original state.
|
|
4950
5229
|
*/
|
|
4951
5230
|
reset(initialState) {
|
|
4952
|
-
this.replaceState(initialState);
|
|
5231
|
+
this.replaceState(initialState || JSON.parse(JSON.stringify(this._initialState)));
|
|
4953
5232
|
this._history = [];
|
|
5233
|
+
this._undoStack = [];
|
|
5234
|
+
this._redoStack = [];
|
|
4954
5235
|
}
|
|
4955
5236
|
}
|
|
4956
5237
|
|
|
@@ -4977,7 +5258,7 @@ function getStore(name = 'default') {
|
|
|
4977
5258
|
|
|
4978
5259
|
// --- src/http.js -------------------------------------------------
|
|
4979
5260
|
/**
|
|
4980
|
-
* zQuery HTTP
|
|
5261
|
+
* zQuery HTTP - Lightweight fetch wrapper
|
|
4981
5262
|
*
|
|
4982
5263
|
* Clean API for GET/POST/PUT/PATCH/DELETE with:
|
|
4983
5264
|
* - Auto JSON serialization/deserialization
|
|
@@ -5167,7 +5448,7 @@ const http = {
|
|
|
5167
5448
|
|
|
5168
5449
|
/**
|
|
5169
5450
|
* Add request interceptor
|
|
5170
|
-
* @param {Function} fn
|
|
5451
|
+
* @param {Function} fn - (fetchOpts, url) → void | false | { url, options }
|
|
5171
5452
|
* @returns {Function} unsubscribe function
|
|
5172
5453
|
*/
|
|
5173
5454
|
onRequest(fn) {
|
|
@@ -5180,7 +5461,7 @@ const http = {
|
|
|
5180
5461
|
|
|
5181
5462
|
/**
|
|
5182
5463
|
* Add response interceptor
|
|
5183
|
-
* @param {Function} fn
|
|
5464
|
+
* @param {Function} fn - (result) → void
|
|
5184
5465
|
* @returns {Function} unsubscribe function
|
|
5185
5466
|
*/
|
|
5186
5467
|
onResponse(fn) {
|
|
@@ -5192,7 +5473,7 @@ const http = {
|
|
|
5192
5473
|
},
|
|
5193
5474
|
|
|
5194
5475
|
/**
|
|
5195
|
-
* Clear interceptors
|
|
5476
|
+
* Clear interceptors - all, or just 'request' / 'response'
|
|
5196
5477
|
*/
|
|
5197
5478
|
clearInterceptors(type) {
|
|
5198
5479
|
if (!type || type === 'request') _interceptors.request.length = 0;
|
|
@@ -5221,7 +5502,7 @@ const http = {
|
|
|
5221
5502
|
|
|
5222
5503
|
// --- src/utils.js ------------------------------------------------
|
|
5223
5504
|
/**
|
|
5224
|
-
* zQuery Utils
|
|
5505
|
+
* zQuery Utils - Common utility functions
|
|
5225
5506
|
*
|
|
5226
5507
|
* Quality-of-life helpers that every frontend project needs.
|
|
5227
5508
|
* Attached to $ namespace for convenience.
|
|
@@ -5232,7 +5513,7 @@ const http = {
|
|
|
5232
5513
|
// ---------------------------------------------------------------------------
|
|
5233
5514
|
|
|
5234
5515
|
/**
|
|
5235
|
-
* Debounce
|
|
5516
|
+
* Debounce - delays execution until after `ms` of inactivity
|
|
5236
5517
|
*/
|
|
5237
5518
|
function debounce(fn, ms = 250) {
|
|
5238
5519
|
let timer;
|
|
@@ -5245,7 +5526,7 @@ function debounce(fn, ms = 250) {
|
|
|
5245
5526
|
}
|
|
5246
5527
|
|
|
5247
5528
|
/**
|
|
5248
|
-
* Throttle
|
|
5529
|
+
* Throttle - limits execution to once per `ms`
|
|
5249
5530
|
*/
|
|
5250
5531
|
function throttle(fn, ms = 250) {
|
|
5251
5532
|
let last = 0;
|
|
@@ -5264,14 +5545,14 @@ function throttle(fn, ms = 250) {
|
|
|
5264
5545
|
}
|
|
5265
5546
|
|
|
5266
5547
|
/**
|
|
5267
|
-
* Pipe
|
|
5548
|
+
* Pipe - compose functions left-to-right
|
|
5268
5549
|
*/
|
|
5269
5550
|
function pipe(...fns) {
|
|
5270
5551
|
return (input) => fns.reduce((val, fn) => fn(val), input);
|
|
5271
5552
|
}
|
|
5272
5553
|
|
|
5273
5554
|
/**
|
|
5274
|
-
* Once
|
|
5555
|
+
* Once - function that only runs once
|
|
5275
5556
|
*/
|
|
5276
5557
|
function once(fn) {
|
|
5277
5558
|
let called = false, result;
|
|
@@ -5282,7 +5563,7 @@ function once(fn) {
|
|
|
5282
5563
|
}
|
|
5283
5564
|
|
|
5284
5565
|
/**
|
|
5285
|
-
* Sleep
|
|
5566
|
+
* Sleep - promise-based delay
|
|
5286
5567
|
*/
|
|
5287
5568
|
function sleep(ms) {
|
|
5288
5569
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -5333,8 +5614,12 @@ function trust(htmlStr) {
|
|
|
5333
5614
|
* Generate UUID v4
|
|
5334
5615
|
*/
|
|
5335
5616
|
function uuid() {
|
|
5336
|
-
|
|
5337
|
-
|
|
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;
|
|
5338
5623
|
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
5339
5624
|
});
|
|
5340
5625
|
}
|
|
@@ -5362,13 +5647,50 @@ function kebabCase(str) {
|
|
|
5362
5647
|
// ---------------------------------------------------------------------------
|
|
5363
5648
|
|
|
5364
5649
|
/**
|
|
5365
|
-
* 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.
|
|
5366
5653
|
*/
|
|
5367
5654
|
function deepClone(obj) {
|
|
5368
5655
|
if (typeof structuredClone === 'function') return structuredClone(obj);
|
|
5369
|
-
|
|
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);
|
|
5370
5689
|
}
|
|
5371
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
|
+
|
|
5372
5694
|
/**
|
|
5373
5695
|
* Deep merge objects
|
|
5374
5696
|
*/
|
|
@@ -5378,6 +5700,7 @@ function deepMerge(target, ...sources) {
|
|
|
5378
5700
|
if (seen.has(src)) return tgt;
|
|
5379
5701
|
seen.add(src);
|
|
5380
5702
|
for (const key of Object.keys(src)) {
|
|
5703
|
+
if (_UNSAFE_KEYS.has(key)) continue;
|
|
5381
5704
|
if (src[key] && typeof src[key] === 'object' && !Array.isArray(src[key])) {
|
|
5382
5705
|
if (!tgt[key] || typeof tgt[key] !== 'object') tgt[key] = {};
|
|
5383
5706
|
merge(tgt[key], src[key]);
|
|
@@ -5584,10 +5907,13 @@ function setPath(obj, path, value) {
|
|
|
5584
5907
|
let cur = obj;
|
|
5585
5908
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
5586
5909
|
const k = keys[i];
|
|
5910
|
+
if (_UNSAFE_KEYS.has(k)) return obj;
|
|
5587
5911
|
if (cur[k] == null || typeof cur[k] !== 'object') cur[k] = {};
|
|
5588
5912
|
cur = cur[k];
|
|
5589
5913
|
}
|
|
5590
|
-
|
|
5914
|
+
const lastKey = keys[keys.length - 1];
|
|
5915
|
+
if (_UNSAFE_KEYS.has(lastKey)) return obj;
|
|
5916
|
+
cur[lastKey] = value;
|
|
5591
5917
|
return obj;
|
|
5592
5918
|
}
|
|
5593
5919
|
|
|
@@ -5638,9 +5964,16 @@ function memoize(fn, keyFnOrOpts) {
|
|
|
5638
5964
|
|
|
5639
5965
|
const memoized = (...args) => {
|
|
5640
5966
|
const key = keyFn ? keyFn(...args) : args[0];
|
|
5641
|
-
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
|
+
}
|
|
5642
5974
|
const result = fn(...args);
|
|
5643
5975
|
cache.set(key, result);
|
|
5976
|
+
// LRU eviction: drop the least-recently-used entry
|
|
5644
5977
|
if (maxSize > 0 && cache.size > maxSize) {
|
|
5645
5978
|
cache.delete(cache.keys().next().value);
|
|
5646
5979
|
}
|
|
@@ -5687,7 +6020,7 @@ function timeout(promise, ms, message) {
|
|
|
5687
6020
|
// --- index.js (assembly) ------------------------------------------
|
|
5688
6021
|
/**
|
|
5689
6022
|
* ┌---------------------------------------------------------┐
|
|
5690
|
-
* │ zQuery (zeroQuery)
|
|
6023
|
+
* │ zQuery (zeroQuery) - Lightweight Frontend Library │
|
|
5691
6024
|
* │ │
|
|
5692
6025
|
* │ jQuery-like selectors · Reactive components │
|
|
5693
6026
|
* │ SPA router · State management · Zero dependencies │
|
|
@@ -5707,11 +6040,11 @@ function timeout(promise, ms, message) {
|
|
|
5707
6040
|
|
|
5708
6041
|
|
|
5709
6042
|
// ---------------------------------------------------------------------------
|
|
5710
|
-
// $
|
|
6043
|
+
// $ - The main function & namespace
|
|
5711
6044
|
// ---------------------------------------------------------------------------
|
|
5712
6045
|
|
|
5713
6046
|
/**
|
|
5714
|
-
* Main selector function
|
|
6047
|
+
* Main selector function - always returns a ZQueryCollection (like jQuery).
|
|
5715
6048
|
*
|
|
5716
6049
|
* $('selector') → ZQueryCollection (querySelectorAll)
|
|
5717
6050
|
* $('<div>hello</div>') → ZQueryCollection from created elements
|
|
@@ -5774,6 +6107,8 @@ $.Signal = Signal;
|
|
|
5774
6107
|
$.signal = signal;
|
|
5775
6108
|
$.computed = computed;
|
|
5776
6109
|
$.effect = effect;
|
|
6110
|
+
$.batch = batch;
|
|
6111
|
+
$.untracked = untracked;
|
|
5777
6112
|
|
|
5778
6113
|
// --- Components ------------------------------------------------------------
|
|
5779
6114
|
$.component = component;
|
|
@@ -5849,12 +6184,14 @@ $.onError = onError;
|
|
|
5849
6184
|
$.ZQueryError = ZQueryError;
|
|
5850
6185
|
$.ErrorCode = ErrorCode;
|
|
5851
6186
|
$.guardCallback = guardCallback;
|
|
6187
|
+
$.guardAsync = guardAsync;
|
|
5852
6188
|
$.validate = validate;
|
|
6189
|
+
$.formatError = formatError;
|
|
5853
6190
|
|
|
5854
6191
|
// --- Meta ------------------------------------------------------------------
|
|
5855
|
-
$.version = '0.
|
|
5856
|
-
$.libSize = '~
|
|
5857
|
-
$.unitTests = {"passed":
|
|
6192
|
+
$.version = '1.0.0';
|
|
6193
|
+
$.libSize = '~106 KB';
|
|
6194
|
+
$.unitTests = {"passed":1882,"failed":0,"total":1882,"suites":508,"duration":3548,"ok":true};
|
|
5858
6195
|
$.meta = {}; // populated at build time by CLI bundler
|
|
5859
6196
|
|
|
5860
6197
|
$.noConflict = () => {
|