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/dist/zquery.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * zQuery (zeroQuery) v0.4.9
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
- onChange(key, value, old);
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
- onChange(key, undefined, old);
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 => 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 () => { /* Signals will hold weak refs if needed */ };
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 function (returns single element for CSS selectors)
710
+ // $() — main selector / creator (returns ZQueryCollection, like jQuery)
525
711
  // ---------------------------------------------------------------------------
526
712
  function query(selector, context) {
527
713
  // null / undefined
528
- if (!selector) return null;
714
+ if (!selector) return new ZQueryCollection([]);
529
715
 
530
- // Already a collection — return first element
531
- if (selector instanceof ZQueryCollection) return selector.first();
716
+ // Already a collection — return as-is
717
+ if (selector instanceof ZQueryCollection) return selector;
532
718
 
533
- // DOM element or Window — return as-is
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 — return first element
724
+ // NodeList / HTMLCollection / Array — wrap in collection
539
725
  if (selector instanceof NodeList || selector instanceof HTMLCollection || Array.isArray(selector)) {
540
- const arr = Array.from(selector);
541
- return arr[0] || null;
726
+ return new ZQueryCollection(Array.from(selector));
542
727
  }
543
728
 
544
- // HTML string → create elements, return first
729
+ // HTML string → create elements, wrap in collection
545
730
  if (typeof selector === 'string' && selector.trim().startsWith('<')) {
546
731
  const fragment = createFragment(selector);
547
- const els = [...fragment.childNodes].filter(n => n.nodeType === 1);
548
- return els[0] || null;
732
+ return new ZQueryCollection([...fragment.childNodes].filter(n => n.nodeType === 1));
549
733
  }
550
734
 
551
- // CSS selector string → querySelector (single element)
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.querySelector(selector);
740
+ return new ZQueryCollection([...root.querySelectorAll(selector)]);
557
741
  }
558
742
 
559
- return null;
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) this._scheduleUpdate();
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) definition.init.call(this);
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
- return new Function('state', 'props', '$', `with(state){return ${expr.trim()}}`)(
2398
+ const result = safeEval(expr.trim(), [
1057
2399
  this.state.__raw || this.state,
1058
- this.props,
1059
- typeof window !== 'undefined' ? window.$ : undefined
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
- const scoped = combinedStyles.replace(/([^{}]+)\{/g, (match, selector) => {
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
- // Before replacing innerHTML, save focus state so we can restore
1089
- // cursor position after the DOM is rebuilt. Works for any focused
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
- this._el.innerHTML = html;
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 after re-render
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) this._def.mounted.call(this);
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) this._def.updated.call(this);
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 — runs expr in component context (state, props, refs)
2740
+ // Expression evaluator — CSP-safe parser (no eval / new Function)
1364
2741
  // ---------------------------------------------------------------------------
1365
2742
  _evalExpr(expr) {
1366
- try {
1367
- return new Function('state', 'props', 'refs', '$',
1368
- `with(state){return (${expr})}`)(
1369
- this.state.__raw || this.state,
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
- return new Function(itemVar, indexVar, 'state', 'props', '$',
1437
- `with(state){return (${inner.trim()})}`)(
1438
- item, index,
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
- typeof window !== 'undefined' ? window.$ : undefined
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) this._def.destroyed.call(this);
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 Error(`zQuery: Component name "${name}" must contain a hyphen (Web Component convention)`);
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 Error(`zQuery: Mount target "${target}" not found`);
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 Error(`zQuery: Component "${componentName}" not registered`);
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 (!attr.name.startsWith('@') && !attr.name.startsWith('z-')) {
1688
- // Try JSON parse for objects/arrays
1689
- try { props[attr.name] = JSON.parse(attr.value); }
1690
- catch { props[attr.name] = attr.value; }
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
- const result = await guard(to, from);
2121
- if (result === false) return; // Cancel
2122
- if (typeof result === 'string') { // Redirect
2123
- return this.navigate(result);
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
- console.error(`zQuery Router: Failed to load module for "${matched.path}"`, err);
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 => fn(value, old, key));
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 => fn(key, value, old));
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
- console.warn(`zQuery Store: Unknown action "${name}"`);
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
- const result = mw(name, args, this.state);
2273
- if (result === false) return; // blocked by middleware
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
- const result = action(this.state, ...args);
2281
- this._history.push({ action: name, args, timestamp: Date.now() });
2282
- return result;
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
- if (contentType.includes('application/json')) {
2457
- responseData = await response.json();
2458
- } else if (contentType.includes('text/')) {
2459
- responseData = await response.text();
2460
- } else if (contentType.includes('application/octet-stream') || contentType.includes('image/')) {
2461
- responseData = await response.blob();
2462
- } else {
2463
- // Try JSON first, fall back to text
2464
- const text = await response.text();
2465
- try { responseData = JSON.parse(text); } catch { responseData = text; }
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') → single Element (querySelector)
2847
- * $('<div>hello</div>') → create element (first created node)
2848
- * $(element) → return element as-is
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 {Element|null}
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.4.9';
4402
+ $.version = '0.6.3';
2950
4403
  $.meta = {}; // populated at build time by CLI bundler
2951
4404
 
2952
4405
  $.noConflict = () => {