zero-query 0.5.2 → 0.6.3
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 +8 -6
- package/cli/commands/build.js +4 -2
- package/cli/commands/dev/index.js +82 -0
- package/cli/commands/dev/logger.js +70 -0
- package/cli/commands/dev/overlay.js +317 -0
- package/cli/commands/dev/server.js +129 -0
- package/cli/commands/dev/validator.js +94 -0
- package/cli/commands/dev/watcher.js +114 -0
- package/cli/scaffold/favicon.ico +0 -0
- package/cli/scaffold/scripts/components/about.js +14 -2
- package/cli/scaffold/scripts/components/contacts/contacts.html +5 -4
- package/cli/scaffold/scripts/components/contacts/contacts.js +17 -1
- package/cli/scaffold/scripts/components/counter.js +30 -10
- package/cli/scaffold/scripts/components/home.js +3 -3
- package/cli/scaffold/scripts/components/todos.js +6 -5
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +1542 -105
- package/dist/zquery.min.js +11 -8
- package/index.d.ts +252 -20
- package/index.js +18 -7
- package/package.json +8 -2
- package/src/component.js +175 -44
- package/src/core.js +22 -25
- package/src/diff.js +280 -0
- package/src/errors.js +155 -0
- package/src/expression.js +806 -0
- package/src/http.js +18 -10
- package/src/reactive.js +29 -4
- package/src/router.js +11 -5
- package/src/ssr.js +224 -0
- package/src/store.js +24 -8
- /package/cli/commands/{dev.js → dev.old.js} +0 -0
package/dist/zquery.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* zQuery (zeroQuery) v0.
|
|
2
|
+
* zQuery (zeroQuery) v0.6.3
|
|
3
3
|
* Lightweight Frontend Library
|
|
4
4
|
* https://github.com/tonywied17/zero-query
|
|
5
5
|
* (c) 2026 Anthony Wiedman — MIT License
|
|
@@ -7,6 +7,163 @@
|
|
|
7
7
|
(function(global) {
|
|
8
8
|
'use strict';
|
|
9
9
|
|
|
10
|
+
// --- src/errors.js ———————————————————————————————————————————————
|
|
11
|
+
/**
|
|
12
|
+
* zQuery Errors — Structured error handling system
|
|
13
|
+
*
|
|
14
|
+
* Provides typed error classes and a configurable error handler so that
|
|
15
|
+
* errors surface consistently across all modules (reactive, component,
|
|
16
|
+
* router, store, expression parser, HTTP, etc.).
|
|
17
|
+
*
|
|
18
|
+
* Default behaviour: errors are logged via console.warn/error.
|
|
19
|
+
* Users can override with $.onError(handler) to integrate with their
|
|
20
|
+
* own logging, crash-reporting, or UI notification system.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Error codes — every zQuery error has a unique code for programmatic use
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
const ErrorCode = Object.freeze({
|
|
27
|
+
// Reactive
|
|
28
|
+
REACTIVE_CALLBACK: 'ZQ_REACTIVE_CALLBACK',
|
|
29
|
+
SIGNAL_CALLBACK: 'ZQ_SIGNAL_CALLBACK',
|
|
30
|
+
EFFECT_EXEC: 'ZQ_EFFECT_EXEC',
|
|
31
|
+
|
|
32
|
+
// Expression parser
|
|
33
|
+
EXPR_PARSE: 'ZQ_EXPR_PARSE',
|
|
34
|
+
EXPR_EVAL: 'ZQ_EXPR_EVAL',
|
|
35
|
+
EXPR_UNSAFE_ACCESS: 'ZQ_EXPR_UNSAFE_ACCESS',
|
|
36
|
+
|
|
37
|
+
// Component
|
|
38
|
+
COMP_INVALID_NAME: 'ZQ_COMP_INVALID_NAME',
|
|
39
|
+
COMP_NOT_FOUND: 'ZQ_COMP_NOT_FOUND',
|
|
40
|
+
COMP_MOUNT_TARGET: 'ZQ_COMP_MOUNT_TARGET',
|
|
41
|
+
COMP_RENDER: 'ZQ_COMP_RENDER',
|
|
42
|
+
COMP_LIFECYCLE: 'ZQ_COMP_LIFECYCLE',
|
|
43
|
+
COMP_RESOURCE: 'ZQ_COMP_RESOURCE',
|
|
44
|
+
COMP_DIRECTIVE: 'ZQ_COMP_DIRECTIVE',
|
|
45
|
+
|
|
46
|
+
// Router
|
|
47
|
+
ROUTER_LOAD: 'ZQ_ROUTER_LOAD',
|
|
48
|
+
ROUTER_GUARD: 'ZQ_ROUTER_GUARD',
|
|
49
|
+
ROUTER_RESOLVE: 'ZQ_ROUTER_RESOLVE',
|
|
50
|
+
|
|
51
|
+
// Store
|
|
52
|
+
STORE_ACTION: 'ZQ_STORE_ACTION',
|
|
53
|
+
STORE_MIDDLEWARE: 'ZQ_STORE_MIDDLEWARE',
|
|
54
|
+
STORE_SUBSCRIBE: 'ZQ_STORE_SUBSCRIBE',
|
|
55
|
+
|
|
56
|
+
// HTTP
|
|
57
|
+
HTTP_REQUEST: 'ZQ_HTTP_REQUEST',
|
|
58
|
+
HTTP_TIMEOUT: 'ZQ_HTTP_TIMEOUT',
|
|
59
|
+
HTTP_INTERCEPTOR: 'ZQ_HTTP_INTERCEPTOR',
|
|
60
|
+
HTTP_PARSE: 'ZQ_HTTP_PARSE',
|
|
61
|
+
|
|
62
|
+
// General
|
|
63
|
+
INVALID_ARGUMENT: 'ZQ_INVALID_ARGUMENT',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// ZQueryError — custom error class
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
class ZQueryError extends Error {
|
|
71
|
+
/**
|
|
72
|
+
* @param {string} code — one of ErrorCode values
|
|
73
|
+
* @param {string} message — human-readable description
|
|
74
|
+
* @param {object} [context] — extra data (component name, expression, etc.)
|
|
75
|
+
* @param {Error} [cause] — original error
|
|
76
|
+
*/
|
|
77
|
+
constructor(code, message, context = {}, cause) {
|
|
78
|
+
super(message);
|
|
79
|
+
this.name = 'ZQueryError';
|
|
80
|
+
this.code = code;
|
|
81
|
+
this.context = context;
|
|
82
|
+
if (cause) this.cause = cause;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Global error handler
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
let _errorHandler = null;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Register a global error handler.
|
|
94
|
+
* Called whenever zQuery catches an error internally.
|
|
95
|
+
*
|
|
96
|
+
* @param {Function|null} handler — (error: ZQueryError) => void
|
|
97
|
+
*/
|
|
98
|
+
function onError(handler) {
|
|
99
|
+
_errorHandler = typeof handler === 'function' ? handler : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Report an error through the global handler and console.
|
|
104
|
+
* Non-throwing — used for recoverable errors in callbacks, lifecycle hooks, etc.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} code — ErrorCode
|
|
107
|
+
* @param {string} message
|
|
108
|
+
* @param {object} [context]
|
|
109
|
+
* @param {Error} [cause]
|
|
110
|
+
*/
|
|
111
|
+
function reportError(code, message, context = {}, cause) {
|
|
112
|
+
const err = cause instanceof ZQueryError
|
|
113
|
+
? cause
|
|
114
|
+
: new ZQueryError(code, message, context, cause);
|
|
115
|
+
|
|
116
|
+
// User handler gets first crack
|
|
117
|
+
if (_errorHandler) {
|
|
118
|
+
try { _errorHandler(err); } catch { /* prevent handler from crashing framework */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Always log for developer visibility
|
|
122
|
+
console.error(`[zQuery ${code}] ${message}`, context, cause || '');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Wrap a callback so that thrown errors are caught, reported, and don't crash
|
|
127
|
+
* the current execution context.
|
|
128
|
+
*
|
|
129
|
+
* @param {Function} fn
|
|
130
|
+
* @param {string} code — ErrorCode to use if the callback throws
|
|
131
|
+
* @param {object} [context]
|
|
132
|
+
* @returns {Function}
|
|
133
|
+
*/
|
|
134
|
+
function guardCallback(fn, code, context = {}) {
|
|
135
|
+
return (...args) => {
|
|
136
|
+
try {
|
|
137
|
+
return fn(...args);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
reportError(code, err.message || 'Callback error', context, err);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Validate a required value is defined and of the expected type.
|
|
146
|
+
* Throws ZQueryError on failure (for fast-fail at API boundaries).
|
|
147
|
+
*
|
|
148
|
+
* @param {*} value
|
|
149
|
+
* @param {string} name — parameter name for error message
|
|
150
|
+
* @param {string} expectedType — 'string', 'function', 'object', etc.
|
|
151
|
+
*/
|
|
152
|
+
function validate(value, name, expectedType) {
|
|
153
|
+
if (value === undefined || value === null) {
|
|
154
|
+
throw new ZQueryError(
|
|
155
|
+
ErrorCode.INVALID_ARGUMENT,
|
|
156
|
+
`"${name}" is required but got ${value}`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (expectedType && typeof value !== expectedType) {
|
|
160
|
+
throw new ZQueryError(
|
|
161
|
+
ErrorCode.INVALID_ARGUMENT,
|
|
162
|
+
`"${name}" must be a ${expectedType}, got ${typeof value}`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
10
167
|
// --- src/reactive.js —————————————————————————————————————————————
|
|
11
168
|
/**
|
|
12
169
|
* zQuery Reactive — Proxy-based deep reactivity system
|
|
@@ -15,11 +172,16 @@
|
|
|
15
172
|
* Used internally by components and store for auto-updates.
|
|
16
173
|
*/
|
|
17
174
|
|
|
175
|
+
|
|
18
176
|
// ---------------------------------------------------------------------------
|
|
19
177
|
// Deep reactive proxy
|
|
20
178
|
// ---------------------------------------------------------------------------
|
|
21
179
|
function reactive(target, onChange, _path = '') {
|
|
22
180
|
if (typeof target !== 'object' || target === null) return target;
|
|
181
|
+
if (typeof onChange !== 'function') {
|
|
182
|
+
reportError(ErrorCode.REACTIVE_CALLBACK, 'reactive() onChange must be a function', { received: typeof onChange });
|
|
183
|
+
onChange = () => {};
|
|
184
|
+
}
|
|
23
185
|
|
|
24
186
|
const proxyCache = new WeakMap();
|
|
25
187
|
|
|
@@ -43,14 +205,22 @@ function reactive(target, onChange, _path = '') {
|
|
|
43
205
|
const old = obj[key];
|
|
44
206
|
if (old === value) return true;
|
|
45
207
|
obj[key] = value;
|
|
46
|
-
|
|
208
|
+
try {
|
|
209
|
+
onChange(key, value, old);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
reportError(ErrorCode.REACTIVE_CALLBACK, `Reactive onChange threw for key "${String(key)}"`, { key, value, old }, err);
|
|
212
|
+
}
|
|
47
213
|
return true;
|
|
48
214
|
},
|
|
49
215
|
|
|
50
216
|
deleteProperty(obj, key) {
|
|
51
217
|
const old = obj[key];
|
|
52
218
|
delete obj[key];
|
|
53
|
-
|
|
219
|
+
try {
|
|
220
|
+
onChange(key, undefined, old);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
reportError(ErrorCode.REACTIVE_CALLBACK, `Reactive onChange threw for key "${String(key)}"`, { key, old }, err);
|
|
223
|
+
}
|
|
54
224
|
return true;
|
|
55
225
|
}
|
|
56
226
|
};
|
|
@@ -85,7 +255,12 @@ class Signal {
|
|
|
85
255
|
peek() { return this._value; }
|
|
86
256
|
|
|
87
257
|
_notify() {
|
|
88
|
-
this._subscribers.forEach(fn =>
|
|
258
|
+
this._subscribers.forEach(fn => {
|
|
259
|
+
try { fn(); }
|
|
260
|
+
catch (err) {
|
|
261
|
+
reportError(ErrorCode.SIGNAL_CALLBACK, 'Signal subscriber threw', { signal: this }, err);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
89
264
|
}
|
|
90
265
|
|
|
91
266
|
subscribe(fn) {
|
|
@@ -128,10 +303,16 @@ function effect(fn) {
|
|
|
128
303
|
const execute = () => {
|
|
129
304
|
Signal._activeEffect = execute;
|
|
130
305
|
try { fn(); }
|
|
306
|
+
catch (err) {
|
|
307
|
+
reportError(ErrorCode.EFFECT_EXEC, 'Effect function threw', {}, err);
|
|
308
|
+
}
|
|
131
309
|
finally { Signal._activeEffect = null; }
|
|
132
310
|
};
|
|
133
311
|
execute();
|
|
134
|
-
return () => {
|
|
312
|
+
return () => {
|
|
313
|
+
// Remove this effect from all signals that track it
|
|
314
|
+
Signal._activeEffect = null;
|
|
315
|
+
};
|
|
135
316
|
}
|
|
136
317
|
|
|
137
318
|
// --- src/core.js —————————————————————————————————————————————————
|
|
@@ -163,6 +344,11 @@ class ZQueryCollection {
|
|
|
163
344
|
return this.elements.map((el, i) => fn.call(el, i, el));
|
|
164
345
|
}
|
|
165
346
|
|
|
347
|
+
forEach(fn) {
|
|
348
|
+
this.elements.forEach((el, i) => fn(el, i, this.elements));
|
|
349
|
+
return this;
|
|
350
|
+
}
|
|
351
|
+
|
|
166
352
|
first() { return this.elements[0] || null; }
|
|
167
353
|
last() { return this.elements[this.length - 1] || null; }
|
|
168
354
|
eq(i) { return new ZQueryCollection(this.elements[i] ? [this.elements[i]] : []); }
|
|
@@ -521,42 +707,40 @@ function createFragment(html) {
|
|
|
521
707
|
|
|
522
708
|
|
|
523
709
|
// ---------------------------------------------------------------------------
|
|
524
|
-
// $() — main selector / creator
|
|
710
|
+
// $() — main selector / creator (returns ZQueryCollection, like jQuery)
|
|
525
711
|
// ---------------------------------------------------------------------------
|
|
526
712
|
function query(selector, context) {
|
|
527
713
|
// null / undefined
|
|
528
|
-
if (!selector) return
|
|
714
|
+
if (!selector) return new ZQueryCollection([]);
|
|
529
715
|
|
|
530
|
-
// Already a collection — return
|
|
531
|
-
if (selector instanceof ZQueryCollection) return selector
|
|
716
|
+
// Already a collection — return as-is
|
|
717
|
+
if (selector instanceof ZQueryCollection) return selector;
|
|
532
718
|
|
|
533
|
-
// DOM element or Window —
|
|
719
|
+
// DOM element or Window — wrap in collection
|
|
534
720
|
if (selector instanceof Node || selector === window) {
|
|
535
|
-
return selector;
|
|
721
|
+
return new ZQueryCollection([selector]);
|
|
536
722
|
}
|
|
537
723
|
|
|
538
|
-
// NodeList / HTMLCollection / Array —
|
|
724
|
+
// NodeList / HTMLCollection / Array — wrap in collection
|
|
539
725
|
if (selector instanceof NodeList || selector instanceof HTMLCollection || Array.isArray(selector)) {
|
|
540
|
-
|
|
541
|
-
return arr[0] || null;
|
|
726
|
+
return new ZQueryCollection(Array.from(selector));
|
|
542
727
|
}
|
|
543
728
|
|
|
544
|
-
// HTML string → create elements,
|
|
729
|
+
// HTML string → create elements, wrap in collection
|
|
545
730
|
if (typeof selector === 'string' && selector.trim().startsWith('<')) {
|
|
546
731
|
const fragment = createFragment(selector);
|
|
547
|
-
|
|
548
|
-
return els[0] || null;
|
|
732
|
+
return new ZQueryCollection([...fragment.childNodes].filter(n => n.nodeType === 1));
|
|
549
733
|
}
|
|
550
734
|
|
|
551
|
-
// CSS selector string →
|
|
735
|
+
// CSS selector string → querySelectorAll (collection)
|
|
552
736
|
if (typeof selector === 'string') {
|
|
553
737
|
const root = context
|
|
554
738
|
? (typeof context === 'string' ? document.querySelector(context) : context)
|
|
555
739
|
: document;
|
|
556
|
-
return root.
|
|
740
|
+
return new ZQueryCollection([...root.querySelectorAll(selector)]);
|
|
557
741
|
}
|
|
558
742
|
|
|
559
|
-
return
|
|
743
|
+
return new ZQueryCollection([]);
|
|
560
744
|
}
|
|
561
745
|
|
|
562
746
|
|
|
@@ -603,21 +787,15 @@ function queryAll(selector, context) {
|
|
|
603
787
|
// ---------------------------------------------------------------------------
|
|
604
788
|
query.id = (id) => document.getElementById(id);
|
|
605
789
|
query.class = (name) => document.querySelector(`.${name}`);
|
|
606
|
-
query.classes = (name) => Array.from(document.getElementsByClassName(name));
|
|
607
|
-
query.tag = (name) => Array.from(document.getElementsByTagName(name));
|
|
790
|
+
query.classes = (name) => new ZQueryCollection(Array.from(document.getElementsByClassName(name)));
|
|
791
|
+
query.tag = (name) => new ZQueryCollection(Array.from(document.getElementsByTagName(name)));
|
|
608
792
|
Object.defineProperty(query, 'name', {
|
|
609
|
-
value: (name) => Array.from(document.getElementsByName(name)),
|
|
793
|
+
value: (name) => new ZQueryCollection(Array.from(document.getElementsByName(name))),
|
|
610
794
|
writable: true, configurable: true
|
|
611
795
|
});
|
|
612
|
-
query.attr = (attr, value) => Array.from(
|
|
613
|
-
document.querySelectorAll(value !== undefined ? `[${attr}="${value}"]` : `[${attr}]`)
|
|
614
|
-
);
|
|
615
|
-
query.data = (key, value) => Array.from(
|
|
616
|
-
document.querySelectorAll(value !== undefined ? `[data-${key}="${value}"]` : `[data-${key}]`)
|
|
617
|
-
);
|
|
618
796
|
query.children = (parentId) => {
|
|
619
797
|
const p = document.getElementById(parentId);
|
|
620
|
-
return p ? Array.from(p.children) : [];
|
|
798
|
+
return new ZQueryCollection(p ? Array.from(p.children) : []);
|
|
621
799
|
};
|
|
622
800
|
|
|
623
801
|
// Create element shorthand
|
|
@@ -667,6 +845,1096 @@ query.off = (event, handler) => {
|
|
|
667
845
|
// Extend collection prototype (like $.fn in jQuery)
|
|
668
846
|
query.fn = ZQueryCollection.prototype;
|
|
669
847
|
|
|
848
|
+
// --- src/expression.js ———————————————————————————————————————————
|
|
849
|
+
/**
|
|
850
|
+
* zQuery Expression Parser — CSP-safe expression evaluator
|
|
851
|
+
*
|
|
852
|
+
* Replaces `new Function()` / `eval()` with a hand-written parser that
|
|
853
|
+
* evaluates expressions safely without violating Content Security Policy.
|
|
854
|
+
*
|
|
855
|
+
* Supports:
|
|
856
|
+
* - Property access: user.name, items[0], items[i]
|
|
857
|
+
* - Method calls: items.length, str.toUpperCase()
|
|
858
|
+
* - Arithmetic: a + b, count * 2, i % 2
|
|
859
|
+
* - Comparison: a === b, count > 0, x != null
|
|
860
|
+
* - Logical: a && b, a || b, !a
|
|
861
|
+
* - Ternary: a ? b : c
|
|
862
|
+
* - Typeof: typeof x
|
|
863
|
+
* - Unary: -a, +a, !a
|
|
864
|
+
* - Literals: 42, 'hello', "world", true, false, null, undefined
|
|
865
|
+
* - Template literals: `Hello ${name}`
|
|
866
|
+
* - Array literals: [1, 2, 3]
|
|
867
|
+
* - Object literals: { foo: 'bar', baz: 1 }
|
|
868
|
+
* - Grouping: (a + b) * c
|
|
869
|
+
* - Nullish coalescing: a ?? b
|
|
870
|
+
* - Optional chaining: a?.b, a?.[b], a?.()
|
|
871
|
+
* - Arrow functions: x => x.id, (a, b) => a + b
|
|
872
|
+
*/
|
|
873
|
+
|
|
874
|
+
// Token types
|
|
875
|
+
const T = {
|
|
876
|
+
NUM: 1, STR: 2, IDENT: 3, OP: 4, PUNC: 5, TMPL: 6, EOF: 7
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
// Operator precedence (higher = binds tighter)
|
|
880
|
+
const PREC = {
|
|
881
|
+
'??': 2,
|
|
882
|
+
'||': 3,
|
|
883
|
+
'&&': 4,
|
|
884
|
+
'==': 8, '!=': 8, '===': 8, '!==': 8,
|
|
885
|
+
'<': 9, '>': 9, '<=': 9, '>=': 9, 'instanceof': 9, 'in': 9,
|
|
886
|
+
'+': 11, '-': 11,
|
|
887
|
+
'*': 12, '/': 12, '%': 12,
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
const KEYWORDS = new Set([
|
|
891
|
+
'true', 'false', 'null', 'undefined', 'typeof', 'instanceof', 'in',
|
|
892
|
+
'new', 'void'
|
|
893
|
+
]);
|
|
894
|
+
|
|
895
|
+
// ---------------------------------------------------------------------------
|
|
896
|
+
// Tokenizer
|
|
897
|
+
// ---------------------------------------------------------------------------
|
|
898
|
+
function tokenize(expr) {
|
|
899
|
+
const tokens = [];
|
|
900
|
+
let i = 0;
|
|
901
|
+
const len = expr.length;
|
|
902
|
+
|
|
903
|
+
while (i < len) {
|
|
904
|
+
const ch = expr[i];
|
|
905
|
+
|
|
906
|
+
// Whitespace
|
|
907
|
+
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { i++; continue; }
|
|
908
|
+
|
|
909
|
+
// Numbers
|
|
910
|
+
if ((ch >= '0' && ch <= '9') || (ch === '.' && i + 1 < len && expr[i + 1] >= '0' && expr[i + 1] <= '9')) {
|
|
911
|
+
let num = '';
|
|
912
|
+
if (ch === '0' && i + 1 < len && (expr[i + 1] === 'x' || expr[i + 1] === 'X')) {
|
|
913
|
+
num = '0x'; i += 2;
|
|
914
|
+
while (i < len && /[0-9a-fA-F]/.test(expr[i])) num += expr[i++];
|
|
915
|
+
} else {
|
|
916
|
+
while (i < len && ((expr[i] >= '0' && expr[i] <= '9') || expr[i] === '.')) num += expr[i++];
|
|
917
|
+
if (i < len && (expr[i] === 'e' || expr[i] === 'E')) {
|
|
918
|
+
num += expr[i++];
|
|
919
|
+
if (i < len && (expr[i] === '+' || expr[i] === '-')) num += expr[i++];
|
|
920
|
+
while (i < len && expr[i] >= '0' && expr[i] <= '9') num += expr[i++];
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
tokens.push({ t: T.NUM, v: Number(num) });
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Strings
|
|
928
|
+
if (ch === "'" || ch === '"') {
|
|
929
|
+
const quote = ch;
|
|
930
|
+
let str = '';
|
|
931
|
+
i++;
|
|
932
|
+
while (i < len && expr[i] !== quote) {
|
|
933
|
+
if (expr[i] === '\\' && i + 1 < len) {
|
|
934
|
+
const esc = expr[++i];
|
|
935
|
+
if (esc === 'n') str += '\n';
|
|
936
|
+
else if (esc === 't') str += '\t';
|
|
937
|
+
else if (esc === 'r') str += '\r';
|
|
938
|
+
else if (esc === '\\') str += '\\';
|
|
939
|
+
else if (esc === quote) str += quote;
|
|
940
|
+
else str += esc;
|
|
941
|
+
} else {
|
|
942
|
+
str += expr[i];
|
|
943
|
+
}
|
|
944
|
+
i++;
|
|
945
|
+
}
|
|
946
|
+
i++; // closing quote
|
|
947
|
+
tokens.push({ t: T.STR, v: str });
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Template literals
|
|
952
|
+
if (ch === '`') {
|
|
953
|
+
const parts = []; // alternating: string, expr, string, expr, ...
|
|
954
|
+
let str = '';
|
|
955
|
+
i++;
|
|
956
|
+
while (i < len && expr[i] !== '`') {
|
|
957
|
+
if (expr[i] === '$' && i + 1 < len && expr[i + 1] === '{') {
|
|
958
|
+
parts.push(str);
|
|
959
|
+
str = '';
|
|
960
|
+
i += 2;
|
|
961
|
+
let depth = 1;
|
|
962
|
+
let inner = '';
|
|
963
|
+
while (i < len && depth > 0) {
|
|
964
|
+
if (expr[i] === '{') depth++;
|
|
965
|
+
else if (expr[i] === '}') { depth--; if (depth === 0) break; }
|
|
966
|
+
inner += expr[i++];
|
|
967
|
+
}
|
|
968
|
+
i++; // closing }
|
|
969
|
+
parts.push({ expr: inner });
|
|
970
|
+
} else {
|
|
971
|
+
if (expr[i] === '\\' && i + 1 < len) { str += expr[++i]; }
|
|
972
|
+
else str += expr[i];
|
|
973
|
+
i++;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
i++; // closing backtick
|
|
977
|
+
parts.push(str);
|
|
978
|
+
tokens.push({ t: T.TMPL, v: parts });
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// Identifiers & keywords
|
|
983
|
+
if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_' || ch === '$') {
|
|
984
|
+
let ident = '';
|
|
985
|
+
while (i < len && /[\w$]/.test(expr[i])) ident += expr[i++];
|
|
986
|
+
tokens.push({ t: T.IDENT, v: ident });
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Multi-char operators
|
|
991
|
+
const two = expr.slice(i, i + 3);
|
|
992
|
+
if (two === '===' || two === '!==' || two === '?.') {
|
|
993
|
+
if (two === '?.') {
|
|
994
|
+
tokens.push({ t: T.OP, v: '?.' });
|
|
995
|
+
i += 2;
|
|
996
|
+
} else {
|
|
997
|
+
tokens.push({ t: T.OP, v: two });
|
|
998
|
+
i += 3;
|
|
999
|
+
}
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
const pair = expr.slice(i, i + 2);
|
|
1003
|
+
if (pair === '==' || pair === '!=' || pair === '<=' || pair === '>=' ||
|
|
1004
|
+
pair === '&&' || pair === '||' || pair === '??' || pair === '?.' ||
|
|
1005
|
+
pair === '=>') {
|
|
1006
|
+
tokens.push({ t: T.OP, v: pair });
|
|
1007
|
+
i += 2;
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Single char operators and punctuation
|
|
1012
|
+
if ('+-*/%'.includes(ch)) {
|
|
1013
|
+
tokens.push({ t: T.OP, v: ch });
|
|
1014
|
+
i++; continue;
|
|
1015
|
+
}
|
|
1016
|
+
if ('<>=!'.includes(ch)) {
|
|
1017
|
+
tokens.push({ t: T.OP, v: ch });
|
|
1018
|
+
i++; continue;
|
|
1019
|
+
}
|
|
1020
|
+
if ('()[]{},.?:'.includes(ch)) {
|
|
1021
|
+
tokens.push({ t: T.PUNC, v: ch });
|
|
1022
|
+
i++; continue;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Unknown — skip
|
|
1026
|
+
i++;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
tokens.push({ t: T.EOF, v: null });
|
|
1030
|
+
return tokens;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// ---------------------------------------------------------------------------
|
|
1034
|
+
// Parser — Pratt (precedence climbing)
|
|
1035
|
+
// ---------------------------------------------------------------------------
|
|
1036
|
+
class Parser {
|
|
1037
|
+
constructor(tokens, scope) {
|
|
1038
|
+
this.tokens = tokens;
|
|
1039
|
+
this.pos = 0;
|
|
1040
|
+
this.scope = scope;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
peek() { return this.tokens[this.pos]; }
|
|
1044
|
+
next() { return this.tokens[this.pos++]; }
|
|
1045
|
+
|
|
1046
|
+
expect(type, val) {
|
|
1047
|
+
const t = this.next();
|
|
1048
|
+
if (t.t !== type || (val !== undefined && t.v !== val)) {
|
|
1049
|
+
throw new Error(`Expected ${val || type} but got ${t.v}`);
|
|
1050
|
+
}
|
|
1051
|
+
return t;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
match(type, val) {
|
|
1055
|
+
const t = this.peek();
|
|
1056
|
+
if (t.t === type && (val === undefined || t.v === val)) {
|
|
1057
|
+
return this.next();
|
|
1058
|
+
}
|
|
1059
|
+
return null;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// Main entry
|
|
1063
|
+
parse() {
|
|
1064
|
+
const result = this.parseExpression(0);
|
|
1065
|
+
return result;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Precedence climbing
|
|
1069
|
+
parseExpression(minPrec) {
|
|
1070
|
+
let left = this.parseUnary();
|
|
1071
|
+
|
|
1072
|
+
while (true) {
|
|
1073
|
+
const tok = this.peek();
|
|
1074
|
+
|
|
1075
|
+
// Ternary
|
|
1076
|
+
if (tok.t === T.PUNC && tok.v === '?') {
|
|
1077
|
+
// Distinguish ternary ? from optional chaining ?.
|
|
1078
|
+
if (this.tokens[this.pos + 1]?.v !== '.') {
|
|
1079
|
+
if (1 <= minPrec) break; // ternary has very low precedence
|
|
1080
|
+
this.next(); // consume ?
|
|
1081
|
+
const truthy = this.parseExpression(0);
|
|
1082
|
+
this.expect(T.PUNC, ':');
|
|
1083
|
+
const falsy = this.parseExpression(1);
|
|
1084
|
+
left = { type: 'ternary', cond: left, truthy, falsy };
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// Binary operators
|
|
1090
|
+
if (tok.t === T.OP && tok.v in PREC) {
|
|
1091
|
+
const prec = PREC[tok.v];
|
|
1092
|
+
if (prec <= minPrec) break;
|
|
1093
|
+
this.next();
|
|
1094
|
+
const right = this.parseExpression(prec);
|
|
1095
|
+
left = { type: 'binary', op: tok.v, left, right };
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// instanceof and in as binary operators
|
|
1100
|
+
if (tok.t === T.IDENT && (tok.v === 'instanceof' || tok.v === 'in') && PREC[tok.v] > minPrec) {
|
|
1101
|
+
const prec = PREC[tok.v];
|
|
1102
|
+
this.next();
|
|
1103
|
+
const right = this.parseExpression(prec);
|
|
1104
|
+
left = { type: 'binary', op: tok.v, left, right };
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
break;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
return left;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
parseUnary() {
|
|
1115
|
+
const tok = this.peek();
|
|
1116
|
+
|
|
1117
|
+
// typeof
|
|
1118
|
+
if (tok.t === T.IDENT && tok.v === 'typeof') {
|
|
1119
|
+
this.next();
|
|
1120
|
+
const arg = this.parseUnary();
|
|
1121
|
+
return { type: 'typeof', arg };
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// void
|
|
1125
|
+
if (tok.t === T.IDENT && tok.v === 'void') {
|
|
1126
|
+
this.next();
|
|
1127
|
+
this.parseUnary(); // evaluate but discard
|
|
1128
|
+
return { type: 'literal', value: undefined };
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// !expr
|
|
1132
|
+
if (tok.t === T.OP && tok.v === '!') {
|
|
1133
|
+
this.next();
|
|
1134
|
+
const arg = this.parseUnary();
|
|
1135
|
+
return { type: 'not', arg };
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// -expr, +expr
|
|
1139
|
+
if (tok.t === T.OP && (tok.v === '-' || tok.v === '+')) {
|
|
1140
|
+
this.next();
|
|
1141
|
+
const arg = this.parseUnary();
|
|
1142
|
+
return { type: 'unary', op: tok.v, arg };
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
return this.parsePostfix();
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
parsePostfix() {
|
|
1149
|
+
let left = this.parsePrimary();
|
|
1150
|
+
|
|
1151
|
+
while (true) {
|
|
1152
|
+
const tok = this.peek();
|
|
1153
|
+
|
|
1154
|
+
// Property access: a.b
|
|
1155
|
+
if (tok.t === T.PUNC && tok.v === '.') {
|
|
1156
|
+
this.next();
|
|
1157
|
+
const prop = this.next();
|
|
1158
|
+
left = { type: 'member', obj: left, prop: prop.v, computed: false };
|
|
1159
|
+
// Check for method call: a.b()
|
|
1160
|
+
if (this.peek().t === T.PUNC && this.peek().v === '(') {
|
|
1161
|
+
left = this._parseCall(left);
|
|
1162
|
+
}
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Optional chaining: a?.b, a?.[b], a?.()
|
|
1167
|
+
if (tok.t === T.OP && tok.v === '?.') {
|
|
1168
|
+
this.next();
|
|
1169
|
+
const next = this.peek();
|
|
1170
|
+
if (next.t === T.PUNC && next.v === '[') {
|
|
1171
|
+
// a?.[expr]
|
|
1172
|
+
this.next();
|
|
1173
|
+
const prop = this.parseExpression(0);
|
|
1174
|
+
this.expect(T.PUNC, ']');
|
|
1175
|
+
left = { type: 'optional_member', obj: left, prop, computed: true };
|
|
1176
|
+
} else if (next.t === T.PUNC && next.v === '(') {
|
|
1177
|
+
// a?.()
|
|
1178
|
+
left = { type: 'optional_call', callee: left, args: this._parseArgs() };
|
|
1179
|
+
} else {
|
|
1180
|
+
// a?.b
|
|
1181
|
+
const prop = this.next();
|
|
1182
|
+
left = { type: 'optional_member', obj: left, prop: prop.v, computed: false };
|
|
1183
|
+
if (this.peek().t === T.PUNC && this.peek().v === '(') {
|
|
1184
|
+
left = this._parseCall(left);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// Computed access: a[b]
|
|
1191
|
+
if (tok.t === T.PUNC && tok.v === '[') {
|
|
1192
|
+
this.next();
|
|
1193
|
+
const prop = this.parseExpression(0);
|
|
1194
|
+
this.expect(T.PUNC, ']');
|
|
1195
|
+
left = { type: 'member', obj: left, prop, computed: true };
|
|
1196
|
+
// Check for method call: a[b]()
|
|
1197
|
+
if (this.peek().t === T.PUNC && this.peek().v === '(') {
|
|
1198
|
+
left = this._parseCall(left);
|
|
1199
|
+
}
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Function call: fn()
|
|
1204
|
+
if (tok.t === T.PUNC && tok.v === '(') {
|
|
1205
|
+
left = this._parseCall(left);
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
break;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
return left;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
_parseCall(callee) {
|
|
1216
|
+
const args = this._parseArgs();
|
|
1217
|
+
return { type: 'call', callee, args };
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
_parseArgs() {
|
|
1221
|
+
this.expect(T.PUNC, '(');
|
|
1222
|
+
const args = [];
|
|
1223
|
+
while (!(this.peek().t === T.PUNC && this.peek().v === ')') && this.peek().t !== T.EOF) {
|
|
1224
|
+
args.push(this.parseExpression(0));
|
|
1225
|
+
if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
|
|
1226
|
+
}
|
|
1227
|
+
this.expect(T.PUNC, ')');
|
|
1228
|
+
return args;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
parsePrimary() {
|
|
1232
|
+
const tok = this.peek();
|
|
1233
|
+
|
|
1234
|
+
// Number literal
|
|
1235
|
+
if (tok.t === T.NUM) {
|
|
1236
|
+
this.next();
|
|
1237
|
+
return { type: 'literal', value: tok.v };
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// String literal
|
|
1241
|
+
if (tok.t === T.STR) {
|
|
1242
|
+
this.next();
|
|
1243
|
+
return { type: 'literal', value: tok.v };
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// Template literal
|
|
1247
|
+
if (tok.t === T.TMPL) {
|
|
1248
|
+
this.next();
|
|
1249
|
+
return { type: 'template', parts: tok.v };
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// Arrow function with parens: () =>, (a) =>, (a, b) =>
|
|
1253
|
+
// or regular grouping: (expr)
|
|
1254
|
+
if (tok.t === T.PUNC && tok.v === '(') {
|
|
1255
|
+
const savedPos = this.pos;
|
|
1256
|
+
this.next(); // consume (
|
|
1257
|
+
const params = [];
|
|
1258
|
+
let couldBeArrow = true;
|
|
1259
|
+
|
|
1260
|
+
if (this.peek().t === T.PUNC && this.peek().v === ')') {
|
|
1261
|
+
// () => ... — no params
|
|
1262
|
+
} else {
|
|
1263
|
+
while (couldBeArrow) {
|
|
1264
|
+
const p = this.peek();
|
|
1265
|
+
if (p.t === T.IDENT && !KEYWORDS.has(p.v)) {
|
|
1266
|
+
params.push(this.next().v);
|
|
1267
|
+
if (this.peek().t === T.PUNC && this.peek().v === ',') {
|
|
1268
|
+
this.next();
|
|
1269
|
+
} else {
|
|
1270
|
+
break;
|
|
1271
|
+
}
|
|
1272
|
+
} else {
|
|
1273
|
+
couldBeArrow = false;
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
if (couldBeArrow && this.peek().t === T.PUNC && this.peek().v === ')') {
|
|
1279
|
+
this.next(); // consume )
|
|
1280
|
+
if (this.peek().t === T.OP && this.peek().v === '=>') {
|
|
1281
|
+
this.next(); // consume =>
|
|
1282
|
+
const body = this.parseExpression(0);
|
|
1283
|
+
return { type: 'arrow', params, body };
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// Not an arrow — restore and parse as grouping
|
|
1288
|
+
this.pos = savedPos;
|
|
1289
|
+
this.next(); // consume (
|
|
1290
|
+
const expr = this.parseExpression(0);
|
|
1291
|
+
this.expect(T.PUNC, ')');
|
|
1292
|
+
return expr;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// Array literal
|
|
1296
|
+
if (tok.t === T.PUNC && tok.v === '[') {
|
|
1297
|
+
this.next();
|
|
1298
|
+
const elements = [];
|
|
1299
|
+
while (!(this.peek().t === T.PUNC && this.peek().v === ']') && this.peek().t !== T.EOF) {
|
|
1300
|
+
elements.push(this.parseExpression(0));
|
|
1301
|
+
if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
|
|
1302
|
+
}
|
|
1303
|
+
this.expect(T.PUNC, ']');
|
|
1304
|
+
return { type: 'array', elements };
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// Object literal
|
|
1308
|
+
if (tok.t === T.PUNC && tok.v === '{') {
|
|
1309
|
+
this.next();
|
|
1310
|
+
const properties = [];
|
|
1311
|
+
while (!(this.peek().t === T.PUNC && this.peek().v === '}') && this.peek().t !== T.EOF) {
|
|
1312
|
+
const keyTok = this.next();
|
|
1313
|
+
let key;
|
|
1314
|
+
if (keyTok.t === T.IDENT || keyTok.t === T.STR) key = keyTok.v;
|
|
1315
|
+
else if (keyTok.t === T.NUM) key = String(keyTok.v);
|
|
1316
|
+
else throw new Error('Invalid object key: ' + keyTok.v);
|
|
1317
|
+
|
|
1318
|
+
// Shorthand property: { foo } means { foo: foo }
|
|
1319
|
+
if (this.peek().t === T.PUNC && (this.peek().v === ',' || this.peek().v === '}')) {
|
|
1320
|
+
properties.push({ key, value: { type: 'ident', name: key } });
|
|
1321
|
+
} else {
|
|
1322
|
+
this.expect(T.PUNC, ':');
|
|
1323
|
+
properties.push({ key, value: this.parseExpression(0) });
|
|
1324
|
+
}
|
|
1325
|
+
if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
|
|
1326
|
+
}
|
|
1327
|
+
this.expect(T.PUNC, '}');
|
|
1328
|
+
return { type: 'object', properties };
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// Identifiers & keywords
|
|
1332
|
+
if (tok.t === T.IDENT) {
|
|
1333
|
+
this.next();
|
|
1334
|
+
|
|
1335
|
+
// Keywords
|
|
1336
|
+
if (tok.v === 'true') return { type: 'literal', value: true };
|
|
1337
|
+
if (tok.v === 'false') return { type: 'literal', value: false };
|
|
1338
|
+
if (tok.v === 'null') return { type: 'literal', value: null };
|
|
1339
|
+
if (tok.v === 'undefined') return { type: 'literal', value: undefined };
|
|
1340
|
+
|
|
1341
|
+
// new keyword
|
|
1342
|
+
if (tok.v === 'new') {
|
|
1343
|
+
const classExpr = this.parsePostfix();
|
|
1344
|
+
let args = [];
|
|
1345
|
+
if (this.peek().t === T.PUNC && this.peek().v === '(') {
|
|
1346
|
+
args = this._parseArgs();
|
|
1347
|
+
}
|
|
1348
|
+
return { type: 'new', callee: classExpr, args };
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// Arrow function: x => expr
|
|
1352
|
+
if (this.peek().t === T.OP && this.peek().v === '=>') {
|
|
1353
|
+
this.next(); // consume =>
|
|
1354
|
+
const body = this.parseExpression(0);
|
|
1355
|
+
return { type: 'arrow', params: [tok.v], body };
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
return { type: 'ident', name: tok.v };
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// Fallback — return undefined for unparseable
|
|
1362
|
+
this.next();
|
|
1363
|
+
return { type: 'literal', value: undefined };
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// ---------------------------------------------------------------------------
|
|
1368
|
+
// Evaluator — walks the AST, resolves against scope
|
|
1369
|
+
// ---------------------------------------------------------------------------
|
|
1370
|
+
|
|
1371
|
+
/** Safe property access whitelist for built-in prototypes */
|
|
1372
|
+
const SAFE_ARRAY_METHODS = new Set([
|
|
1373
|
+
'length', 'map', 'filter', 'find', 'findIndex', 'some', 'every',
|
|
1374
|
+
'reduce', 'reduceRight', 'forEach', 'includes', 'indexOf', 'lastIndexOf',
|
|
1375
|
+
'join', 'slice', 'concat', 'flat', 'flatMap', 'reverse', 'sort',
|
|
1376
|
+
'fill', 'keys', 'values', 'entries', 'at', 'toString',
|
|
1377
|
+
]);
|
|
1378
|
+
|
|
1379
|
+
const SAFE_STRING_METHODS = new Set([
|
|
1380
|
+
'length', 'charAt', 'charCodeAt', 'includes', 'indexOf', 'lastIndexOf',
|
|
1381
|
+
'slice', 'substring', 'trim', 'trimStart', 'trimEnd', 'toLowerCase',
|
|
1382
|
+
'toUpperCase', 'split', 'replace', 'replaceAll', 'match', 'search',
|
|
1383
|
+
'startsWith', 'endsWith', 'padStart', 'padEnd', 'repeat', 'at',
|
|
1384
|
+
'toString', 'valueOf',
|
|
1385
|
+
]);
|
|
1386
|
+
|
|
1387
|
+
const SAFE_NUMBER_METHODS = new Set([
|
|
1388
|
+
'toFixed', 'toPrecision', 'toString', 'valueOf',
|
|
1389
|
+
]);
|
|
1390
|
+
|
|
1391
|
+
const SAFE_OBJECT_METHODS = new Set([
|
|
1392
|
+
'hasOwnProperty', 'toString', 'valueOf',
|
|
1393
|
+
]);
|
|
1394
|
+
|
|
1395
|
+
const SAFE_MATH_PROPS = new Set([
|
|
1396
|
+
'PI', 'E', 'LN2', 'LN10', 'LOG2E', 'LOG10E', 'SQRT2', 'SQRT1_2',
|
|
1397
|
+
'abs', 'ceil', 'floor', 'round', 'trunc', 'max', 'min', 'pow',
|
|
1398
|
+
'sqrt', 'sign', 'random', 'log', 'log2', 'log10',
|
|
1399
|
+
]);
|
|
1400
|
+
|
|
1401
|
+
const SAFE_JSON_PROPS = new Set(['parse', 'stringify']);
|
|
1402
|
+
|
|
1403
|
+
/**
|
|
1404
|
+
* Check if property access is safe
|
|
1405
|
+
*/
|
|
1406
|
+
function _isSafeAccess(obj, prop) {
|
|
1407
|
+
// Never allow access to dangerous properties
|
|
1408
|
+
const BLOCKED = new Set([
|
|
1409
|
+
'constructor', '__proto__', 'prototype', '__defineGetter__',
|
|
1410
|
+
'__defineSetter__', '__lookupGetter__', '__lookupSetter__',
|
|
1411
|
+
]);
|
|
1412
|
+
if (typeof prop === 'string' && BLOCKED.has(prop)) return false;
|
|
1413
|
+
|
|
1414
|
+
// Always allow plain object/function property access and array index access
|
|
1415
|
+
if (obj !== null && obj !== undefined && (typeof obj === 'object' || typeof obj === 'function')) return true;
|
|
1416
|
+
if (typeof obj === 'string') return SAFE_STRING_METHODS.has(prop);
|
|
1417
|
+
if (typeof obj === 'number') return SAFE_NUMBER_METHODS.has(prop);
|
|
1418
|
+
return false;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function evaluate(node, scope) {
|
|
1422
|
+
if (!node) return undefined;
|
|
1423
|
+
|
|
1424
|
+
switch (node.type) {
|
|
1425
|
+
case 'literal':
|
|
1426
|
+
return node.value;
|
|
1427
|
+
|
|
1428
|
+
case 'ident': {
|
|
1429
|
+
const name = node.name;
|
|
1430
|
+
// Check scope layers in order
|
|
1431
|
+
for (const layer of scope) {
|
|
1432
|
+
if (layer && typeof layer === 'object' && name in layer) {
|
|
1433
|
+
return layer[name];
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
// Built-in globals (safe ones only)
|
|
1437
|
+
if (name === 'Math') return Math;
|
|
1438
|
+
if (name === 'JSON') return JSON;
|
|
1439
|
+
if (name === 'Date') return Date;
|
|
1440
|
+
if (name === 'Array') return Array;
|
|
1441
|
+
if (name === 'Object') return Object;
|
|
1442
|
+
if (name === 'String') return String;
|
|
1443
|
+
if (name === 'Number') return Number;
|
|
1444
|
+
if (name === 'Boolean') return Boolean;
|
|
1445
|
+
if (name === 'parseInt') return parseInt;
|
|
1446
|
+
if (name === 'parseFloat') return parseFloat;
|
|
1447
|
+
if (name === 'isNaN') return isNaN;
|
|
1448
|
+
if (name === 'isFinite') return isFinite;
|
|
1449
|
+
if (name === 'Infinity') return Infinity;
|
|
1450
|
+
if (name === 'NaN') return NaN;
|
|
1451
|
+
if (name === 'encodeURIComponent') return encodeURIComponent;
|
|
1452
|
+
if (name === 'decodeURIComponent') return decodeURIComponent;
|
|
1453
|
+
if (name === 'console') return console;
|
|
1454
|
+
return undefined;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
case 'template': {
|
|
1458
|
+
// Template literal with interpolation
|
|
1459
|
+
let result = '';
|
|
1460
|
+
for (const part of node.parts) {
|
|
1461
|
+
if (typeof part === 'string') {
|
|
1462
|
+
result += part;
|
|
1463
|
+
} else if (part && part.expr) {
|
|
1464
|
+
result += String(safeEval(part.expr, scope) ?? '');
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
return result;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
case 'member': {
|
|
1471
|
+
const obj = evaluate(node.obj, scope);
|
|
1472
|
+
if (obj == null) return undefined;
|
|
1473
|
+
const prop = node.computed ? evaluate(node.prop, scope) : node.prop;
|
|
1474
|
+
if (!_isSafeAccess(obj, prop)) return undefined;
|
|
1475
|
+
return obj[prop];
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
case 'optional_member': {
|
|
1479
|
+
const obj = evaluate(node.obj, scope);
|
|
1480
|
+
if (obj == null) return undefined;
|
|
1481
|
+
const prop = node.computed ? evaluate(node.prop, scope) : node.prop;
|
|
1482
|
+
if (!_isSafeAccess(obj, prop)) return undefined;
|
|
1483
|
+
return obj[prop];
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
case 'call': {
|
|
1487
|
+
const result = _resolveCall(node, scope, false);
|
|
1488
|
+
return result;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
case 'optional_call': {
|
|
1492
|
+
const callee = evaluate(node.callee, scope);
|
|
1493
|
+
if (callee == null) return undefined;
|
|
1494
|
+
if (typeof callee !== 'function') return undefined;
|
|
1495
|
+
const args = node.args.map(a => evaluate(a, scope));
|
|
1496
|
+
return callee(...args);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
case 'new': {
|
|
1500
|
+
const Ctor = evaluate(node.callee, scope);
|
|
1501
|
+
if (typeof Ctor !== 'function') return undefined;
|
|
1502
|
+
// Only allow safe constructors
|
|
1503
|
+
if (Ctor === Date || Ctor === Array || Ctor === Map || Ctor === Set ||
|
|
1504
|
+
Ctor === RegExp || Ctor === Error || Ctor === URL || Ctor === URLSearchParams) {
|
|
1505
|
+
const args = node.args.map(a => evaluate(a, scope));
|
|
1506
|
+
return new Ctor(...args);
|
|
1507
|
+
}
|
|
1508
|
+
return undefined;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
case 'binary':
|
|
1512
|
+
return _evalBinary(node, scope);
|
|
1513
|
+
|
|
1514
|
+
case 'unary': {
|
|
1515
|
+
const val = evaluate(node.arg, scope);
|
|
1516
|
+
return node.op === '-' ? -val : +val;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
case 'not':
|
|
1520
|
+
return !evaluate(node.arg, scope);
|
|
1521
|
+
|
|
1522
|
+
case 'typeof': {
|
|
1523
|
+
try {
|
|
1524
|
+
return typeof evaluate(node.arg, scope);
|
|
1525
|
+
} catch {
|
|
1526
|
+
return 'undefined';
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
case 'ternary': {
|
|
1531
|
+
const cond = evaluate(node.cond, scope);
|
|
1532
|
+
return cond ? evaluate(node.truthy, scope) : evaluate(node.falsy, scope);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
case 'array':
|
|
1536
|
+
return node.elements.map(e => evaluate(e, scope));
|
|
1537
|
+
|
|
1538
|
+
case 'object': {
|
|
1539
|
+
const obj = {};
|
|
1540
|
+
for (const { key, value } of node.properties) {
|
|
1541
|
+
obj[key] = evaluate(value, scope);
|
|
1542
|
+
}
|
|
1543
|
+
return obj;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
case 'arrow': {
|
|
1547
|
+
const paramNames = node.params;
|
|
1548
|
+
const bodyNode = node.body;
|
|
1549
|
+
const closedScope = scope;
|
|
1550
|
+
return function(...args) {
|
|
1551
|
+
const arrowScope = {};
|
|
1552
|
+
paramNames.forEach((name, i) => { arrowScope[name] = args[i]; });
|
|
1553
|
+
return evaluate(bodyNode, [arrowScope, ...closedScope]);
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
default:
|
|
1558
|
+
return undefined;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/**
|
|
1563
|
+
* Resolve and execute a function call safely.
|
|
1564
|
+
*/
|
|
1565
|
+
function _resolveCall(node, scope) {
|
|
1566
|
+
const callee = node.callee;
|
|
1567
|
+
const args = node.args.map(a => evaluate(a, scope));
|
|
1568
|
+
|
|
1569
|
+
// Method call: obj.method() — bind `this` to obj
|
|
1570
|
+
if (callee.type === 'member' || callee.type === 'optional_member') {
|
|
1571
|
+
const obj = evaluate(callee.obj, scope);
|
|
1572
|
+
if (obj == null) return undefined;
|
|
1573
|
+
const prop = callee.computed ? evaluate(callee.prop, scope) : callee.prop;
|
|
1574
|
+
if (!_isSafeAccess(obj, prop)) return undefined;
|
|
1575
|
+
const fn = obj[prop];
|
|
1576
|
+
if (typeof fn !== 'function') return undefined;
|
|
1577
|
+
return fn.apply(obj, args);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// Direct call: fn(args)
|
|
1581
|
+
const fn = evaluate(callee, scope);
|
|
1582
|
+
if (typeof fn !== 'function') return undefined;
|
|
1583
|
+
return fn(...args);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/**
|
|
1587
|
+
* Evaluate binary expression.
|
|
1588
|
+
*/
|
|
1589
|
+
function _evalBinary(node, scope) {
|
|
1590
|
+
// Short-circuit for logical ops
|
|
1591
|
+
if (node.op === '&&') {
|
|
1592
|
+
const left = evaluate(node.left, scope);
|
|
1593
|
+
return left ? evaluate(node.right, scope) : left;
|
|
1594
|
+
}
|
|
1595
|
+
if (node.op === '||') {
|
|
1596
|
+
const left = evaluate(node.left, scope);
|
|
1597
|
+
return left ? left : evaluate(node.right, scope);
|
|
1598
|
+
}
|
|
1599
|
+
if (node.op === '??') {
|
|
1600
|
+
const left = evaluate(node.left, scope);
|
|
1601
|
+
return left != null ? left : evaluate(node.right, scope);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
const left = evaluate(node.left, scope);
|
|
1605
|
+
const right = evaluate(node.right, scope);
|
|
1606
|
+
|
|
1607
|
+
switch (node.op) {
|
|
1608
|
+
case '+': return left + right;
|
|
1609
|
+
case '-': return left - right;
|
|
1610
|
+
case '*': return left * right;
|
|
1611
|
+
case '/': return left / right;
|
|
1612
|
+
case '%': return left % right;
|
|
1613
|
+
case '==': return left == right;
|
|
1614
|
+
case '!=': return left != right;
|
|
1615
|
+
case '===': return left === right;
|
|
1616
|
+
case '!==': return left !== right;
|
|
1617
|
+
case '<': return left < right;
|
|
1618
|
+
case '>': return left > right;
|
|
1619
|
+
case '<=': return left <= right;
|
|
1620
|
+
case '>=': return left >= right;
|
|
1621
|
+
case 'instanceof': return left instanceof right;
|
|
1622
|
+
case 'in': return left in right;
|
|
1623
|
+
default: return undefined;
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
// ---------------------------------------------------------------------------
|
|
1629
|
+
// Public API
|
|
1630
|
+
// ---------------------------------------------------------------------------
|
|
1631
|
+
|
|
1632
|
+
/**
|
|
1633
|
+
* Safely evaluate a JS expression string against scope layers.
|
|
1634
|
+
*
|
|
1635
|
+
* @param {string} expr — expression string
|
|
1636
|
+
* @param {object[]} scope — array of scope objects, checked in order
|
|
1637
|
+
* Typical: [loopVars, state, { props, refs, $ }]
|
|
1638
|
+
* @returns {*} — evaluation result, or undefined on error
|
|
1639
|
+
*/
|
|
1640
|
+
function safeEval(expr, scope) {
|
|
1641
|
+
try {
|
|
1642
|
+
const trimmed = expr.trim();
|
|
1643
|
+
if (!trimmed) return undefined;
|
|
1644
|
+
const tokens = tokenize(trimmed);
|
|
1645
|
+
const parser = new Parser(tokens, scope);
|
|
1646
|
+
const ast = parser.parse();
|
|
1647
|
+
return evaluate(ast, scope);
|
|
1648
|
+
} catch (err) {
|
|
1649
|
+
if (typeof console !== 'undefined' && console.debug) {
|
|
1650
|
+
console.debug(`[zQuery EXPR_EVAL] Failed to evaluate: "${expr}"`, err.message);
|
|
1651
|
+
}
|
|
1652
|
+
return undefined;
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// --- src/diff.js —————————————————————————————————————————————————
|
|
1657
|
+
/**
|
|
1658
|
+
* zQuery Diff — Lightweight DOM morphing engine
|
|
1659
|
+
*
|
|
1660
|
+
* Patches an existing DOM tree to match new HTML without destroying nodes
|
|
1661
|
+
* that haven't changed. Preserves focus, scroll positions, third-party
|
|
1662
|
+
* widget state, video playback, and other live DOM state.
|
|
1663
|
+
*
|
|
1664
|
+
* Approach: walk old and new trees in parallel, reconcile node by node.
|
|
1665
|
+
* Keyed elements (via `z-key`) get matched across position changes.
|
|
1666
|
+
*/
|
|
1667
|
+
|
|
1668
|
+
// ---------------------------------------------------------------------------
|
|
1669
|
+
// morph(existingRoot, newHTML) — patch existing DOM to match newHTML
|
|
1670
|
+
// ---------------------------------------------------------------------------
|
|
1671
|
+
|
|
1672
|
+
/**
|
|
1673
|
+
* Morph an existing DOM element's children to match new HTML.
|
|
1674
|
+
* Only touches nodes that actually differ.
|
|
1675
|
+
*
|
|
1676
|
+
* @param {Element} rootEl — The live DOM container to patch
|
|
1677
|
+
* @param {string} newHTML — The desired HTML string
|
|
1678
|
+
*/
|
|
1679
|
+
function morph(rootEl, newHTML) {
|
|
1680
|
+
const template = document.createElement('template');
|
|
1681
|
+
template.innerHTML = newHTML;
|
|
1682
|
+
const newRoot = template.content;
|
|
1683
|
+
|
|
1684
|
+
// Convert to element for consistent handling — wrap in a div if needed
|
|
1685
|
+
const tempDiv = document.createElement('div');
|
|
1686
|
+
while (newRoot.firstChild) tempDiv.appendChild(newRoot.firstChild);
|
|
1687
|
+
|
|
1688
|
+
_morphChildren(rootEl, tempDiv);
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
/**
|
|
1692
|
+
* Reconcile children of `oldParent` to match `newParent`.
|
|
1693
|
+
*
|
|
1694
|
+
* @param {Element} oldParent — live DOM parent
|
|
1695
|
+
* @param {Element} newParent — desired state parent
|
|
1696
|
+
*/
|
|
1697
|
+
function _morphChildren(oldParent, newParent) {
|
|
1698
|
+
const oldChildren = [...oldParent.childNodes];
|
|
1699
|
+
const newChildren = [...newParent.childNodes];
|
|
1700
|
+
|
|
1701
|
+
// Build key maps for keyed element matching
|
|
1702
|
+
const oldKeyMap = new Map();
|
|
1703
|
+
const newKeyMap = new Map();
|
|
1704
|
+
|
|
1705
|
+
for (let i = 0; i < oldChildren.length; i++) {
|
|
1706
|
+
const key = _getKey(oldChildren[i]);
|
|
1707
|
+
if (key != null) oldKeyMap.set(key, i);
|
|
1708
|
+
}
|
|
1709
|
+
for (let i = 0; i < newChildren.length; i++) {
|
|
1710
|
+
const key = _getKey(newChildren[i]);
|
|
1711
|
+
if (key != null) newKeyMap.set(key, i);
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
const hasKeys = oldKeyMap.size > 0 || newKeyMap.size > 0;
|
|
1715
|
+
|
|
1716
|
+
if (hasKeys) {
|
|
1717
|
+
_morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap);
|
|
1718
|
+
} else {
|
|
1719
|
+
_morphChildrenUnkeyed(oldParent, oldChildren, newChildren);
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/**
|
|
1724
|
+
* Unkeyed reconciliation — positional matching.
|
|
1725
|
+
*/
|
|
1726
|
+
function _morphChildrenUnkeyed(oldParent, oldChildren, newChildren) {
|
|
1727
|
+
const maxLen = Math.max(oldChildren.length, newChildren.length);
|
|
1728
|
+
|
|
1729
|
+
for (let i = 0; i < maxLen; i++) {
|
|
1730
|
+
const oldNode = oldChildren[i];
|
|
1731
|
+
const newNode = newChildren[i];
|
|
1732
|
+
|
|
1733
|
+
if (!oldNode && newNode) {
|
|
1734
|
+
// New node — append
|
|
1735
|
+
oldParent.appendChild(newNode.cloneNode(true));
|
|
1736
|
+
} else if (oldNode && !newNode) {
|
|
1737
|
+
// Extra old node — remove
|
|
1738
|
+
oldParent.removeChild(oldNode);
|
|
1739
|
+
} else if (oldNode && newNode) {
|
|
1740
|
+
_morphNode(oldParent, oldNode, newNode);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
/**
|
|
1746
|
+
* Keyed reconciliation — match by z-key, reorder minimal moves.
|
|
1747
|
+
*/
|
|
1748
|
+
function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap) {
|
|
1749
|
+
// Track which old nodes are consumed
|
|
1750
|
+
const consumed = new Set();
|
|
1751
|
+
|
|
1752
|
+
// Step 1: Build ordered list of matched old nodes for new children
|
|
1753
|
+
const newLen = newChildren.length;
|
|
1754
|
+
const matched = new Array(newLen); // matched[newIdx] = oldNode | null
|
|
1755
|
+
|
|
1756
|
+
for (let i = 0; i < newLen; i++) {
|
|
1757
|
+
const key = _getKey(newChildren[i]);
|
|
1758
|
+
if (key != null && oldKeyMap.has(key)) {
|
|
1759
|
+
const oldIdx = oldKeyMap.get(key);
|
|
1760
|
+
matched[i] = oldChildren[oldIdx];
|
|
1761
|
+
consumed.add(oldIdx);
|
|
1762
|
+
} else {
|
|
1763
|
+
matched[i] = null;
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
// Step 2: Remove old nodes that are not in the new tree
|
|
1768
|
+
for (let i = oldChildren.length - 1; i >= 0; i--) {
|
|
1769
|
+
if (!consumed.has(i)) {
|
|
1770
|
+
const key = _getKey(oldChildren[i]);
|
|
1771
|
+
if (key != null && !newKeyMap.has(key)) {
|
|
1772
|
+
oldParent.removeChild(oldChildren[i]);
|
|
1773
|
+
} else if (key == null) {
|
|
1774
|
+
// Unkeyed old node — will be handled positionally below
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
// Step 3: Insert/reorder/morph
|
|
1780
|
+
let cursor = oldParent.firstChild;
|
|
1781
|
+
const unkeyedOld = oldChildren.filter((n, i) => !consumed.has(i) && _getKey(n) == null);
|
|
1782
|
+
let unkeyedIdx = 0;
|
|
1783
|
+
|
|
1784
|
+
for (let i = 0; i < newLen; i++) {
|
|
1785
|
+
const newNode = newChildren[i];
|
|
1786
|
+
const newKey = _getKey(newNode);
|
|
1787
|
+
let oldNode = matched[i];
|
|
1788
|
+
|
|
1789
|
+
if (!oldNode && newKey == null) {
|
|
1790
|
+
// Try to match an unkeyed old node positionally
|
|
1791
|
+
oldNode = unkeyedOld[unkeyedIdx++] || null;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
if (oldNode) {
|
|
1795
|
+
// Move into position if needed
|
|
1796
|
+
if (oldNode !== cursor) {
|
|
1797
|
+
oldParent.insertBefore(oldNode, cursor);
|
|
1798
|
+
}
|
|
1799
|
+
// Morph in place
|
|
1800
|
+
_morphNode(oldParent, oldNode, newNode);
|
|
1801
|
+
cursor = oldNode.nextSibling;
|
|
1802
|
+
} else {
|
|
1803
|
+
// Insert new node
|
|
1804
|
+
const clone = newNode.cloneNode(true);
|
|
1805
|
+
if (cursor) {
|
|
1806
|
+
oldParent.insertBefore(clone, cursor);
|
|
1807
|
+
} else {
|
|
1808
|
+
oldParent.appendChild(clone);
|
|
1809
|
+
}
|
|
1810
|
+
// cursor stays the same — new node is before it
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// Remove any remaining unkeyed old nodes at the end
|
|
1815
|
+
while (unkeyedIdx < unkeyedOld.length) {
|
|
1816
|
+
const leftover = unkeyedOld[unkeyedIdx++];
|
|
1817
|
+
if (leftover.parentNode === oldParent) {
|
|
1818
|
+
oldParent.removeChild(leftover);
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
// Remove any remaining keyed old nodes that weren't consumed
|
|
1823
|
+
for (let i = 0; i < oldChildren.length; i++) {
|
|
1824
|
+
if (!consumed.has(i)) {
|
|
1825
|
+
const node = oldChildren[i];
|
|
1826
|
+
if (node.parentNode === oldParent && _getKey(node) != null && !newKeyMap.has(_getKey(node))) {
|
|
1827
|
+
oldParent.removeChild(node);
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
/**
|
|
1834
|
+
* Morph a single node in place.
|
|
1835
|
+
*/
|
|
1836
|
+
function _morphNode(parent, oldNode, newNode) {
|
|
1837
|
+
// Text / comment nodes — just update content
|
|
1838
|
+
if (oldNode.nodeType === 3 || oldNode.nodeType === 8) {
|
|
1839
|
+
if (newNode.nodeType === oldNode.nodeType) {
|
|
1840
|
+
if (oldNode.nodeValue !== newNode.nodeValue) {
|
|
1841
|
+
oldNode.nodeValue = newNode.nodeValue;
|
|
1842
|
+
}
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
// Different node types — replace
|
|
1846
|
+
parent.replaceChild(newNode.cloneNode(true), oldNode);
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
// Different node types or tag names — replace entirely
|
|
1851
|
+
if (oldNode.nodeType !== newNode.nodeType ||
|
|
1852
|
+
oldNode.nodeName !== newNode.nodeName) {
|
|
1853
|
+
parent.replaceChild(newNode.cloneNode(true), oldNode);
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// Both are elements — diff attributes then recurse children
|
|
1858
|
+
if (oldNode.nodeType === 1) {
|
|
1859
|
+
_morphAttributes(oldNode, newNode);
|
|
1860
|
+
|
|
1861
|
+
// Special elements: don't recurse into their children
|
|
1862
|
+
// (textarea value, input value, select, etc.)
|
|
1863
|
+
const tag = oldNode.nodeName;
|
|
1864
|
+
if (tag === 'INPUT') {
|
|
1865
|
+
_syncInputValue(oldNode, newNode);
|
|
1866
|
+
return;
|
|
1867
|
+
}
|
|
1868
|
+
if (tag === 'TEXTAREA') {
|
|
1869
|
+
if (oldNode.value !== newNode.textContent) {
|
|
1870
|
+
oldNode.value = newNode.textContent || '';
|
|
1871
|
+
}
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
if (tag === 'SELECT') {
|
|
1875
|
+
// Recurse children (options) then sync value
|
|
1876
|
+
_morphChildren(oldNode, newNode);
|
|
1877
|
+
if (oldNode.value !== newNode.value) {
|
|
1878
|
+
oldNode.value = newNode.value;
|
|
1879
|
+
}
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
// Generic element — recurse children
|
|
1884
|
+
_morphChildren(oldNode, newNode);
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
/**
|
|
1889
|
+
* Sync attributes from newEl onto oldEl.
|
|
1890
|
+
*/
|
|
1891
|
+
function _morphAttributes(oldEl, newEl) {
|
|
1892
|
+
// Add/update attributes
|
|
1893
|
+
const newAttrs = newEl.attributes;
|
|
1894
|
+
for (let i = 0; i < newAttrs.length; i++) {
|
|
1895
|
+
const attr = newAttrs[i];
|
|
1896
|
+
if (oldEl.getAttribute(attr.name) !== attr.value) {
|
|
1897
|
+
oldEl.setAttribute(attr.name, attr.value);
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
// Remove stale attributes
|
|
1902
|
+
const oldAttrs = oldEl.attributes;
|
|
1903
|
+
for (let i = oldAttrs.length - 1; i >= 0; i--) {
|
|
1904
|
+
const attr = oldAttrs[i];
|
|
1905
|
+
if (!newEl.hasAttribute(attr.name)) {
|
|
1906
|
+
oldEl.removeAttribute(attr.name);
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
/**
|
|
1912
|
+
* Sync input element value, checked, disabled states.
|
|
1913
|
+
*/
|
|
1914
|
+
function _syncInputValue(oldEl, newEl) {
|
|
1915
|
+
const type = (oldEl.type || '').toLowerCase();
|
|
1916
|
+
|
|
1917
|
+
if (type === 'checkbox' || type === 'radio') {
|
|
1918
|
+
if (oldEl.checked !== newEl.checked) oldEl.checked = newEl.checked;
|
|
1919
|
+
} else {
|
|
1920
|
+
if (oldEl.value !== (newEl.getAttribute('value') || '')) {
|
|
1921
|
+
oldEl.value = newEl.getAttribute('value') || '';
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
// Sync disabled
|
|
1926
|
+
if (oldEl.disabled !== newEl.disabled) oldEl.disabled = newEl.disabled;
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
/**
|
|
1930
|
+
* Get the reconciliation key from a node (z-key attribute).
|
|
1931
|
+
* @returns {string|null}
|
|
1932
|
+
*/
|
|
1933
|
+
function _getKey(node) {
|
|
1934
|
+
if (node.nodeType !== 1) return null;
|
|
1935
|
+
return node.getAttribute('z-key') || null;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
670
1938
|
// --- src/component.js ————————————————————————————————————————————
|
|
671
1939
|
/**
|
|
672
1940
|
* zQuery Component — Lightweight reactive component system
|
|
@@ -690,6 +1958,9 @@ query.fn = ZQueryCollection.prototype;
|
|
|
690
1958
|
*/
|
|
691
1959
|
|
|
692
1960
|
|
|
1961
|
+
|
|
1962
|
+
|
|
1963
|
+
|
|
693
1964
|
// ---------------------------------------------------------------------------
|
|
694
1965
|
// Component registry & external resource cache
|
|
695
1966
|
// ---------------------------------------------------------------------------
|
|
@@ -864,10 +2135,27 @@ class Component {
|
|
|
864
2135
|
this._destroyed = false;
|
|
865
2136
|
this._updateQueued = false;
|
|
866
2137
|
this._listeners = [];
|
|
2138
|
+
this._watchCleanups = [];
|
|
867
2139
|
|
|
868
2140
|
// Refs map
|
|
869
2141
|
this.refs = {};
|
|
870
2142
|
|
|
2143
|
+
// Capture slot content before first render replaces it
|
|
2144
|
+
this._slotContent = {};
|
|
2145
|
+
const defaultSlotNodes = [];
|
|
2146
|
+
[...el.childNodes].forEach(node => {
|
|
2147
|
+
if (node.nodeType === 1 && node.hasAttribute('slot')) {
|
|
2148
|
+
const slotName = node.getAttribute('slot');
|
|
2149
|
+
if (!this._slotContent[slotName]) this._slotContent[slotName] = '';
|
|
2150
|
+
this._slotContent[slotName] += node.outerHTML;
|
|
2151
|
+
} else if (node.nodeType === 1 || (node.nodeType === 3 && node.textContent.trim())) {
|
|
2152
|
+
defaultSlotNodes.push(node.nodeType === 1 ? node.outerHTML : node.textContent);
|
|
2153
|
+
}
|
|
2154
|
+
});
|
|
2155
|
+
if (defaultSlotNodes.length) {
|
|
2156
|
+
this._slotContent['default'] = defaultSlotNodes.join('');
|
|
2157
|
+
}
|
|
2158
|
+
|
|
871
2159
|
// Props (read-only from parent)
|
|
872
2160
|
this.props = Object.freeze({ ...props });
|
|
873
2161
|
|
|
@@ -876,10 +2164,25 @@ class Component {
|
|
|
876
2164
|
? definition.state()
|
|
877
2165
|
: { ...(definition.state || {}) };
|
|
878
2166
|
|
|
879
|
-
this.state = reactive(initialState, () => {
|
|
880
|
-
if (!this._destroyed)
|
|
2167
|
+
this.state = reactive(initialState, (key, value, old) => {
|
|
2168
|
+
if (!this._destroyed) {
|
|
2169
|
+
// Run watchers for the changed key
|
|
2170
|
+
this._runWatchers(key, value, old);
|
|
2171
|
+
this._scheduleUpdate();
|
|
2172
|
+
}
|
|
881
2173
|
});
|
|
882
2174
|
|
|
2175
|
+
// Computed properties — lazy getters derived from state
|
|
2176
|
+
this.computed = {};
|
|
2177
|
+
if (definition.computed) {
|
|
2178
|
+
for (const [name, fn] of Object.entries(definition.computed)) {
|
|
2179
|
+
Object.defineProperty(this.computed, name, {
|
|
2180
|
+
get: () => fn.call(this, this.state.__raw || this.state),
|
|
2181
|
+
enumerable: true
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
|
|
883
2186
|
// Bind all user methods to this instance
|
|
884
2187
|
for (const [key, val] of Object.entries(definition)) {
|
|
885
2188
|
if (typeof val === 'function' && !_reservedKeys.has(key)) {
|
|
@@ -888,7 +2191,36 @@ class Component {
|
|
|
888
2191
|
}
|
|
889
2192
|
|
|
890
2193
|
// Init lifecycle
|
|
891
|
-
if (definition.init)
|
|
2194
|
+
if (definition.init) {
|
|
2195
|
+
try { definition.init.call(this); }
|
|
2196
|
+
catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${definition._name}" init() threw`, { component: definition._name }, err); }
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
// Set up watchers after init so initial state is ready
|
|
2200
|
+
if (definition.watch) {
|
|
2201
|
+
this._prevWatchValues = {};
|
|
2202
|
+
for (const key of Object.keys(definition.watch)) {
|
|
2203
|
+
this._prevWatchValues[key] = _getPath(this.state.__raw || this.state, key);
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
// Run registered watchers for a changed key
|
|
2209
|
+
_runWatchers(changedKey, value, old) {
|
|
2210
|
+
const watchers = this._def.watch;
|
|
2211
|
+
if (!watchers) return;
|
|
2212
|
+
for (const [key, handler] of Object.entries(watchers)) {
|
|
2213
|
+
// Match exact key or parent key (e.g. watcher on 'user' fires when 'user.name' changes)
|
|
2214
|
+
if (changedKey === key || key.startsWith(changedKey + '.') || changedKey.startsWith(key + '.') || changedKey === key) {
|
|
2215
|
+
const currentVal = _getPath(this.state.__raw || this.state, key);
|
|
2216
|
+
const prevVal = this._prevWatchValues?.[key];
|
|
2217
|
+
if (currentVal !== prevVal) {
|
|
2218
|
+
const fn = typeof handler === 'function' ? handler : handler.handler;
|
|
2219
|
+
if (typeof fn === 'function') fn.call(this, currentVal, prevVal);
|
|
2220
|
+
if (this._prevWatchValues) this._prevWatchValues[key] = currentVal;
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
892
2224
|
}
|
|
893
2225
|
|
|
894
2226
|
// Schedule a batched DOM update (microtask)
|
|
@@ -1063,17 +2395,29 @@ class Component {
|
|
|
1063
2395
|
// Then do global {{expression}} interpolation on the remaining content
|
|
1064
2396
|
html = html.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
|
|
1065
2397
|
try {
|
|
1066
|
-
|
|
2398
|
+
const result = safeEval(expr.trim(), [
|
|
1067
2399
|
this.state.__raw || this.state,
|
|
1068
|
-
this.props,
|
|
1069
|
-
|
|
1070
|
-
|
|
2400
|
+
{ props: this.props, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
|
|
2401
|
+
]);
|
|
2402
|
+
return result != null ? result : '';
|
|
1071
2403
|
} catch { return ''; }
|
|
1072
2404
|
});
|
|
1073
2405
|
} else {
|
|
1074
2406
|
html = '';
|
|
1075
2407
|
}
|
|
1076
2408
|
|
|
2409
|
+
// -- Slot distribution ----------------------------------------
|
|
2410
|
+
// Replace <slot> elements with captured slot content from parent.
|
|
2411
|
+
// <slot> → default slot content
|
|
2412
|
+
// <slot name="header"> → named slot content
|
|
2413
|
+
// Fallback content between <slot>...</slot> used when no content provided.
|
|
2414
|
+
if (html.includes('<slot')) {
|
|
2415
|
+
html = html.replace(/<slot(?:\s+name="([^"]*)")?\s*(?:\/>|>([\s\S]*?)<\/slot>)/g, (_, name, fallback) => {
|
|
2416
|
+
const slotName = name || 'default';
|
|
2417
|
+
return this._slotContent[slotName] || fallback || '';
|
|
2418
|
+
});
|
|
2419
|
+
}
|
|
2420
|
+
|
|
1077
2421
|
// Combine inline styles + external styles
|
|
1078
2422
|
const combinedStyles = [
|
|
1079
2423
|
this._def.styles || '',
|
|
@@ -1084,7 +2428,22 @@ class Component {
|
|
|
1084
2428
|
if (!this._mounted && combinedStyles) {
|
|
1085
2429
|
const scopeAttr = `z-s${this._uid}`;
|
|
1086
2430
|
this._el.setAttribute(scopeAttr, '');
|
|
1087
|
-
|
|
2431
|
+
let inAtBlock = 0;
|
|
2432
|
+
const scoped = combinedStyles.replace(/([^{}]+)\{|\}/g, (match, selector) => {
|
|
2433
|
+
if (match === '}') {
|
|
2434
|
+
if (inAtBlock > 0) inAtBlock--;
|
|
2435
|
+
return match;
|
|
2436
|
+
}
|
|
2437
|
+
const trimmed = selector.trim();
|
|
2438
|
+
// Don't scope @-rules (@media, @keyframes, @supports, @container, @layer, @font-face, etc.)
|
|
2439
|
+
if (trimmed.startsWith('@')) {
|
|
2440
|
+
inAtBlock++;
|
|
2441
|
+
return match;
|
|
2442
|
+
}
|
|
2443
|
+
// Don't scope keyframe stops (from, to, 0%, 50%, etc.)
|
|
2444
|
+
if (inAtBlock > 0 && /^[\d%\s,fromto]+$/.test(trimmed.replace(/\s/g, ''))) {
|
|
2445
|
+
return match;
|
|
2446
|
+
}
|
|
1088
2447
|
return selector.split(',').map(s => `[${scopeAttr}] ${s.trim()}`).join(', ') + ' {';
|
|
1089
2448
|
});
|
|
1090
2449
|
const styleEl = document.createElement('style');
|
|
@@ -1095,22 +2454,19 @@ class Component {
|
|
|
1095
2454
|
}
|
|
1096
2455
|
|
|
1097
2456
|
// -- Focus preservation ----------------------------------------
|
|
1098
|
-
//
|
|
1099
|
-
//
|
|
1100
|
-
// input/textarea/select inside the component, not only z-model.
|
|
2457
|
+
// DOM morphing preserves unchanged nodes naturally, but we still
|
|
2458
|
+
// track focus for cases where the focused element's subtree changes.
|
|
1101
2459
|
let _focusInfo = null;
|
|
1102
2460
|
const _active = document.activeElement;
|
|
1103
2461
|
if (_active && this._el.contains(_active)) {
|
|
1104
2462
|
const modelKey = _active.getAttribute?.('z-model');
|
|
1105
2463
|
const refKey = _active.getAttribute?.('z-ref');
|
|
1106
|
-
// Build a selector that can locate the same element after re-render
|
|
1107
2464
|
let selector = null;
|
|
1108
2465
|
if (modelKey) {
|
|
1109
2466
|
selector = `[z-model="${modelKey}"]`;
|
|
1110
2467
|
} else if (refKey) {
|
|
1111
2468
|
selector = `[z-ref="${refKey}"]`;
|
|
1112
2469
|
} else {
|
|
1113
|
-
// Fallback: match by tag + type + name + placeholder combination
|
|
1114
2470
|
const tag = _active.tagName.toLowerCase();
|
|
1115
2471
|
if (tag === 'input' || tag === 'textarea' || tag === 'select') {
|
|
1116
2472
|
let s = tag;
|
|
@@ -1130,8 +2486,13 @@ class Component {
|
|
|
1130
2486
|
}
|
|
1131
2487
|
}
|
|
1132
2488
|
|
|
1133
|
-
// Update DOM
|
|
1134
|
-
|
|
2489
|
+
// Update DOM via morphing (diffing) — preserves unchanged nodes
|
|
2490
|
+
// First render uses innerHTML for speed; subsequent renders morph.
|
|
2491
|
+
if (!this._mounted) {
|
|
2492
|
+
this._el.innerHTML = html;
|
|
2493
|
+
} else {
|
|
2494
|
+
morph(this._el, html);
|
|
2495
|
+
}
|
|
1135
2496
|
|
|
1136
2497
|
// Process structural & attribute directives
|
|
1137
2498
|
this._processDirectives();
|
|
@@ -1141,10 +2502,10 @@ class Component {
|
|
|
1141
2502
|
this._bindRefs();
|
|
1142
2503
|
this._bindModels();
|
|
1143
2504
|
|
|
1144
|
-
// Restore focus
|
|
2505
|
+
// Restore focus if the morph replaced the focused element
|
|
1145
2506
|
if (_focusInfo) {
|
|
1146
2507
|
const el = this._el.querySelector(_focusInfo.selector);
|
|
1147
|
-
if (el) {
|
|
2508
|
+
if (el && el !== document.activeElement) {
|
|
1148
2509
|
el.focus();
|
|
1149
2510
|
try {
|
|
1150
2511
|
if (_focusInfo.start !== null && _focusInfo.start !== undefined) {
|
|
@@ -1159,9 +2520,15 @@ class Component {
|
|
|
1159
2520
|
|
|
1160
2521
|
if (!this._mounted) {
|
|
1161
2522
|
this._mounted = true;
|
|
1162
|
-
if (this._def.mounted)
|
|
2523
|
+
if (this._def.mounted) {
|
|
2524
|
+
try { this._def.mounted.call(this); }
|
|
2525
|
+
catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${this._def._name}" mounted() threw`, { component: this._def._name }, err); }
|
|
2526
|
+
}
|
|
1163
2527
|
} else {
|
|
1164
|
-
if (this._def.updated)
|
|
2528
|
+
if (this._def.updated) {
|
|
2529
|
+
try { this._def.updated.call(this); }
|
|
2530
|
+
catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${this._def._name}" updated() threw`, { component: this._def._name }, err); }
|
|
2531
|
+
}
|
|
1165
2532
|
}
|
|
1166
2533
|
}
|
|
1167
2534
|
|
|
@@ -1370,18 +2737,13 @@ class Component {
|
|
|
1370
2737
|
}
|
|
1371
2738
|
|
|
1372
2739
|
// ---------------------------------------------------------------------------
|
|
1373
|
-
// Expression evaluator —
|
|
2740
|
+
// Expression evaluator — CSP-safe parser (no eval / new Function)
|
|
1374
2741
|
// ---------------------------------------------------------------------------
|
|
1375
2742
|
_evalExpr(expr) {
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
this.props,
|
|
1381
|
-
this.refs,
|
|
1382
|
-
typeof window !== 'undefined' ? window.$ : undefined
|
|
1383
|
-
);
|
|
1384
|
-
} catch { return undefined; }
|
|
2743
|
+
return safeEval(expr, [
|
|
2744
|
+
this.state.__raw || this.state,
|
|
2745
|
+
{ props: this.props, refs: this.refs, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
|
|
2746
|
+
]);
|
|
1385
2747
|
}
|
|
1386
2748
|
|
|
1387
2749
|
// ---------------------------------------------------------------------------
|
|
@@ -1443,13 +2805,15 @@ class Component {
|
|
|
1443
2805
|
const evalReplace = (str, item, index) =>
|
|
1444
2806
|
str.replace(/\{\{(.+?)\}\}/g, (_, inner) => {
|
|
1445
2807
|
try {
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
2808
|
+
const loopScope = {};
|
|
2809
|
+
loopScope[itemVar] = item;
|
|
2810
|
+
loopScope[indexVar] = index;
|
|
2811
|
+
const result = safeEval(inner.trim(), [
|
|
2812
|
+
loopScope,
|
|
1449
2813
|
this.state.__raw || this.state,
|
|
1450
|
-
this.props,
|
|
1451
|
-
|
|
1452
|
-
|
|
2814
|
+
{ props: this.props, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
|
|
2815
|
+
]);
|
|
2816
|
+
return result != null ? result : '';
|
|
1453
2817
|
} catch { return ''; }
|
|
1454
2818
|
});
|
|
1455
2819
|
|
|
@@ -1614,7 +2978,10 @@ class Component {
|
|
|
1614
2978
|
destroy() {
|
|
1615
2979
|
if (this._destroyed) return;
|
|
1616
2980
|
this._destroyed = true;
|
|
1617
|
-
if (this._def.destroyed)
|
|
2981
|
+
if (this._def.destroyed) {
|
|
2982
|
+
try { this._def.destroyed.call(this); }
|
|
2983
|
+
catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${this._def._name}" destroyed() threw`, { component: this._def._name }, err); }
|
|
2984
|
+
}
|
|
1618
2985
|
this._listeners.forEach(({ event, handler }) => this._el.removeEventListener(event, handler));
|
|
1619
2986
|
this._listeners = [];
|
|
1620
2987
|
if (this._styleEl) this._styleEl.remove();
|
|
@@ -1627,7 +2994,8 @@ class Component {
|
|
|
1627
2994
|
// Reserved definition keys (not user methods)
|
|
1628
2995
|
const _reservedKeys = new Set([
|
|
1629
2996
|
'state', 'render', 'styles', 'init', 'mounted', 'updated', 'destroyed', 'props',
|
|
1630
|
-
'templateUrl', 'styleUrl', 'templates', 'pages', 'activePage', 'base'
|
|
2997
|
+
'templateUrl', 'styleUrl', 'templates', 'pages', 'activePage', 'base',
|
|
2998
|
+
'computed', 'watch'
|
|
1631
2999
|
]);
|
|
1632
3000
|
|
|
1633
3001
|
|
|
@@ -1641,8 +3009,11 @@ const _reservedKeys = new Set([
|
|
|
1641
3009
|
* @param {object} definition — component definition
|
|
1642
3010
|
*/
|
|
1643
3011
|
function component(name, definition) {
|
|
3012
|
+
if (!name || typeof name !== 'string') {
|
|
3013
|
+
throw new ZQueryError(ErrorCode.COMP_INVALID_NAME, 'Component name must be a non-empty string');
|
|
3014
|
+
}
|
|
1644
3015
|
if (!name.includes('-')) {
|
|
1645
|
-
throw new
|
|
3016
|
+
throw new ZQueryError(ErrorCode.COMP_INVALID_NAME, `Component name "${name}" must contain a hyphen (Web Component convention)`);
|
|
1646
3017
|
}
|
|
1647
3018
|
definition._name = name;
|
|
1648
3019
|
|
|
@@ -1667,10 +3038,10 @@ function component(name, definition) {
|
|
|
1667
3038
|
*/
|
|
1668
3039
|
function mount(target, componentName, props = {}) {
|
|
1669
3040
|
const el = typeof target === 'string' ? document.querySelector(target) : target;
|
|
1670
|
-
if (!el) throw new
|
|
3041
|
+
if (!el) throw new ZQueryError(ErrorCode.COMP_MOUNT_TARGET, `Mount target "${target}" not found`, { target });
|
|
1671
3042
|
|
|
1672
3043
|
const def = _registry.get(componentName);
|
|
1673
|
-
if (!def) throw new
|
|
3044
|
+
if (!def) throw new ZQueryError(ErrorCode.COMP_NOT_FOUND, `Component "${componentName}" not registered`, { component: componentName });
|
|
1674
3045
|
|
|
1675
3046
|
// Destroy existing instance
|
|
1676
3047
|
if (_instances.has(el)) _instances.get(el).destroy();
|
|
@@ -1693,12 +3064,40 @@ function mountAll(root = document.body) {
|
|
|
1693
3064
|
|
|
1694
3065
|
// Extract props from attributes
|
|
1695
3066
|
const props = {};
|
|
3067
|
+
|
|
3068
|
+
// Find parent component instance for evaluating dynamic prop expressions
|
|
3069
|
+
let parentInstance = null;
|
|
3070
|
+
let ancestor = tag.parentElement;
|
|
3071
|
+
while (ancestor) {
|
|
3072
|
+
if (_instances.has(ancestor)) {
|
|
3073
|
+
parentInstance = _instances.get(ancestor);
|
|
3074
|
+
break;
|
|
3075
|
+
}
|
|
3076
|
+
ancestor = ancestor.parentElement;
|
|
3077
|
+
}
|
|
3078
|
+
|
|
1696
3079
|
[...tag.attributes].forEach(attr => {
|
|
1697
|
-
if (
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
3080
|
+
if (attr.name.startsWith('@') || attr.name.startsWith('z-')) return;
|
|
3081
|
+
|
|
3082
|
+
// Dynamic prop: :propName="expression" — evaluate in parent context
|
|
3083
|
+
if (attr.name.startsWith(':')) {
|
|
3084
|
+
const propName = attr.name.slice(1);
|
|
3085
|
+
if (parentInstance) {
|
|
3086
|
+
props[propName] = safeEval(attr.value, [
|
|
3087
|
+
parentInstance.state.__raw || parentInstance.state,
|
|
3088
|
+
{ props: parentInstance.props, refs: parentInstance.refs, computed: parentInstance.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
|
|
3089
|
+
]);
|
|
3090
|
+
} else {
|
|
3091
|
+
// No parent — try JSON parse
|
|
3092
|
+
try { props[propName] = JSON.parse(attr.value); }
|
|
3093
|
+
catch { props[propName] = attr.value; }
|
|
3094
|
+
}
|
|
3095
|
+
return;
|
|
1701
3096
|
}
|
|
3097
|
+
|
|
3098
|
+
// Static prop
|
|
3099
|
+
try { props[attr.name] = JSON.parse(attr.value); }
|
|
3100
|
+
catch { props[attr.name] = attr.value; }
|
|
1702
3101
|
});
|
|
1703
3102
|
|
|
1704
3103
|
const instance = new Component(tag, def, props);
|
|
@@ -1857,6 +3256,7 @@ function style(urls, opts = {}) {
|
|
|
1857
3256
|
*/
|
|
1858
3257
|
|
|
1859
3258
|
|
|
3259
|
+
|
|
1860
3260
|
class Router {
|
|
1861
3261
|
constructor(config = {}) {
|
|
1862
3262
|
this._el = null;
|
|
@@ -2127,10 +3527,15 @@ class Router {
|
|
|
2127
3527
|
|
|
2128
3528
|
// Run before guards
|
|
2129
3529
|
for (const guard of this._guards.before) {
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
3530
|
+
try {
|
|
3531
|
+
const result = await guard(to, from);
|
|
3532
|
+
if (result === false) return; // Cancel
|
|
3533
|
+
if (typeof result === 'string') { // Redirect
|
|
3534
|
+
return this.navigate(result);
|
|
3535
|
+
}
|
|
3536
|
+
} catch (err) {
|
|
3537
|
+
reportError(ErrorCode.ROUTER_GUARD, 'Before-guard threw', { to, from }, err);
|
|
3538
|
+
return;
|
|
2134
3539
|
}
|
|
2135
3540
|
}
|
|
2136
3541
|
|
|
@@ -2138,7 +3543,7 @@ class Router {
|
|
|
2138
3543
|
if (matched.load) {
|
|
2139
3544
|
try { await matched.load(); }
|
|
2140
3545
|
catch (err) {
|
|
2141
|
-
|
|
3546
|
+
reportError(ErrorCode.ROUTER_LOAD, `Failed to load module for route "${matched.path}"`, { path: matched.path }, err);
|
|
2142
3547
|
return;
|
|
2143
3548
|
}
|
|
2144
3549
|
}
|
|
@@ -2234,6 +3639,7 @@ function getRouter() {
|
|
|
2234
3639
|
*/
|
|
2235
3640
|
|
|
2236
3641
|
|
|
3642
|
+
|
|
2237
3643
|
class Store {
|
|
2238
3644
|
constructor(config = {}) {
|
|
2239
3645
|
this._subscribers = new Map(); // key → Set<fn>
|
|
@@ -2250,9 +3656,15 @@ class Store {
|
|
|
2250
3656
|
this.state = reactive(initial, (key, value, old) => {
|
|
2251
3657
|
// Notify key-specific subscribers
|
|
2252
3658
|
const subs = this._subscribers.get(key);
|
|
2253
|
-
if (subs) subs.forEach(fn =>
|
|
3659
|
+
if (subs) subs.forEach(fn => {
|
|
3660
|
+
try { fn(value, old, key); }
|
|
3661
|
+
catch (err) { reportError(ErrorCode.STORE_SUBSCRIBE, `Subscriber for "${key}" threw`, { key }, err); }
|
|
3662
|
+
});
|
|
2254
3663
|
// Notify wildcard subscribers
|
|
2255
|
-
this._wildcards.forEach(fn =>
|
|
3664
|
+
this._wildcards.forEach(fn => {
|
|
3665
|
+
try { fn(key, value, old); }
|
|
3666
|
+
catch (err) { reportError(ErrorCode.STORE_SUBSCRIBE, 'Wildcard subscriber threw', { key }, err); }
|
|
3667
|
+
});
|
|
2256
3668
|
});
|
|
2257
3669
|
|
|
2258
3670
|
// Build getters as computed properties
|
|
@@ -2273,23 +3685,32 @@ class Store {
|
|
|
2273
3685
|
dispatch(name, ...args) {
|
|
2274
3686
|
const action = this._actions[name];
|
|
2275
3687
|
if (!action) {
|
|
2276
|
-
|
|
3688
|
+
reportError(ErrorCode.STORE_ACTION, `Unknown action "${name}"`, { action: name, args });
|
|
2277
3689
|
return;
|
|
2278
3690
|
}
|
|
2279
3691
|
|
|
2280
3692
|
// Run middleware
|
|
2281
3693
|
for (const mw of this._middleware) {
|
|
2282
|
-
|
|
2283
|
-
|
|
3694
|
+
try {
|
|
3695
|
+
const result = mw(name, args, this.state);
|
|
3696
|
+
if (result === false) return; // blocked by middleware
|
|
3697
|
+
} catch (err) {
|
|
3698
|
+
reportError(ErrorCode.STORE_MIDDLEWARE, `Middleware threw during "${name}"`, { action: name }, err);
|
|
3699
|
+
return;
|
|
3700
|
+
}
|
|
2284
3701
|
}
|
|
2285
3702
|
|
|
2286
3703
|
if (this._debug) {
|
|
2287
3704
|
console.log(`%c[Store] ${name}`, 'color: #4CAF50; font-weight: bold;', ...args);
|
|
2288
3705
|
}
|
|
2289
3706
|
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
3707
|
+
try {
|
|
3708
|
+
const result = action(this.state, ...args);
|
|
3709
|
+
this._history.push({ action: name, args, timestamp: Date.now() });
|
|
3710
|
+
return result;
|
|
3711
|
+
} catch (err) {
|
|
3712
|
+
reportError(ErrorCode.STORE_ACTION, `Action "${name}" threw`, { action: name, args }, err);
|
|
3713
|
+
}
|
|
2293
3714
|
}
|
|
2294
3715
|
|
|
2295
3716
|
/**
|
|
@@ -2408,6 +3829,9 @@ const _interceptors = {
|
|
|
2408
3829
|
* Core request function
|
|
2409
3830
|
*/
|
|
2410
3831
|
async function request(method, url, data, options = {}) {
|
|
3832
|
+
if (!url || typeof url !== 'string') {
|
|
3833
|
+
throw new Error(`HTTP request requires a URL string, got ${typeof url}`);
|
|
3834
|
+
}
|
|
2411
3835
|
let fullURL = url.startsWith('http') ? url : _config.baseURL + url;
|
|
2412
3836
|
let headers = { ..._config.headers, ...options.headers };
|
|
2413
3837
|
let body = undefined;
|
|
@@ -2463,16 +3887,21 @@ async function request(method, url, data, options = {}) {
|
|
|
2463
3887
|
const contentType = response.headers.get('Content-Type') || '';
|
|
2464
3888
|
let responseData;
|
|
2465
3889
|
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
3890
|
+
try {
|
|
3891
|
+
if (contentType.includes('application/json')) {
|
|
3892
|
+
responseData = await response.json();
|
|
3893
|
+
} else if (contentType.includes('text/')) {
|
|
3894
|
+
responseData = await response.text();
|
|
3895
|
+
} else if (contentType.includes('application/octet-stream') || contentType.includes('image/')) {
|
|
3896
|
+
responseData = await response.blob();
|
|
3897
|
+
} else {
|
|
3898
|
+
// Try JSON first, fall back to text
|
|
3899
|
+
const text = await response.text();
|
|
3900
|
+
try { responseData = JSON.parse(text); } catch { responseData = text; }
|
|
3901
|
+
}
|
|
3902
|
+
} catch (parseErr) {
|
|
3903
|
+
responseData = null;
|
|
3904
|
+
console.warn(`[zQuery HTTP] Failed to parse response body from ${method} ${fullURL}:`, parseErr.message);
|
|
2476
3905
|
}
|
|
2477
3906
|
|
|
2478
3907
|
const result = {
|
|
@@ -2846,21 +4275,24 @@ const bus = new EventBus();
|
|
|
2846
4275
|
|
|
2847
4276
|
|
|
2848
4277
|
|
|
4278
|
+
|
|
4279
|
+
|
|
4280
|
+
|
|
2849
4281
|
// ---------------------------------------------------------------------------
|
|
2850
4282
|
// $ — The main function & namespace
|
|
2851
4283
|
// ---------------------------------------------------------------------------
|
|
2852
4284
|
|
|
2853
4285
|
/**
|
|
2854
|
-
* Main selector function
|
|
4286
|
+
* Main selector function — always returns a ZQueryCollection (like jQuery).
|
|
2855
4287
|
*
|
|
2856
|
-
* $('selector') →
|
|
2857
|
-
* $('<div>hello</div>') →
|
|
2858
|
-
* $(element) →
|
|
4288
|
+
* $('selector') → ZQueryCollection (querySelectorAll)
|
|
4289
|
+
* $('<div>hello</div>') → ZQueryCollection from created elements
|
|
4290
|
+
* $(element) → ZQueryCollection wrapping the element
|
|
2859
4291
|
* $(fn) → DOMContentLoaded shorthand
|
|
2860
4292
|
*
|
|
2861
4293
|
* @param {string|Element|NodeList|Function} selector
|
|
2862
4294
|
* @param {string|Element} [context]
|
|
2863
|
-
* @returns {
|
|
4295
|
+
* @returns {ZQueryCollection}
|
|
2864
4296
|
*/
|
|
2865
4297
|
function $(selector, context) {
|
|
2866
4298
|
// $(fn) → DOM ready shorthand
|
|
@@ -2880,8 +4312,6 @@ $.tag = query.tag;
|
|
|
2880
4312
|
Object.defineProperty($, 'name', {
|
|
2881
4313
|
value: query.name, writable: true, configurable: true
|
|
2882
4314
|
});
|
|
2883
|
-
$.attr = query.attr;
|
|
2884
|
-
$.data = query.data;
|
|
2885
4315
|
$.children = query.children;
|
|
2886
4316
|
|
|
2887
4317
|
// --- Collection selector ---------------------------------------------------
|
|
@@ -2923,6 +4353,8 @@ $.getInstance = getInstance;
|
|
|
2923
4353
|
$.destroy = destroy;
|
|
2924
4354
|
$.components = getRegistry;
|
|
2925
4355
|
$.style = style;
|
|
4356
|
+
$.morph = morph;
|
|
4357
|
+
$.safeEval = safeEval;
|
|
2926
4358
|
|
|
2927
4359
|
// --- Router ----------------------------------------------------------------
|
|
2928
4360
|
$.router = createRouter;
|
|
@@ -2961,8 +4393,13 @@ $.storage = storage;
|
|
|
2961
4393
|
$.session = session;
|
|
2962
4394
|
$.bus = bus;
|
|
2963
4395
|
|
|
4396
|
+
// --- Error handling --------------------------------------------------------
|
|
4397
|
+
$.onError = onError;
|
|
4398
|
+
$.ZQueryError = ZQueryError;
|
|
4399
|
+
$.ErrorCode = ErrorCode;
|
|
4400
|
+
|
|
2964
4401
|
// --- Meta ------------------------------------------------------------------
|
|
2965
|
-
$.version = '0.
|
|
4402
|
+
$.version = '0.6.3';
|
|
2966
4403
|
$.meta = {}; // populated at build time by CLI bundler
|
|
2967
4404
|
|
|
2968
4405
|
$.noConflict = () => {
|