zero-query 0.4.9 → 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 +16 -10
- package/cli/commands/build.js +4 -2
- package/cli/commands/bundle.js +113 -10
- 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/commands/{dev.js → dev.old.js} +8 -4
- package/cli/help.js +18 -6
- 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 +1550 -97
- package/dist/zquery.min.js +11 -8
- package/index.d.ts +253 -14
- package/index.js +25 -8
- package/package.json +8 -2
- package/src/component.js +175 -44
- package/src/core.js +25 -18
- 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/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,11 +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)));
|
|
792
|
+
Object.defineProperty(query, 'name', {
|
|
793
|
+
value: (name) => new ZQueryCollection(Array.from(document.getElementsByName(name))),
|
|
794
|
+
writable: true, configurable: true
|
|
795
|
+
});
|
|
608
796
|
query.children = (parentId) => {
|
|
609
797
|
const p = document.getElementById(parentId);
|
|
610
|
-
return p ? Array.from(p.children) : [];
|
|
798
|
+
return new ZQueryCollection(p ? Array.from(p.children) : []);
|
|
611
799
|
};
|
|
612
800
|
|
|
613
801
|
// Create element shorthand
|
|
@@ -657,6 +845,1096 @@ query.off = (event, handler) => {
|
|
|
657
845
|
// Extend collection prototype (like $.fn in jQuery)
|
|
658
846
|
query.fn = ZQueryCollection.prototype;
|
|
659
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
|
+
|
|
660
1938
|
// --- src/component.js ————————————————————————————————————————————
|
|
661
1939
|
/**
|
|
662
1940
|
* zQuery Component — Lightweight reactive component system
|
|
@@ -680,6 +1958,9 @@ query.fn = ZQueryCollection.prototype;
|
|
|
680
1958
|
*/
|
|
681
1959
|
|
|
682
1960
|
|
|
1961
|
+
|
|
1962
|
+
|
|
1963
|
+
|
|
683
1964
|
// ---------------------------------------------------------------------------
|
|
684
1965
|
// Component registry & external resource cache
|
|
685
1966
|
// ---------------------------------------------------------------------------
|
|
@@ -854,10 +2135,27 @@ class Component {
|
|
|
854
2135
|
this._destroyed = false;
|
|
855
2136
|
this._updateQueued = false;
|
|
856
2137
|
this._listeners = [];
|
|
2138
|
+
this._watchCleanups = [];
|
|
857
2139
|
|
|
858
2140
|
// Refs map
|
|
859
2141
|
this.refs = {};
|
|
860
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
|
+
|
|
861
2159
|
// Props (read-only from parent)
|
|
862
2160
|
this.props = Object.freeze({ ...props });
|
|
863
2161
|
|
|
@@ -866,10 +2164,25 @@ class Component {
|
|
|
866
2164
|
? definition.state()
|
|
867
2165
|
: { ...(definition.state || {}) };
|
|
868
2166
|
|
|
869
|
-
this.state = reactive(initialState, () => {
|
|
870
|
-
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
|
+
}
|
|
871
2173
|
});
|
|
872
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
|
+
|
|
873
2186
|
// Bind all user methods to this instance
|
|
874
2187
|
for (const [key, val] of Object.entries(definition)) {
|
|
875
2188
|
if (typeof val === 'function' && !_reservedKeys.has(key)) {
|
|
@@ -878,7 +2191,36 @@ class Component {
|
|
|
878
2191
|
}
|
|
879
2192
|
|
|
880
2193
|
// Init lifecycle
|
|
881
|
-
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
|
+
}
|
|
882
2224
|
}
|
|
883
2225
|
|
|
884
2226
|
// Schedule a batched DOM update (microtask)
|
|
@@ -1053,17 +2395,29 @@ class Component {
|
|
|
1053
2395
|
// Then do global {{expression}} interpolation on the remaining content
|
|
1054
2396
|
html = html.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
|
|
1055
2397
|
try {
|
|
1056
|
-
|
|
2398
|
+
const result = safeEval(expr.trim(), [
|
|
1057
2399
|
this.state.__raw || this.state,
|
|
1058
|
-
this.props,
|
|
1059
|
-
|
|
1060
|
-
|
|
2400
|
+
{ props: this.props, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
|
|
2401
|
+
]);
|
|
2402
|
+
return result != null ? result : '';
|
|
1061
2403
|
} catch { return ''; }
|
|
1062
2404
|
});
|
|
1063
2405
|
} else {
|
|
1064
2406
|
html = '';
|
|
1065
2407
|
}
|
|
1066
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
|
+
|
|
1067
2421
|
// Combine inline styles + external styles
|
|
1068
2422
|
const combinedStyles = [
|
|
1069
2423
|
this._def.styles || '',
|
|
@@ -1074,7 +2428,22 @@ class Component {
|
|
|
1074
2428
|
if (!this._mounted && combinedStyles) {
|
|
1075
2429
|
const scopeAttr = `z-s${this._uid}`;
|
|
1076
2430
|
this._el.setAttribute(scopeAttr, '');
|
|
1077
|
-
|
|
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
|
+
}
|
|
1078
2447
|
return selector.split(',').map(s => `[${scopeAttr}] ${s.trim()}`).join(', ') + ' {';
|
|
1079
2448
|
});
|
|
1080
2449
|
const styleEl = document.createElement('style');
|
|
@@ -1085,22 +2454,19 @@ class Component {
|
|
|
1085
2454
|
}
|
|
1086
2455
|
|
|
1087
2456
|
// -- Focus preservation ----------------------------------------
|
|
1088
|
-
//
|
|
1089
|
-
//
|
|
1090
|
-
// 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.
|
|
1091
2459
|
let _focusInfo = null;
|
|
1092
2460
|
const _active = document.activeElement;
|
|
1093
2461
|
if (_active && this._el.contains(_active)) {
|
|
1094
2462
|
const modelKey = _active.getAttribute?.('z-model');
|
|
1095
2463
|
const refKey = _active.getAttribute?.('z-ref');
|
|
1096
|
-
// Build a selector that can locate the same element after re-render
|
|
1097
2464
|
let selector = null;
|
|
1098
2465
|
if (modelKey) {
|
|
1099
2466
|
selector = `[z-model="${modelKey}"]`;
|
|
1100
2467
|
} else if (refKey) {
|
|
1101
2468
|
selector = `[z-ref="${refKey}"]`;
|
|
1102
2469
|
} else {
|
|
1103
|
-
// Fallback: match by tag + type + name + placeholder combination
|
|
1104
2470
|
const tag = _active.tagName.toLowerCase();
|
|
1105
2471
|
if (tag === 'input' || tag === 'textarea' || tag === 'select') {
|
|
1106
2472
|
let s = tag;
|
|
@@ -1120,8 +2486,13 @@ class Component {
|
|
|
1120
2486
|
}
|
|
1121
2487
|
}
|
|
1122
2488
|
|
|
1123
|
-
// Update DOM
|
|
1124
|
-
|
|
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
|
+
}
|
|
1125
2496
|
|
|
1126
2497
|
// Process structural & attribute directives
|
|
1127
2498
|
this._processDirectives();
|
|
@@ -1131,10 +2502,10 @@ class Component {
|
|
|
1131
2502
|
this._bindRefs();
|
|
1132
2503
|
this._bindModels();
|
|
1133
2504
|
|
|
1134
|
-
// Restore focus
|
|
2505
|
+
// Restore focus if the morph replaced the focused element
|
|
1135
2506
|
if (_focusInfo) {
|
|
1136
2507
|
const el = this._el.querySelector(_focusInfo.selector);
|
|
1137
|
-
if (el) {
|
|
2508
|
+
if (el && el !== document.activeElement) {
|
|
1138
2509
|
el.focus();
|
|
1139
2510
|
try {
|
|
1140
2511
|
if (_focusInfo.start !== null && _focusInfo.start !== undefined) {
|
|
@@ -1149,9 +2520,15 @@ class Component {
|
|
|
1149
2520
|
|
|
1150
2521
|
if (!this._mounted) {
|
|
1151
2522
|
this._mounted = true;
|
|
1152
|
-
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
|
+
}
|
|
1153
2527
|
} else {
|
|
1154
|
-
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
|
+
}
|
|
1155
2532
|
}
|
|
1156
2533
|
}
|
|
1157
2534
|
|
|
@@ -1360,18 +2737,13 @@ class Component {
|
|
|
1360
2737
|
}
|
|
1361
2738
|
|
|
1362
2739
|
// ---------------------------------------------------------------------------
|
|
1363
|
-
// Expression evaluator —
|
|
2740
|
+
// Expression evaluator — CSP-safe parser (no eval / new Function)
|
|
1364
2741
|
// ---------------------------------------------------------------------------
|
|
1365
2742
|
_evalExpr(expr) {
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
this.props,
|
|
1371
|
-
this.refs,
|
|
1372
|
-
typeof window !== 'undefined' ? window.$ : undefined
|
|
1373
|
-
);
|
|
1374
|
-
} 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
|
+
]);
|
|
1375
2747
|
}
|
|
1376
2748
|
|
|
1377
2749
|
// ---------------------------------------------------------------------------
|
|
@@ -1433,13 +2805,15 @@ class Component {
|
|
|
1433
2805
|
const evalReplace = (str, item, index) =>
|
|
1434
2806
|
str.replace(/\{\{(.+?)\}\}/g, (_, inner) => {
|
|
1435
2807
|
try {
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
2808
|
+
const loopScope = {};
|
|
2809
|
+
loopScope[itemVar] = item;
|
|
2810
|
+
loopScope[indexVar] = index;
|
|
2811
|
+
const result = safeEval(inner.trim(), [
|
|
2812
|
+
loopScope,
|
|
1439
2813
|
this.state.__raw || this.state,
|
|
1440
|
-
this.props,
|
|
1441
|
-
|
|
1442
|
-
|
|
2814
|
+
{ props: this.props, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
|
|
2815
|
+
]);
|
|
2816
|
+
return result != null ? result : '';
|
|
1443
2817
|
} catch { return ''; }
|
|
1444
2818
|
});
|
|
1445
2819
|
|
|
@@ -1604,7 +2978,10 @@ class Component {
|
|
|
1604
2978
|
destroy() {
|
|
1605
2979
|
if (this._destroyed) return;
|
|
1606
2980
|
this._destroyed = true;
|
|
1607
|
-
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
|
+
}
|
|
1608
2985
|
this._listeners.forEach(({ event, handler }) => this._el.removeEventListener(event, handler));
|
|
1609
2986
|
this._listeners = [];
|
|
1610
2987
|
if (this._styleEl) this._styleEl.remove();
|
|
@@ -1617,7 +2994,8 @@ class Component {
|
|
|
1617
2994
|
// Reserved definition keys (not user methods)
|
|
1618
2995
|
const _reservedKeys = new Set([
|
|
1619
2996
|
'state', 'render', 'styles', 'init', 'mounted', 'updated', 'destroyed', 'props',
|
|
1620
|
-
'templateUrl', 'styleUrl', 'templates', 'pages', 'activePage', 'base'
|
|
2997
|
+
'templateUrl', 'styleUrl', 'templates', 'pages', 'activePage', 'base',
|
|
2998
|
+
'computed', 'watch'
|
|
1621
2999
|
]);
|
|
1622
3000
|
|
|
1623
3001
|
|
|
@@ -1631,8 +3009,11 @@ const _reservedKeys = new Set([
|
|
|
1631
3009
|
* @param {object} definition — component definition
|
|
1632
3010
|
*/
|
|
1633
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
|
+
}
|
|
1634
3015
|
if (!name.includes('-')) {
|
|
1635
|
-
throw new
|
|
3016
|
+
throw new ZQueryError(ErrorCode.COMP_INVALID_NAME, `Component name "${name}" must contain a hyphen (Web Component convention)`);
|
|
1636
3017
|
}
|
|
1637
3018
|
definition._name = name;
|
|
1638
3019
|
|
|
@@ -1657,10 +3038,10 @@ function component(name, definition) {
|
|
|
1657
3038
|
*/
|
|
1658
3039
|
function mount(target, componentName, props = {}) {
|
|
1659
3040
|
const el = typeof target === 'string' ? document.querySelector(target) : target;
|
|
1660
|
-
if (!el) throw new
|
|
3041
|
+
if (!el) throw new ZQueryError(ErrorCode.COMP_MOUNT_TARGET, `Mount target "${target}" not found`, { target });
|
|
1661
3042
|
|
|
1662
3043
|
const def = _registry.get(componentName);
|
|
1663
|
-
if (!def) throw new
|
|
3044
|
+
if (!def) throw new ZQueryError(ErrorCode.COMP_NOT_FOUND, `Component "${componentName}" not registered`, { component: componentName });
|
|
1664
3045
|
|
|
1665
3046
|
// Destroy existing instance
|
|
1666
3047
|
if (_instances.has(el)) _instances.get(el).destroy();
|
|
@@ -1683,12 +3064,40 @@ function mountAll(root = document.body) {
|
|
|
1683
3064
|
|
|
1684
3065
|
// Extract props from attributes
|
|
1685
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
|
+
|
|
1686
3079
|
[...tag.attributes].forEach(attr => {
|
|
1687
|
-
if (
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
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;
|
|
1691
3096
|
}
|
|
3097
|
+
|
|
3098
|
+
// Static prop
|
|
3099
|
+
try { props[attr.name] = JSON.parse(attr.value); }
|
|
3100
|
+
catch { props[attr.name] = attr.value; }
|
|
1692
3101
|
});
|
|
1693
3102
|
|
|
1694
3103
|
const instance = new Component(tag, def, props);
|
|
@@ -1847,6 +3256,7 @@ function style(urls, opts = {}) {
|
|
|
1847
3256
|
*/
|
|
1848
3257
|
|
|
1849
3258
|
|
|
3259
|
+
|
|
1850
3260
|
class Router {
|
|
1851
3261
|
constructor(config = {}) {
|
|
1852
3262
|
this._el = null;
|
|
@@ -2117,10 +3527,15 @@ class Router {
|
|
|
2117
3527
|
|
|
2118
3528
|
// Run before guards
|
|
2119
3529
|
for (const guard of this._guards.before) {
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
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;
|
|
2124
3539
|
}
|
|
2125
3540
|
}
|
|
2126
3541
|
|
|
@@ -2128,7 +3543,7 @@ class Router {
|
|
|
2128
3543
|
if (matched.load) {
|
|
2129
3544
|
try { await matched.load(); }
|
|
2130
3545
|
catch (err) {
|
|
2131
|
-
|
|
3546
|
+
reportError(ErrorCode.ROUTER_LOAD, `Failed to load module for route "${matched.path}"`, { path: matched.path }, err);
|
|
2132
3547
|
return;
|
|
2133
3548
|
}
|
|
2134
3549
|
}
|
|
@@ -2224,6 +3639,7 @@ function getRouter() {
|
|
|
2224
3639
|
*/
|
|
2225
3640
|
|
|
2226
3641
|
|
|
3642
|
+
|
|
2227
3643
|
class Store {
|
|
2228
3644
|
constructor(config = {}) {
|
|
2229
3645
|
this._subscribers = new Map(); // key → Set<fn>
|
|
@@ -2240,9 +3656,15 @@ class Store {
|
|
|
2240
3656
|
this.state = reactive(initial, (key, value, old) => {
|
|
2241
3657
|
// Notify key-specific subscribers
|
|
2242
3658
|
const subs = this._subscribers.get(key);
|
|
2243
|
-
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
|
+
});
|
|
2244
3663
|
// Notify wildcard subscribers
|
|
2245
|
-
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
|
+
});
|
|
2246
3668
|
});
|
|
2247
3669
|
|
|
2248
3670
|
// Build getters as computed properties
|
|
@@ -2263,23 +3685,32 @@ class Store {
|
|
|
2263
3685
|
dispatch(name, ...args) {
|
|
2264
3686
|
const action = this._actions[name];
|
|
2265
3687
|
if (!action) {
|
|
2266
|
-
|
|
3688
|
+
reportError(ErrorCode.STORE_ACTION, `Unknown action "${name}"`, { action: name, args });
|
|
2267
3689
|
return;
|
|
2268
3690
|
}
|
|
2269
3691
|
|
|
2270
3692
|
// Run middleware
|
|
2271
3693
|
for (const mw of this._middleware) {
|
|
2272
|
-
|
|
2273
|
-
|
|
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
|
+
}
|
|
2274
3701
|
}
|
|
2275
3702
|
|
|
2276
3703
|
if (this._debug) {
|
|
2277
3704
|
console.log(`%c[Store] ${name}`, 'color: #4CAF50; font-weight: bold;', ...args);
|
|
2278
3705
|
}
|
|
2279
3706
|
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
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
|
+
}
|
|
2283
3714
|
}
|
|
2284
3715
|
|
|
2285
3716
|
/**
|
|
@@ -2398,6 +3829,9 @@ const _interceptors = {
|
|
|
2398
3829
|
* Core request function
|
|
2399
3830
|
*/
|
|
2400
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
|
+
}
|
|
2401
3835
|
let fullURL = url.startsWith('http') ? url : _config.baseURL + url;
|
|
2402
3836
|
let headers = { ..._config.headers, ...options.headers };
|
|
2403
3837
|
let body = undefined;
|
|
@@ -2453,16 +3887,21 @@ async function request(method, url, data, options = {}) {
|
|
|
2453
3887
|
const contentType = response.headers.get('Content-Type') || '';
|
|
2454
3888
|
let responseData;
|
|
2455
3889
|
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
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);
|
|
2466
3905
|
}
|
|
2467
3906
|
|
|
2468
3907
|
const result = {
|
|
@@ -2836,21 +4275,24 @@ const bus = new EventBus();
|
|
|
2836
4275
|
|
|
2837
4276
|
|
|
2838
4277
|
|
|
4278
|
+
|
|
4279
|
+
|
|
4280
|
+
|
|
2839
4281
|
// ---------------------------------------------------------------------------
|
|
2840
4282
|
// $ — The main function & namespace
|
|
2841
4283
|
// ---------------------------------------------------------------------------
|
|
2842
4284
|
|
|
2843
4285
|
/**
|
|
2844
|
-
* Main selector function
|
|
4286
|
+
* Main selector function — always returns a ZQueryCollection (like jQuery).
|
|
2845
4287
|
*
|
|
2846
|
-
* $('selector') →
|
|
2847
|
-
* $('<div>hello</div>') →
|
|
2848
|
-
* $(element) →
|
|
4288
|
+
* $('selector') → ZQueryCollection (querySelectorAll)
|
|
4289
|
+
* $('<div>hello</div>') → ZQueryCollection from created elements
|
|
4290
|
+
* $(element) → ZQueryCollection wrapping the element
|
|
2849
4291
|
* $(fn) → DOMContentLoaded shorthand
|
|
2850
4292
|
*
|
|
2851
4293
|
* @param {string|Element|NodeList|Function} selector
|
|
2852
4294
|
* @param {string|Element} [context]
|
|
2853
|
-
* @returns {
|
|
4295
|
+
* @returns {ZQueryCollection}
|
|
2854
4296
|
*/
|
|
2855
4297
|
function $(selector, context) {
|
|
2856
4298
|
// $(fn) → DOM ready shorthand
|
|
@@ -2862,11 +4304,14 @@ function $(selector, context) {
|
|
|
2862
4304
|
}
|
|
2863
4305
|
|
|
2864
4306
|
|
|
2865
|
-
// --- Quick refs
|
|
4307
|
+
// --- Quick refs (DOM selectors) --------------------------------------------
|
|
2866
4308
|
$.id = query.id;
|
|
2867
4309
|
$.class = query.class;
|
|
2868
4310
|
$.classes = query.classes;
|
|
2869
4311
|
$.tag = query.tag;
|
|
4312
|
+
Object.defineProperty($, 'name', {
|
|
4313
|
+
value: query.name, writable: true, configurable: true
|
|
4314
|
+
});
|
|
2870
4315
|
$.children = query.children;
|
|
2871
4316
|
|
|
2872
4317
|
// --- Collection selector ---------------------------------------------------
|
|
@@ -2895,6 +4340,7 @@ $.fn = query.fn;
|
|
|
2895
4340
|
|
|
2896
4341
|
// --- Reactive primitives ---------------------------------------------------
|
|
2897
4342
|
$.reactive = reactive;
|
|
4343
|
+
$.Signal = Signal;
|
|
2898
4344
|
$.signal = signal;
|
|
2899
4345
|
$.computed = computed;
|
|
2900
4346
|
$.effect = effect;
|
|
@@ -2907,6 +4353,8 @@ $.getInstance = getInstance;
|
|
|
2907
4353
|
$.destroy = destroy;
|
|
2908
4354
|
$.components = getRegistry;
|
|
2909
4355
|
$.style = style;
|
|
4356
|
+
$.morph = morph;
|
|
4357
|
+
$.safeEval = safeEval;
|
|
2910
4358
|
|
|
2911
4359
|
// --- Router ----------------------------------------------------------------
|
|
2912
4360
|
$.router = createRouter;
|
|
@@ -2945,8 +4393,13 @@ $.storage = storage;
|
|
|
2945
4393
|
$.session = session;
|
|
2946
4394
|
$.bus = bus;
|
|
2947
4395
|
|
|
4396
|
+
// --- Error handling --------------------------------------------------------
|
|
4397
|
+
$.onError = onError;
|
|
4398
|
+
$.ZQueryError = ZQueryError;
|
|
4399
|
+
$.ErrorCode = ErrorCode;
|
|
4400
|
+
|
|
2948
4401
|
// --- Meta ------------------------------------------------------------------
|
|
2949
|
-
$.version = '0.
|
|
4402
|
+
$.version = '0.6.3';
|
|
2950
4403
|
$.meta = {}; // populated at build time by CLI bundler
|
|
2951
4404
|
|
|
2952
4405
|
$.noConflict = () => {
|