zero-query 0.5.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/zquery.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * zQuery (zeroQuery) v0.5.2
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,21 +787,15 @@ function queryAll(selector, context) {
603
787
  // ---------------------------------------------------------------------------
604
788
  query.id = (id) => document.getElementById(id);
605
789
  query.class = (name) => document.querySelector(`.${name}`);
606
- query.classes = (name) => Array.from(document.getElementsByClassName(name));
607
- query.tag = (name) => Array.from(document.getElementsByTagName(name));
790
+ query.classes = (name) => new ZQueryCollection(Array.from(document.getElementsByClassName(name)));
791
+ query.tag = (name) => new ZQueryCollection(Array.from(document.getElementsByTagName(name)));
608
792
  Object.defineProperty(query, 'name', {
609
- value: (name) => Array.from(document.getElementsByName(name)),
793
+ value: (name) => new ZQueryCollection(Array.from(document.getElementsByName(name))),
610
794
  writable: true, configurable: true
611
795
  });
612
- query.attr = (attr, value) => Array.from(
613
- document.querySelectorAll(value !== undefined ? `[${attr}="${value}"]` : `[${attr}]`)
614
- );
615
- query.data = (key, value) => Array.from(
616
- document.querySelectorAll(value !== undefined ? `[data-${key}="${value}"]` : `[data-${key}]`)
617
- );
618
796
  query.children = (parentId) => {
619
797
  const p = document.getElementById(parentId);
620
- return p ? Array.from(p.children) : [];
798
+ return new ZQueryCollection(p ? Array.from(p.children) : []);
621
799
  };
622
800
 
623
801
  // Create element shorthand
@@ -667,6 +845,1096 @@ query.off = (event, handler) => {
667
845
  // Extend collection prototype (like $.fn in jQuery)
668
846
  query.fn = ZQueryCollection.prototype;
669
847
 
848
+ // --- src/expression.js ———————————————————————————————————————————
849
+ /**
850
+ * zQuery Expression Parser — CSP-safe expression evaluator
851
+ *
852
+ * Replaces `new Function()` / `eval()` with a hand-written parser that
853
+ * evaluates expressions safely without violating Content Security Policy.
854
+ *
855
+ * Supports:
856
+ * - Property access: user.name, items[0], items[i]
857
+ * - Method calls: items.length, str.toUpperCase()
858
+ * - Arithmetic: a + b, count * 2, i % 2
859
+ * - Comparison: a === b, count > 0, x != null
860
+ * - Logical: a && b, a || b, !a
861
+ * - Ternary: a ? b : c
862
+ * - Typeof: typeof x
863
+ * - Unary: -a, +a, !a
864
+ * - Literals: 42, 'hello', "world", true, false, null, undefined
865
+ * - Template literals: `Hello ${name}`
866
+ * - Array literals: [1, 2, 3]
867
+ * - Object literals: { foo: 'bar', baz: 1 }
868
+ * - Grouping: (a + b) * c
869
+ * - Nullish coalescing: a ?? b
870
+ * - Optional chaining: a?.b, a?.[b], a?.()
871
+ * - Arrow functions: x => x.id, (a, b) => a + b
872
+ */
873
+
874
+ // Token types
875
+ const T = {
876
+ NUM: 1, STR: 2, IDENT: 3, OP: 4, PUNC: 5, TMPL: 6, EOF: 7
877
+ };
878
+
879
+ // Operator precedence (higher = binds tighter)
880
+ const PREC = {
881
+ '??': 2,
882
+ '||': 3,
883
+ '&&': 4,
884
+ '==': 8, '!=': 8, '===': 8, '!==': 8,
885
+ '<': 9, '>': 9, '<=': 9, '>=': 9, 'instanceof': 9, 'in': 9,
886
+ '+': 11, '-': 11,
887
+ '*': 12, '/': 12, '%': 12,
888
+ };
889
+
890
+ const KEYWORDS = new Set([
891
+ 'true', 'false', 'null', 'undefined', 'typeof', 'instanceof', 'in',
892
+ 'new', 'void'
893
+ ]);
894
+
895
+ // ---------------------------------------------------------------------------
896
+ // Tokenizer
897
+ // ---------------------------------------------------------------------------
898
+ function tokenize(expr) {
899
+ const tokens = [];
900
+ let i = 0;
901
+ const len = expr.length;
902
+
903
+ while (i < len) {
904
+ const ch = expr[i];
905
+
906
+ // Whitespace
907
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { i++; continue; }
908
+
909
+ // Numbers
910
+ if ((ch >= '0' && ch <= '9') || (ch === '.' && i + 1 < len && expr[i + 1] >= '0' && expr[i + 1] <= '9')) {
911
+ let num = '';
912
+ if (ch === '0' && i + 1 < len && (expr[i + 1] === 'x' || expr[i + 1] === 'X')) {
913
+ num = '0x'; i += 2;
914
+ while (i < len && /[0-9a-fA-F]/.test(expr[i])) num += expr[i++];
915
+ } else {
916
+ while (i < len && ((expr[i] >= '0' && expr[i] <= '9') || expr[i] === '.')) num += expr[i++];
917
+ if (i < len && (expr[i] === 'e' || expr[i] === 'E')) {
918
+ num += expr[i++];
919
+ if (i < len && (expr[i] === '+' || expr[i] === '-')) num += expr[i++];
920
+ while (i < len && expr[i] >= '0' && expr[i] <= '9') num += expr[i++];
921
+ }
922
+ }
923
+ tokens.push({ t: T.NUM, v: Number(num) });
924
+ continue;
925
+ }
926
+
927
+ // Strings
928
+ if (ch === "'" || ch === '"') {
929
+ const quote = ch;
930
+ let str = '';
931
+ i++;
932
+ while (i < len && expr[i] !== quote) {
933
+ if (expr[i] === '\\' && i + 1 < len) {
934
+ const esc = expr[++i];
935
+ if (esc === 'n') str += '\n';
936
+ else if (esc === 't') str += '\t';
937
+ else if (esc === 'r') str += '\r';
938
+ else if (esc === '\\') str += '\\';
939
+ else if (esc === quote) str += quote;
940
+ else str += esc;
941
+ } else {
942
+ str += expr[i];
943
+ }
944
+ i++;
945
+ }
946
+ i++; // closing quote
947
+ tokens.push({ t: T.STR, v: str });
948
+ continue;
949
+ }
950
+
951
+ // Template literals
952
+ if (ch === '`') {
953
+ const parts = []; // alternating: string, expr, string, expr, ...
954
+ let str = '';
955
+ i++;
956
+ while (i < len && expr[i] !== '`') {
957
+ if (expr[i] === '$' && i + 1 < len && expr[i + 1] === '{') {
958
+ parts.push(str);
959
+ str = '';
960
+ i += 2;
961
+ let depth = 1;
962
+ let inner = '';
963
+ while (i < len && depth > 0) {
964
+ if (expr[i] === '{') depth++;
965
+ else if (expr[i] === '}') { depth--; if (depth === 0) break; }
966
+ inner += expr[i++];
967
+ }
968
+ i++; // closing }
969
+ parts.push({ expr: inner });
970
+ } else {
971
+ if (expr[i] === '\\' && i + 1 < len) { str += expr[++i]; }
972
+ else str += expr[i];
973
+ i++;
974
+ }
975
+ }
976
+ i++; // closing backtick
977
+ parts.push(str);
978
+ tokens.push({ t: T.TMPL, v: parts });
979
+ continue;
980
+ }
981
+
982
+ // Identifiers & keywords
983
+ if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_' || ch === '$') {
984
+ let ident = '';
985
+ while (i < len && /[\w$]/.test(expr[i])) ident += expr[i++];
986
+ tokens.push({ t: T.IDENT, v: ident });
987
+ continue;
988
+ }
989
+
990
+ // Multi-char operators
991
+ const two = expr.slice(i, i + 3);
992
+ if (two === '===' || two === '!==' || two === '?.') {
993
+ if (two === '?.') {
994
+ tokens.push({ t: T.OP, v: '?.' });
995
+ i += 2;
996
+ } else {
997
+ tokens.push({ t: T.OP, v: two });
998
+ i += 3;
999
+ }
1000
+ continue;
1001
+ }
1002
+ const pair = expr.slice(i, i + 2);
1003
+ if (pair === '==' || pair === '!=' || pair === '<=' || pair === '>=' ||
1004
+ pair === '&&' || pair === '||' || pair === '??' || pair === '?.' ||
1005
+ pair === '=>') {
1006
+ tokens.push({ t: T.OP, v: pair });
1007
+ i += 2;
1008
+ continue;
1009
+ }
1010
+
1011
+ // Single char operators and punctuation
1012
+ if ('+-*/%'.includes(ch)) {
1013
+ tokens.push({ t: T.OP, v: ch });
1014
+ i++; continue;
1015
+ }
1016
+ if ('<>=!'.includes(ch)) {
1017
+ tokens.push({ t: T.OP, v: ch });
1018
+ i++; continue;
1019
+ }
1020
+ if ('()[]{},.?:'.includes(ch)) {
1021
+ tokens.push({ t: T.PUNC, v: ch });
1022
+ i++; continue;
1023
+ }
1024
+
1025
+ // Unknown — skip
1026
+ i++;
1027
+ }
1028
+
1029
+ tokens.push({ t: T.EOF, v: null });
1030
+ return tokens;
1031
+ }
1032
+
1033
+ // ---------------------------------------------------------------------------
1034
+ // Parser — Pratt (precedence climbing)
1035
+ // ---------------------------------------------------------------------------
1036
+ class Parser {
1037
+ constructor(tokens, scope) {
1038
+ this.tokens = tokens;
1039
+ this.pos = 0;
1040
+ this.scope = scope;
1041
+ }
1042
+
1043
+ peek() { return this.tokens[this.pos]; }
1044
+ next() { return this.tokens[this.pos++]; }
1045
+
1046
+ expect(type, val) {
1047
+ const t = this.next();
1048
+ if (t.t !== type || (val !== undefined && t.v !== val)) {
1049
+ throw new Error(`Expected ${val || type} but got ${t.v}`);
1050
+ }
1051
+ return t;
1052
+ }
1053
+
1054
+ match(type, val) {
1055
+ const t = this.peek();
1056
+ if (t.t === type && (val === undefined || t.v === val)) {
1057
+ return this.next();
1058
+ }
1059
+ return null;
1060
+ }
1061
+
1062
+ // Main entry
1063
+ parse() {
1064
+ const result = this.parseExpression(0);
1065
+ return result;
1066
+ }
1067
+
1068
+ // Precedence climbing
1069
+ parseExpression(minPrec) {
1070
+ let left = this.parseUnary();
1071
+
1072
+ while (true) {
1073
+ const tok = this.peek();
1074
+
1075
+ // Ternary
1076
+ if (tok.t === T.PUNC && tok.v === '?') {
1077
+ // Distinguish ternary ? from optional chaining ?.
1078
+ if (this.tokens[this.pos + 1]?.v !== '.') {
1079
+ if (1 <= minPrec) break; // ternary has very low precedence
1080
+ this.next(); // consume ?
1081
+ const truthy = this.parseExpression(0);
1082
+ this.expect(T.PUNC, ':');
1083
+ const falsy = this.parseExpression(1);
1084
+ left = { type: 'ternary', cond: left, truthy, falsy };
1085
+ continue;
1086
+ }
1087
+ }
1088
+
1089
+ // Binary operators
1090
+ if (tok.t === T.OP && tok.v in PREC) {
1091
+ const prec = PREC[tok.v];
1092
+ if (prec <= minPrec) break;
1093
+ this.next();
1094
+ const right = this.parseExpression(prec);
1095
+ left = { type: 'binary', op: tok.v, left, right };
1096
+ continue;
1097
+ }
1098
+
1099
+ // instanceof and in as binary operators
1100
+ if (tok.t === T.IDENT && (tok.v === 'instanceof' || tok.v === 'in') && PREC[tok.v] > minPrec) {
1101
+ const prec = PREC[tok.v];
1102
+ this.next();
1103
+ const right = this.parseExpression(prec);
1104
+ left = { type: 'binary', op: tok.v, left, right };
1105
+ continue;
1106
+ }
1107
+
1108
+ break;
1109
+ }
1110
+
1111
+ return left;
1112
+ }
1113
+
1114
+ parseUnary() {
1115
+ const tok = this.peek();
1116
+
1117
+ // typeof
1118
+ if (tok.t === T.IDENT && tok.v === 'typeof') {
1119
+ this.next();
1120
+ const arg = this.parseUnary();
1121
+ return { type: 'typeof', arg };
1122
+ }
1123
+
1124
+ // void
1125
+ if (tok.t === T.IDENT && tok.v === 'void') {
1126
+ this.next();
1127
+ this.parseUnary(); // evaluate but discard
1128
+ return { type: 'literal', value: undefined };
1129
+ }
1130
+
1131
+ // !expr
1132
+ if (tok.t === T.OP && tok.v === '!') {
1133
+ this.next();
1134
+ const arg = this.parseUnary();
1135
+ return { type: 'not', arg };
1136
+ }
1137
+
1138
+ // -expr, +expr
1139
+ if (tok.t === T.OP && (tok.v === '-' || tok.v === '+')) {
1140
+ this.next();
1141
+ const arg = this.parseUnary();
1142
+ return { type: 'unary', op: tok.v, arg };
1143
+ }
1144
+
1145
+ return this.parsePostfix();
1146
+ }
1147
+
1148
+ parsePostfix() {
1149
+ let left = this.parsePrimary();
1150
+
1151
+ while (true) {
1152
+ const tok = this.peek();
1153
+
1154
+ // Property access: a.b
1155
+ if (tok.t === T.PUNC && tok.v === '.') {
1156
+ this.next();
1157
+ const prop = this.next();
1158
+ left = { type: 'member', obj: left, prop: prop.v, computed: false };
1159
+ // Check for method call: a.b()
1160
+ if (this.peek().t === T.PUNC && this.peek().v === '(') {
1161
+ left = this._parseCall(left);
1162
+ }
1163
+ continue;
1164
+ }
1165
+
1166
+ // Optional chaining: a?.b, a?.[b], a?.()
1167
+ if (tok.t === T.OP && tok.v === '?.') {
1168
+ this.next();
1169
+ const next = this.peek();
1170
+ if (next.t === T.PUNC && next.v === '[') {
1171
+ // a?.[expr]
1172
+ this.next();
1173
+ const prop = this.parseExpression(0);
1174
+ this.expect(T.PUNC, ']');
1175
+ left = { type: 'optional_member', obj: left, prop, computed: true };
1176
+ } else if (next.t === T.PUNC && next.v === '(') {
1177
+ // a?.()
1178
+ left = { type: 'optional_call', callee: left, args: this._parseArgs() };
1179
+ } else {
1180
+ // a?.b
1181
+ const prop = this.next();
1182
+ left = { type: 'optional_member', obj: left, prop: prop.v, computed: false };
1183
+ if (this.peek().t === T.PUNC && this.peek().v === '(') {
1184
+ left = this._parseCall(left);
1185
+ }
1186
+ }
1187
+ continue;
1188
+ }
1189
+
1190
+ // Computed access: a[b]
1191
+ if (tok.t === T.PUNC && tok.v === '[') {
1192
+ this.next();
1193
+ const prop = this.parseExpression(0);
1194
+ this.expect(T.PUNC, ']');
1195
+ left = { type: 'member', obj: left, prop, computed: true };
1196
+ // Check for method call: a[b]()
1197
+ if (this.peek().t === T.PUNC && this.peek().v === '(') {
1198
+ left = this._parseCall(left);
1199
+ }
1200
+ continue;
1201
+ }
1202
+
1203
+ // Function call: fn()
1204
+ if (tok.t === T.PUNC && tok.v === '(') {
1205
+ left = this._parseCall(left);
1206
+ continue;
1207
+ }
1208
+
1209
+ break;
1210
+ }
1211
+
1212
+ return left;
1213
+ }
1214
+
1215
+ _parseCall(callee) {
1216
+ const args = this._parseArgs();
1217
+ return { type: 'call', callee, args };
1218
+ }
1219
+
1220
+ _parseArgs() {
1221
+ this.expect(T.PUNC, '(');
1222
+ const args = [];
1223
+ while (!(this.peek().t === T.PUNC && this.peek().v === ')') && this.peek().t !== T.EOF) {
1224
+ args.push(this.parseExpression(0));
1225
+ if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
1226
+ }
1227
+ this.expect(T.PUNC, ')');
1228
+ return args;
1229
+ }
1230
+
1231
+ parsePrimary() {
1232
+ const tok = this.peek();
1233
+
1234
+ // Number literal
1235
+ if (tok.t === T.NUM) {
1236
+ this.next();
1237
+ return { type: 'literal', value: tok.v };
1238
+ }
1239
+
1240
+ // String literal
1241
+ if (tok.t === T.STR) {
1242
+ this.next();
1243
+ return { type: 'literal', value: tok.v };
1244
+ }
1245
+
1246
+ // Template literal
1247
+ if (tok.t === T.TMPL) {
1248
+ this.next();
1249
+ return { type: 'template', parts: tok.v };
1250
+ }
1251
+
1252
+ // Arrow function with parens: () =>, (a) =>, (a, b) =>
1253
+ // or regular grouping: (expr)
1254
+ if (tok.t === T.PUNC && tok.v === '(') {
1255
+ const savedPos = this.pos;
1256
+ this.next(); // consume (
1257
+ const params = [];
1258
+ let couldBeArrow = true;
1259
+
1260
+ if (this.peek().t === T.PUNC && this.peek().v === ')') {
1261
+ // () => ... — no params
1262
+ } else {
1263
+ while (couldBeArrow) {
1264
+ const p = this.peek();
1265
+ if (p.t === T.IDENT && !KEYWORDS.has(p.v)) {
1266
+ params.push(this.next().v);
1267
+ if (this.peek().t === T.PUNC && this.peek().v === ',') {
1268
+ this.next();
1269
+ } else {
1270
+ break;
1271
+ }
1272
+ } else {
1273
+ couldBeArrow = false;
1274
+ }
1275
+ }
1276
+ }
1277
+
1278
+ if (couldBeArrow && this.peek().t === T.PUNC && this.peek().v === ')') {
1279
+ this.next(); // consume )
1280
+ if (this.peek().t === T.OP && this.peek().v === '=>') {
1281
+ this.next(); // consume =>
1282
+ const body = this.parseExpression(0);
1283
+ return { type: 'arrow', params, body };
1284
+ }
1285
+ }
1286
+
1287
+ // Not an arrow — restore and parse as grouping
1288
+ this.pos = savedPos;
1289
+ this.next(); // consume (
1290
+ const expr = this.parseExpression(0);
1291
+ this.expect(T.PUNC, ')');
1292
+ return expr;
1293
+ }
1294
+
1295
+ // Array literal
1296
+ if (tok.t === T.PUNC && tok.v === '[') {
1297
+ this.next();
1298
+ const elements = [];
1299
+ while (!(this.peek().t === T.PUNC && this.peek().v === ']') && this.peek().t !== T.EOF) {
1300
+ elements.push(this.parseExpression(0));
1301
+ if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
1302
+ }
1303
+ this.expect(T.PUNC, ']');
1304
+ return { type: 'array', elements };
1305
+ }
1306
+
1307
+ // Object literal
1308
+ if (tok.t === T.PUNC && tok.v === '{') {
1309
+ this.next();
1310
+ const properties = [];
1311
+ while (!(this.peek().t === T.PUNC && this.peek().v === '}') && this.peek().t !== T.EOF) {
1312
+ const keyTok = this.next();
1313
+ let key;
1314
+ if (keyTok.t === T.IDENT || keyTok.t === T.STR) key = keyTok.v;
1315
+ else if (keyTok.t === T.NUM) key = String(keyTok.v);
1316
+ else throw new Error('Invalid object key: ' + keyTok.v);
1317
+
1318
+ // Shorthand property: { foo } means { foo: foo }
1319
+ if (this.peek().t === T.PUNC && (this.peek().v === ',' || this.peek().v === '}')) {
1320
+ properties.push({ key, value: { type: 'ident', name: key } });
1321
+ } else {
1322
+ this.expect(T.PUNC, ':');
1323
+ properties.push({ key, value: this.parseExpression(0) });
1324
+ }
1325
+ if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
1326
+ }
1327
+ this.expect(T.PUNC, '}');
1328
+ return { type: 'object', properties };
1329
+ }
1330
+
1331
+ // Identifiers & keywords
1332
+ if (tok.t === T.IDENT) {
1333
+ this.next();
1334
+
1335
+ // Keywords
1336
+ if (tok.v === 'true') return { type: 'literal', value: true };
1337
+ if (tok.v === 'false') return { type: 'literal', value: false };
1338
+ if (tok.v === 'null') return { type: 'literal', value: null };
1339
+ if (tok.v === 'undefined') return { type: 'literal', value: undefined };
1340
+
1341
+ // new keyword
1342
+ if (tok.v === 'new') {
1343
+ const classExpr = this.parsePostfix();
1344
+ let args = [];
1345
+ if (this.peek().t === T.PUNC && this.peek().v === '(') {
1346
+ args = this._parseArgs();
1347
+ }
1348
+ return { type: 'new', callee: classExpr, args };
1349
+ }
1350
+
1351
+ // Arrow function: x => expr
1352
+ if (this.peek().t === T.OP && this.peek().v === '=>') {
1353
+ this.next(); // consume =>
1354
+ const body = this.parseExpression(0);
1355
+ return { type: 'arrow', params: [tok.v], body };
1356
+ }
1357
+
1358
+ return { type: 'ident', name: tok.v };
1359
+ }
1360
+
1361
+ // Fallback — return undefined for unparseable
1362
+ this.next();
1363
+ return { type: 'literal', value: undefined };
1364
+ }
1365
+ }
1366
+
1367
+ // ---------------------------------------------------------------------------
1368
+ // Evaluator — walks the AST, resolves against scope
1369
+ // ---------------------------------------------------------------------------
1370
+
1371
+ /** Safe property access whitelist for built-in prototypes */
1372
+ const SAFE_ARRAY_METHODS = new Set([
1373
+ 'length', 'map', 'filter', 'find', 'findIndex', 'some', 'every',
1374
+ 'reduce', 'reduceRight', 'forEach', 'includes', 'indexOf', 'lastIndexOf',
1375
+ 'join', 'slice', 'concat', 'flat', 'flatMap', 'reverse', 'sort',
1376
+ 'fill', 'keys', 'values', 'entries', 'at', 'toString',
1377
+ ]);
1378
+
1379
+ const SAFE_STRING_METHODS = new Set([
1380
+ 'length', 'charAt', 'charCodeAt', 'includes', 'indexOf', 'lastIndexOf',
1381
+ 'slice', 'substring', 'trim', 'trimStart', 'trimEnd', 'toLowerCase',
1382
+ 'toUpperCase', 'split', 'replace', 'replaceAll', 'match', 'search',
1383
+ 'startsWith', 'endsWith', 'padStart', 'padEnd', 'repeat', 'at',
1384
+ 'toString', 'valueOf',
1385
+ ]);
1386
+
1387
+ const SAFE_NUMBER_METHODS = new Set([
1388
+ 'toFixed', 'toPrecision', 'toString', 'valueOf',
1389
+ ]);
1390
+
1391
+ const SAFE_OBJECT_METHODS = new Set([
1392
+ 'hasOwnProperty', 'toString', 'valueOf',
1393
+ ]);
1394
+
1395
+ const SAFE_MATH_PROPS = new Set([
1396
+ 'PI', 'E', 'LN2', 'LN10', 'LOG2E', 'LOG10E', 'SQRT2', 'SQRT1_2',
1397
+ 'abs', 'ceil', 'floor', 'round', 'trunc', 'max', 'min', 'pow',
1398
+ 'sqrt', 'sign', 'random', 'log', 'log2', 'log10',
1399
+ ]);
1400
+
1401
+ const SAFE_JSON_PROPS = new Set(['parse', 'stringify']);
1402
+
1403
+ /**
1404
+ * Check if property access is safe
1405
+ */
1406
+ function _isSafeAccess(obj, prop) {
1407
+ // Never allow access to dangerous properties
1408
+ const BLOCKED = new Set([
1409
+ 'constructor', '__proto__', 'prototype', '__defineGetter__',
1410
+ '__defineSetter__', '__lookupGetter__', '__lookupSetter__',
1411
+ ]);
1412
+ if (typeof prop === 'string' && BLOCKED.has(prop)) return false;
1413
+
1414
+ // Always allow plain object/function property access and array index access
1415
+ if (obj !== null && obj !== undefined && (typeof obj === 'object' || typeof obj === 'function')) return true;
1416
+ if (typeof obj === 'string') return SAFE_STRING_METHODS.has(prop);
1417
+ if (typeof obj === 'number') return SAFE_NUMBER_METHODS.has(prop);
1418
+ return false;
1419
+ }
1420
+
1421
+ function evaluate(node, scope) {
1422
+ if (!node) return undefined;
1423
+
1424
+ switch (node.type) {
1425
+ case 'literal':
1426
+ return node.value;
1427
+
1428
+ case 'ident': {
1429
+ const name = node.name;
1430
+ // Check scope layers in order
1431
+ for (const layer of scope) {
1432
+ if (layer && typeof layer === 'object' && name in layer) {
1433
+ return layer[name];
1434
+ }
1435
+ }
1436
+ // Built-in globals (safe ones only)
1437
+ if (name === 'Math') return Math;
1438
+ if (name === 'JSON') return JSON;
1439
+ if (name === 'Date') return Date;
1440
+ if (name === 'Array') return Array;
1441
+ if (name === 'Object') return Object;
1442
+ if (name === 'String') return String;
1443
+ if (name === 'Number') return Number;
1444
+ if (name === 'Boolean') return Boolean;
1445
+ if (name === 'parseInt') return parseInt;
1446
+ if (name === 'parseFloat') return parseFloat;
1447
+ if (name === 'isNaN') return isNaN;
1448
+ if (name === 'isFinite') return isFinite;
1449
+ if (name === 'Infinity') return Infinity;
1450
+ if (name === 'NaN') return NaN;
1451
+ if (name === 'encodeURIComponent') return encodeURIComponent;
1452
+ if (name === 'decodeURIComponent') return decodeURIComponent;
1453
+ if (name === 'console') return console;
1454
+ return undefined;
1455
+ }
1456
+
1457
+ case 'template': {
1458
+ // Template literal with interpolation
1459
+ let result = '';
1460
+ for (const part of node.parts) {
1461
+ if (typeof part === 'string') {
1462
+ result += part;
1463
+ } else if (part && part.expr) {
1464
+ result += String(safeEval(part.expr, scope) ?? '');
1465
+ }
1466
+ }
1467
+ return result;
1468
+ }
1469
+
1470
+ case 'member': {
1471
+ const obj = evaluate(node.obj, scope);
1472
+ if (obj == null) return undefined;
1473
+ const prop = node.computed ? evaluate(node.prop, scope) : node.prop;
1474
+ if (!_isSafeAccess(obj, prop)) return undefined;
1475
+ return obj[prop];
1476
+ }
1477
+
1478
+ case 'optional_member': {
1479
+ const obj = evaluate(node.obj, scope);
1480
+ if (obj == null) return undefined;
1481
+ const prop = node.computed ? evaluate(node.prop, scope) : node.prop;
1482
+ if (!_isSafeAccess(obj, prop)) return undefined;
1483
+ return obj[prop];
1484
+ }
1485
+
1486
+ case 'call': {
1487
+ const result = _resolveCall(node, scope, false);
1488
+ return result;
1489
+ }
1490
+
1491
+ case 'optional_call': {
1492
+ const callee = evaluate(node.callee, scope);
1493
+ if (callee == null) return undefined;
1494
+ if (typeof callee !== 'function') return undefined;
1495
+ const args = node.args.map(a => evaluate(a, scope));
1496
+ return callee(...args);
1497
+ }
1498
+
1499
+ case 'new': {
1500
+ const Ctor = evaluate(node.callee, scope);
1501
+ if (typeof Ctor !== 'function') return undefined;
1502
+ // Only allow safe constructors
1503
+ if (Ctor === Date || Ctor === Array || Ctor === Map || Ctor === Set ||
1504
+ Ctor === RegExp || Ctor === Error || Ctor === URL || Ctor === URLSearchParams) {
1505
+ const args = node.args.map(a => evaluate(a, scope));
1506
+ return new Ctor(...args);
1507
+ }
1508
+ return undefined;
1509
+ }
1510
+
1511
+ case 'binary':
1512
+ return _evalBinary(node, scope);
1513
+
1514
+ case 'unary': {
1515
+ const val = evaluate(node.arg, scope);
1516
+ return node.op === '-' ? -val : +val;
1517
+ }
1518
+
1519
+ case 'not':
1520
+ return !evaluate(node.arg, scope);
1521
+
1522
+ case 'typeof': {
1523
+ try {
1524
+ return typeof evaluate(node.arg, scope);
1525
+ } catch {
1526
+ return 'undefined';
1527
+ }
1528
+ }
1529
+
1530
+ case 'ternary': {
1531
+ const cond = evaluate(node.cond, scope);
1532
+ return cond ? evaluate(node.truthy, scope) : evaluate(node.falsy, scope);
1533
+ }
1534
+
1535
+ case 'array':
1536
+ return node.elements.map(e => evaluate(e, scope));
1537
+
1538
+ case 'object': {
1539
+ const obj = {};
1540
+ for (const { key, value } of node.properties) {
1541
+ obj[key] = evaluate(value, scope);
1542
+ }
1543
+ return obj;
1544
+ }
1545
+
1546
+ case 'arrow': {
1547
+ const paramNames = node.params;
1548
+ const bodyNode = node.body;
1549
+ const closedScope = scope;
1550
+ return function(...args) {
1551
+ const arrowScope = {};
1552
+ paramNames.forEach((name, i) => { arrowScope[name] = args[i]; });
1553
+ return evaluate(bodyNode, [arrowScope, ...closedScope]);
1554
+ };
1555
+ }
1556
+
1557
+ default:
1558
+ return undefined;
1559
+ }
1560
+ }
1561
+
1562
+ /**
1563
+ * Resolve and execute a function call safely.
1564
+ */
1565
+ function _resolveCall(node, scope) {
1566
+ const callee = node.callee;
1567
+ const args = node.args.map(a => evaluate(a, scope));
1568
+
1569
+ // Method call: obj.method() — bind `this` to obj
1570
+ if (callee.type === 'member' || callee.type === 'optional_member') {
1571
+ const obj = evaluate(callee.obj, scope);
1572
+ if (obj == null) return undefined;
1573
+ const prop = callee.computed ? evaluate(callee.prop, scope) : callee.prop;
1574
+ if (!_isSafeAccess(obj, prop)) return undefined;
1575
+ const fn = obj[prop];
1576
+ if (typeof fn !== 'function') return undefined;
1577
+ return fn.apply(obj, args);
1578
+ }
1579
+
1580
+ // Direct call: fn(args)
1581
+ const fn = evaluate(callee, scope);
1582
+ if (typeof fn !== 'function') return undefined;
1583
+ return fn(...args);
1584
+ }
1585
+
1586
+ /**
1587
+ * Evaluate binary expression.
1588
+ */
1589
+ function _evalBinary(node, scope) {
1590
+ // Short-circuit for logical ops
1591
+ if (node.op === '&&') {
1592
+ const left = evaluate(node.left, scope);
1593
+ return left ? evaluate(node.right, scope) : left;
1594
+ }
1595
+ if (node.op === '||') {
1596
+ const left = evaluate(node.left, scope);
1597
+ return left ? left : evaluate(node.right, scope);
1598
+ }
1599
+ if (node.op === '??') {
1600
+ const left = evaluate(node.left, scope);
1601
+ return left != null ? left : evaluate(node.right, scope);
1602
+ }
1603
+
1604
+ const left = evaluate(node.left, scope);
1605
+ const right = evaluate(node.right, scope);
1606
+
1607
+ switch (node.op) {
1608
+ case '+': return left + right;
1609
+ case '-': return left - right;
1610
+ case '*': return left * right;
1611
+ case '/': return left / right;
1612
+ case '%': return left % right;
1613
+ case '==': return left == right;
1614
+ case '!=': return left != right;
1615
+ case '===': return left === right;
1616
+ case '!==': return left !== right;
1617
+ case '<': return left < right;
1618
+ case '>': return left > right;
1619
+ case '<=': return left <= right;
1620
+ case '>=': return left >= right;
1621
+ case 'instanceof': return left instanceof right;
1622
+ case 'in': return left in right;
1623
+ default: return undefined;
1624
+ }
1625
+ }
1626
+
1627
+
1628
+ // ---------------------------------------------------------------------------
1629
+ // Public API
1630
+ // ---------------------------------------------------------------------------
1631
+
1632
+ /**
1633
+ * Safely evaluate a JS expression string against scope layers.
1634
+ *
1635
+ * @param {string} expr — expression string
1636
+ * @param {object[]} scope — array of scope objects, checked in order
1637
+ * Typical: [loopVars, state, { props, refs, $ }]
1638
+ * @returns {*} — evaluation result, or undefined on error
1639
+ */
1640
+ function safeEval(expr, scope) {
1641
+ try {
1642
+ const trimmed = expr.trim();
1643
+ if (!trimmed) return undefined;
1644
+ const tokens = tokenize(trimmed);
1645
+ const parser = new Parser(tokens, scope);
1646
+ const ast = parser.parse();
1647
+ return evaluate(ast, scope);
1648
+ } catch (err) {
1649
+ if (typeof console !== 'undefined' && console.debug) {
1650
+ console.debug(`[zQuery EXPR_EVAL] Failed to evaluate: "${expr}"`, err.message);
1651
+ }
1652
+ return undefined;
1653
+ }
1654
+ }
1655
+
1656
+ // --- src/diff.js —————————————————————————————————————————————————
1657
+ /**
1658
+ * zQuery Diff — Lightweight DOM morphing engine
1659
+ *
1660
+ * Patches an existing DOM tree to match new HTML without destroying nodes
1661
+ * that haven't changed. Preserves focus, scroll positions, third-party
1662
+ * widget state, video playback, and other live DOM state.
1663
+ *
1664
+ * Approach: walk old and new trees in parallel, reconcile node by node.
1665
+ * Keyed elements (via `z-key`) get matched across position changes.
1666
+ */
1667
+
1668
+ // ---------------------------------------------------------------------------
1669
+ // morph(existingRoot, newHTML) — patch existing DOM to match newHTML
1670
+ // ---------------------------------------------------------------------------
1671
+
1672
+ /**
1673
+ * Morph an existing DOM element's children to match new HTML.
1674
+ * Only touches nodes that actually differ.
1675
+ *
1676
+ * @param {Element} rootEl — The live DOM container to patch
1677
+ * @param {string} newHTML — The desired HTML string
1678
+ */
1679
+ function morph(rootEl, newHTML) {
1680
+ const template = document.createElement('template');
1681
+ template.innerHTML = newHTML;
1682
+ const newRoot = template.content;
1683
+
1684
+ // Convert to element for consistent handling — wrap in a div if needed
1685
+ const tempDiv = document.createElement('div');
1686
+ while (newRoot.firstChild) tempDiv.appendChild(newRoot.firstChild);
1687
+
1688
+ _morphChildren(rootEl, tempDiv);
1689
+ }
1690
+
1691
+ /**
1692
+ * Reconcile children of `oldParent` to match `newParent`.
1693
+ *
1694
+ * @param {Element} oldParent — live DOM parent
1695
+ * @param {Element} newParent — desired state parent
1696
+ */
1697
+ function _morphChildren(oldParent, newParent) {
1698
+ const oldChildren = [...oldParent.childNodes];
1699
+ const newChildren = [...newParent.childNodes];
1700
+
1701
+ // Build key maps for keyed element matching
1702
+ const oldKeyMap = new Map();
1703
+ const newKeyMap = new Map();
1704
+
1705
+ for (let i = 0; i < oldChildren.length; i++) {
1706
+ const key = _getKey(oldChildren[i]);
1707
+ if (key != null) oldKeyMap.set(key, i);
1708
+ }
1709
+ for (let i = 0; i < newChildren.length; i++) {
1710
+ const key = _getKey(newChildren[i]);
1711
+ if (key != null) newKeyMap.set(key, i);
1712
+ }
1713
+
1714
+ const hasKeys = oldKeyMap.size > 0 || newKeyMap.size > 0;
1715
+
1716
+ if (hasKeys) {
1717
+ _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap);
1718
+ } else {
1719
+ _morphChildrenUnkeyed(oldParent, oldChildren, newChildren);
1720
+ }
1721
+ }
1722
+
1723
+ /**
1724
+ * Unkeyed reconciliation — positional matching.
1725
+ */
1726
+ function _morphChildrenUnkeyed(oldParent, oldChildren, newChildren) {
1727
+ const maxLen = Math.max(oldChildren.length, newChildren.length);
1728
+
1729
+ for (let i = 0; i < maxLen; i++) {
1730
+ const oldNode = oldChildren[i];
1731
+ const newNode = newChildren[i];
1732
+
1733
+ if (!oldNode && newNode) {
1734
+ // New node — append
1735
+ oldParent.appendChild(newNode.cloneNode(true));
1736
+ } else if (oldNode && !newNode) {
1737
+ // Extra old node — remove
1738
+ oldParent.removeChild(oldNode);
1739
+ } else if (oldNode && newNode) {
1740
+ _morphNode(oldParent, oldNode, newNode);
1741
+ }
1742
+ }
1743
+ }
1744
+
1745
+ /**
1746
+ * Keyed reconciliation — match by z-key, reorder minimal moves.
1747
+ */
1748
+ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap) {
1749
+ // Track which old nodes are consumed
1750
+ const consumed = new Set();
1751
+
1752
+ // Step 1: Build ordered list of matched old nodes for new children
1753
+ const newLen = newChildren.length;
1754
+ const matched = new Array(newLen); // matched[newIdx] = oldNode | null
1755
+
1756
+ for (let i = 0; i < newLen; i++) {
1757
+ const key = _getKey(newChildren[i]);
1758
+ if (key != null && oldKeyMap.has(key)) {
1759
+ const oldIdx = oldKeyMap.get(key);
1760
+ matched[i] = oldChildren[oldIdx];
1761
+ consumed.add(oldIdx);
1762
+ } else {
1763
+ matched[i] = null;
1764
+ }
1765
+ }
1766
+
1767
+ // Step 2: Remove old nodes that are not in the new tree
1768
+ for (let i = oldChildren.length - 1; i >= 0; i--) {
1769
+ if (!consumed.has(i)) {
1770
+ const key = _getKey(oldChildren[i]);
1771
+ if (key != null && !newKeyMap.has(key)) {
1772
+ oldParent.removeChild(oldChildren[i]);
1773
+ } else if (key == null) {
1774
+ // Unkeyed old node — will be handled positionally below
1775
+ }
1776
+ }
1777
+ }
1778
+
1779
+ // Step 3: Insert/reorder/morph
1780
+ let cursor = oldParent.firstChild;
1781
+ const unkeyedOld = oldChildren.filter((n, i) => !consumed.has(i) && _getKey(n) == null);
1782
+ let unkeyedIdx = 0;
1783
+
1784
+ for (let i = 0; i < newLen; i++) {
1785
+ const newNode = newChildren[i];
1786
+ const newKey = _getKey(newNode);
1787
+ let oldNode = matched[i];
1788
+
1789
+ if (!oldNode && newKey == null) {
1790
+ // Try to match an unkeyed old node positionally
1791
+ oldNode = unkeyedOld[unkeyedIdx++] || null;
1792
+ }
1793
+
1794
+ if (oldNode) {
1795
+ // Move into position if needed
1796
+ if (oldNode !== cursor) {
1797
+ oldParent.insertBefore(oldNode, cursor);
1798
+ }
1799
+ // Morph in place
1800
+ _morphNode(oldParent, oldNode, newNode);
1801
+ cursor = oldNode.nextSibling;
1802
+ } else {
1803
+ // Insert new node
1804
+ const clone = newNode.cloneNode(true);
1805
+ if (cursor) {
1806
+ oldParent.insertBefore(clone, cursor);
1807
+ } else {
1808
+ oldParent.appendChild(clone);
1809
+ }
1810
+ // cursor stays the same — new node is before it
1811
+ }
1812
+ }
1813
+
1814
+ // Remove any remaining unkeyed old nodes at the end
1815
+ while (unkeyedIdx < unkeyedOld.length) {
1816
+ const leftover = unkeyedOld[unkeyedIdx++];
1817
+ if (leftover.parentNode === oldParent) {
1818
+ oldParent.removeChild(leftover);
1819
+ }
1820
+ }
1821
+
1822
+ // Remove any remaining keyed old nodes that weren't consumed
1823
+ for (let i = 0; i < oldChildren.length; i++) {
1824
+ if (!consumed.has(i)) {
1825
+ const node = oldChildren[i];
1826
+ if (node.parentNode === oldParent && _getKey(node) != null && !newKeyMap.has(_getKey(node))) {
1827
+ oldParent.removeChild(node);
1828
+ }
1829
+ }
1830
+ }
1831
+ }
1832
+
1833
+ /**
1834
+ * Morph a single node in place.
1835
+ */
1836
+ function _morphNode(parent, oldNode, newNode) {
1837
+ // Text / comment nodes — just update content
1838
+ if (oldNode.nodeType === 3 || oldNode.nodeType === 8) {
1839
+ if (newNode.nodeType === oldNode.nodeType) {
1840
+ if (oldNode.nodeValue !== newNode.nodeValue) {
1841
+ oldNode.nodeValue = newNode.nodeValue;
1842
+ }
1843
+ return;
1844
+ }
1845
+ // Different node types — replace
1846
+ parent.replaceChild(newNode.cloneNode(true), oldNode);
1847
+ return;
1848
+ }
1849
+
1850
+ // Different node types or tag names — replace entirely
1851
+ if (oldNode.nodeType !== newNode.nodeType ||
1852
+ oldNode.nodeName !== newNode.nodeName) {
1853
+ parent.replaceChild(newNode.cloneNode(true), oldNode);
1854
+ return;
1855
+ }
1856
+
1857
+ // Both are elements — diff attributes then recurse children
1858
+ if (oldNode.nodeType === 1) {
1859
+ _morphAttributes(oldNode, newNode);
1860
+
1861
+ // Special elements: don't recurse into their children
1862
+ // (textarea value, input value, select, etc.)
1863
+ const tag = oldNode.nodeName;
1864
+ if (tag === 'INPUT') {
1865
+ _syncInputValue(oldNode, newNode);
1866
+ return;
1867
+ }
1868
+ if (tag === 'TEXTAREA') {
1869
+ if (oldNode.value !== newNode.textContent) {
1870
+ oldNode.value = newNode.textContent || '';
1871
+ }
1872
+ return;
1873
+ }
1874
+ if (tag === 'SELECT') {
1875
+ // Recurse children (options) then sync value
1876
+ _morphChildren(oldNode, newNode);
1877
+ if (oldNode.value !== newNode.value) {
1878
+ oldNode.value = newNode.value;
1879
+ }
1880
+ return;
1881
+ }
1882
+
1883
+ // Generic element — recurse children
1884
+ _morphChildren(oldNode, newNode);
1885
+ }
1886
+ }
1887
+
1888
+ /**
1889
+ * Sync attributes from newEl onto oldEl.
1890
+ */
1891
+ function _morphAttributes(oldEl, newEl) {
1892
+ // Add/update attributes
1893
+ const newAttrs = newEl.attributes;
1894
+ for (let i = 0; i < newAttrs.length; i++) {
1895
+ const attr = newAttrs[i];
1896
+ if (oldEl.getAttribute(attr.name) !== attr.value) {
1897
+ oldEl.setAttribute(attr.name, attr.value);
1898
+ }
1899
+ }
1900
+
1901
+ // Remove stale attributes
1902
+ const oldAttrs = oldEl.attributes;
1903
+ for (let i = oldAttrs.length - 1; i >= 0; i--) {
1904
+ const attr = oldAttrs[i];
1905
+ if (!newEl.hasAttribute(attr.name)) {
1906
+ oldEl.removeAttribute(attr.name);
1907
+ }
1908
+ }
1909
+ }
1910
+
1911
+ /**
1912
+ * Sync input element value, checked, disabled states.
1913
+ */
1914
+ function _syncInputValue(oldEl, newEl) {
1915
+ const type = (oldEl.type || '').toLowerCase();
1916
+
1917
+ if (type === 'checkbox' || type === 'radio') {
1918
+ if (oldEl.checked !== newEl.checked) oldEl.checked = newEl.checked;
1919
+ } else {
1920
+ if (oldEl.value !== (newEl.getAttribute('value') || '')) {
1921
+ oldEl.value = newEl.getAttribute('value') || '';
1922
+ }
1923
+ }
1924
+
1925
+ // Sync disabled
1926
+ if (oldEl.disabled !== newEl.disabled) oldEl.disabled = newEl.disabled;
1927
+ }
1928
+
1929
+ /**
1930
+ * Get the reconciliation key from a node (z-key attribute).
1931
+ * @returns {string|null}
1932
+ */
1933
+ function _getKey(node) {
1934
+ if (node.nodeType !== 1) return null;
1935
+ return node.getAttribute('z-key') || null;
1936
+ }
1937
+
670
1938
  // --- src/component.js ————————————————————————————————————————————
671
1939
  /**
672
1940
  * zQuery Component — Lightweight reactive component system
@@ -690,6 +1958,9 @@ query.fn = ZQueryCollection.prototype;
690
1958
  */
691
1959
 
692
1960
 
1961
+
1962
+
1963
+
693
1964
  // ---------------------------------------------------------------------------
694
1965
  // Component registry & external resource cache
695
1966
  // ---------------------------------------------------------------------------
@@ -864,10 +2135,27 @@ class Component {
864
2135
  this._destroyed = false;
865
2136
  this._updateQueued = false;
866
2137
  this._listeners = [];
2138
+ this._watchCleanups = [];
867
2139
 
868
2140
  // Refs map
869
2141
  this.refs = {};
870
2142
 
2143
+ // Capture slot content before first render replaces it
2144
+ this._slotContent = {};
2145
+ const defaultSlotNodes = [];
2146
+ [...el.childNodes].forEach(node => {
2147
+ if (node.nodeType === 1 && node.hasAttribute('slot')) {
2148
+ const slotName = node.getAttribute('slot');
2149
+ if (!this._slotContent[slotName]) this._slotContent[slotName] = '';
2150
+ this._slotContent[slotName] += node.outerHTML;
2151
+ } else if (node.nodeType === 1 || (node.nodeType === 3 && node.textContent.trim())) {
2152
+ defaultSlotNodes.push(node.nodeType === 1 ? node.outerHTML : node.textContent);
2153
+ }
2154
+ });
2155
+ if (defaultSlotNodes.length) {
2156
+ this._slotContent['default'] = defaultSlotNodes.join('');
2157
+ }
2158
+
871
2159
  // Props (read-only from parent)
872
2160
  this.props = Object.freeze({ ...props });
873
2161
 
@@ -876,10 +2164,25 @@ class Component {
876
2164
  ? definition.state()
877
2165
  : { ...(definition.state || {}) };
878
2166
 
879
- this.state = reactive(initialState, () => {
880
- if (!this._destroyed) 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
+ }
881
2173
  });
882
2174
 
2175
+ // Computed properties — lazy getters derived from state
2176
+ this.computed = {};
2177
+ if (definition.computed) {
2178
+ for (const [name, fn] of Object.entries(definition.computed)) {
2179
+ Object.defineProperty(this.computed, name, {
2180
+ get: () => fn.call(this, this.state.__raw || this.state),
2181
+ enumerable: true
2182
+ });
2183
+ }
2184
+ }
2185
+
883
2186
  // Bind all user methods to this instance
884
2187
  for (const [key, val] of Object.entries(definition)) {
885
2188
  if (typeof val === 'function' && !_reservedKeys.has(key)) {
@@ -888,7 +2191,36 @@ class Component {
888
2191
  }
889
2192
 
890
2193
  // Init lifecycle
891
- if (definition.init) 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
+ }
892
2224
  }
893
2225
 
894
2226
  // Schedule a batched DOM update (microtask)
@@ -1063,17 +2395,29 @@ class Component {
1063
2395
  // Then do global {{expression}} interpolation on the remaining content
1064
2396
  html = html.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
1065
2397
  try {
1066
- return new Function('state', 'props', '$', `with(state){return ${expr.trim()}}`)(
2398
+ const result = safeEval(expr.trim(), [
1067
2399
  this.state.__raw || this.state,
1068
- this.props,
1069
- typeof window !== 'undefined' ? window.$ : undefined
1070
- );
2400
+ { props: this.props, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
2401
+ ]);
2402
+ return result != null ? result : '';
1071
2403
  } catch { return ''; }
1072
2404
  });
1073
2405
  } else {
1074
2406
  html = '';
1075
2407
  }
1076
2408
 
2409
+ // -- Slot distribution ----------------------------------------
2410
+ // Replace <slot> elements with captured slot content from parent.
2411
+ // <slot> → default slot content
2412
+ // <slot name="header"> → named slot content
2413
+ // Fallback content between <slot>...</slot> used when no content provided.
2414
+ if (html.includes('<slot')) {
2415
+ html = html.replace(/<slot(?:\s+name="([^"]*)")?\s*(?:\/>|>([\s\S]*?)<\/slot>)/g, (_, name, fallback) => {
2416
+ const slotName = name || 'default';
2417
+ return this._slotContent[slotName] || fallback || '';
2418
+ });
2419
+ }
2420
+
1077
2421
  // Combine inline styles + external styles
1078
2422
  const combinedStyles = [
1079
2423
  this._def.styles || '',
@@ -1084,7 +2428,22 @@ class Component {
1084
2428
  if (!this._mounted && combinedStyles) {
1085
2429
  const scopeAttr = `z-s${this._uid}`;
1086
2430
  this._el.setAttribute(scopeAttr, '');
1087
- 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
+ }
1088
2447
  return selector.split(',').map(s => `[${scopeAttr}] ${s.trim()}`).join(', ') + ' {';
1089
2448
  });
1090
2449
  const styleEl = document.createElement('style');
@@ -1095,22 +2454,19 @@ class Component {
1095
2454
  }
1096
2455
 
1097
2456
  // -- Focus preservation ----------------------------------------
1098
- // Before replacing innerHTML, save focus state so we can restore
1099
- // cursor position after the DOM is rebuilt. Works for any focused
1100
- // input/textarea/select inside the component, not only z-model.
2457
+ // DOM morphing preserves unchanged nodes naturally, but we still
2458
+ // track focus for cases where the focused element's subtree changes.
1101
2459
  let _focusInfo = null;
1102
2460
  const _active = document.activeElement;
1103
2461
  if (_active && this._el.contains(_active)) {
1104
2462
  const modelKey = _active.getAttribute?.('z-model');
1105
2463
  const refKey = _active.getAttribute?.('z-ref');
1106
- // Build a selector that can locate the same element after re-render
1107
2464
  let selector = null;
1108
2465
  if (modelKey) {
1109
2466
  selector = `[z-model="${modelKey}"]`;
1110
2467
  } else if (refKey) {
1111
2468
  selector = `[z-ref="${refKey}"]`;
1112
2469
  } else {
1113
- // Fallback: match by tag + type + name + placeholder combination
1114
2470
  const tag = _active.tagName.toLowerCase();
1115
2471
  if (tag === 'input' || tag === 'textarea' || tag === 'select') {
1116
2472
  let s = tag;
@@ -1130,8 +2486,13 @@ class Component {
1130
2486
  }
1131
2487
  }
1132
2488
 
1133
- // Update DOM
1134
- 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
+ }
1135
2496
 
1136
2497
  // Process structural & attribute directives
1137
2498
  this._processDirectives();
@@ -1141,10 +2502,10 @@ class Component {
1141
2502
  this._bindRefs();
1142
2503
  this._bindModels();
1143
2504
 
1144
- // Restore focus after re-render
2505
+ // Restore focus if the morph replaced the focused element
1145
2506
  if (_focusInfo) {
1146
2507
  const el = this._el.querySelector(_focusInfo.selector);
1147
- if (el) {
2508
+ if (el && el !== document.activeElement) {
1148
2509
  el.focus();
1149
2510
  try {
1150
2511
  if (_focusInfo.start !== null && _focusInfo.start !== undefined) {
@@ -1159,9 +2520,15 @@ class Component {
1159
2520
 
1160
2521
  if (!this._mounted) {
1161
2522
  this._mounted = true;
1162
- if (this._def.mounted) 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
+ }
1163
2527
  } else {
1164
- 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
+ }
1165
2532
  }
1166
2533
  }
1167
2534
 
@@ -1370,18 +2737,13 @@ class Component {
1370
2737
  }
1371
2738
 
1372
2739
  // ---------------------------------------------------------------------------
1373
- // Expression evaluator — runs expr in component context (state, props, refs)
2740
+ // Expression evaluator — CSP-safe parser (no eval / new Function)
1374
2741
  // ---------------------------------------------------------------------------
1375
2742
  _evalExpr(expr) {
1376
- try {
1377
- return new Function('state', 'props', 'refs', '$',
1378
- `with(state){return (${expr})}`)(
1379
- this.state.__raw || this.state,
1380
- this.props,
1381
- this.refs,
1382
- typeof window !== 'undefined' ? window.$ : undefined
1383
- );
1384
- } catch { return undefined; }
2743
+ return safeEval(expr, [
2744
+ this.state.__raw || this.state,
2745
+ { props: this.props, refs: this.refs, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
2746
+ ]);
1385
2747
  }
1386
2748
 
1387
2749
  // ---------------------------------------------------------------------------
@@ -1443,13 +2805,15 @@ class Component {
1443
2805
  const evalReplace = (str, item, index) =>
1444
2806
  str.replace(/\{\{(.+?)\}\}/g, (_, inner) => {
1445
2807
  try {
1446
- return new Function(itemVar, indexVar, 'state', 'props', '$',
1447
- `with(state){return (${inner.trim()})}`)(
1448
- item, index,
2808
+ const loopScope = {};
2809
+ loopScope[itemVar] = item;
2810
+ loopScope[indexVar] = index;
2811
+ const result = safeEval(inner.trim(), [
2812
+ loopScope,
1449
2813
  this.state.__raw || this.state,
1450
- this.props,
1451
- typeof window !== 'undefined' ? window.$ : undefined
1452
- );
2814
+ { props: this.props, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
2815
+ ]);
2816
+ return result != null ? result : '';
1453
2817
  } catch { return ''; }
1454
2818
  });
1455
2819
 
@@ -1614,7 +2978,10 @@ class Component {
1614
2978
  destroy() {
1615
2979
  if (this._destroyed) return;
1616
2980
  this._destroyed = true;
1617
- if (this._def.destroyed) 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
+ }
1618
2985
  this._listeners.forEach(({ event, handler }) => this._el.removeEventListener(event, handler));
1619
2986
  this._listeners = [];
1620
2987
  if (this._styleEl) this._styleEl.remove();
@@ -1627,7 +2994,8 @@ class Component {
1627
2994
  // Reserved definition keys (not user methods)
1628
2995
  const _reservedKeys = new Set([
1629
2996
  'state', 'render', 'styles', 'init', 'mounted', 'updated', 'destroyed', 'props',
1630
- 'templateUrl', 'styleUrl', 'templates', 'pages', 'activePage', 'base'
2997
+ 'templateUrl', 'styleUrl', 'templates', 'pages', 'activePage', 'base',
2998
+ 'computed', 'watch'
1631
2999
  ]);
1632
3000
 
1633
3001
 
@@ -1641,8 +3009,11 @@ const _reservedKeys = new Set([
1641
3009
  * @param {object} definition — component definition
1642
3010
  */
1643
3011
  function component(name, definition) {
3012
+ if (!name || typeof name !== 'string') {
3013
+ throw new ZQueryError(ErrorCode.COMP_INVALID_NAME, 'Component name must be a non-empty string');
3014
+ }
1644
3015
  if (!name.includes('-')) {
1645
- throw new 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)`);
1646
3017
  }
1647
3018
  definition._name = name;
1648
3019
 
@@ -1667,10 +3038,10 @@ function component(name, definition) {
1667
3038
  */
1668
3039
  function mount(target, componentName, props = {}) {
1669
3040
  const el = typeof target === 'string' ? document.querySelector(target) : target;
1670
- if (!el) throw new Error(`zQuery: Mount target "${target}" not found`);
3041
+ if (!el) throw new ZQueryError(ErrorCode.COMP_MOUNT_TARGET, `Mount target "${target}" not found`, { target });
1671
3042
 
1672
3043
  const def = _registry.get(componentName);
1673
- if (!def) throw new Error(`zQuery: Component "${componentName}" not registered`);
3044
+ if (!def) throw new ZQueryError(ErrorCode.COMP_NOT_FOUND, `Component "${componentName}" not registered`, { component: componentName });
1674
3045
 
1675
3046
  // Destroy existing instance
1676
3047
  if (_instances.has(el)) _instances.get(el).destroy();
@@ -1693,12 +3064,40 @@ function mountAll(root = document.body) {
1693
3064
 
1694
3065
  // Extract props from attributes
1695
3066
  const props = {};
3067
+
3068
+ // Find parent component instance for evaluating dynamic prop expressions
3069
+ let parentInstance = null;
3070
+ let ancestor = tag.parentElement;
3071
+ while (ancestor) {
3072
+ if (_instances.has(ancestor)) {
3073
+ parentInstance = _instances.get(ancestor);
3074
+ break;
3075
+ }
3076
+ ancestor = ancestor.parentElement;
3077
+ }
3078
+
1696
3079
  [...tag.attributes].forEach(attr => {
1697
- if (!attr.name.startsWith('@') && !attr.name.startsWith('z-')) {
1698
- // Try JSON parse for objects/arrays
1699
- try { props[attr.name] = JSON.parse(attr.value); }
1700
- 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;
1701
3096
  }
3097
+
3098
+ // Static prop
3099
+ try { props[attr.name] = JSON.parse(attr.value); }
3100
+ catch { props[attr.name] = attr.value; }
1702
3101
  });
1703
3102
 
1704
3103
  const instance = new Component(tag, def, props);
@@ -1857,6 +3256,7 @@ function style(urls, opts = {}) {
1857
3256
  */
1858
3257
 
1859
3258
 
3259
+
1860
3260
  class Router {
1861
3261
  constructor(config = {}) {
1862
3262
  this._el = null;
@@ -2127,10 +3527,15 @@ class Router {
2127
3527
 
2128
3528
  // Run before guards
2129
3529
  for (const guard of this._guards.before) {
2130
- const result = await guard(to, from);
2131
- if (result === false) return; // Cancel
2132
- if (typeof result === 'string') { // Redirect
2133
- 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;
2134
3539
  }
2135
3540
  }
2136
3541
 
@@ -2138,7 +3543,7 @@ class Router {
2138
3543
  if (matched.load) {
2139
3544
  try { await matched.load(); }
2140
3545
  catch (err) {
2141
- 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);
2142
3547
  return;
2143
3548
  }
2144
3549
  }
@@ -2234,6 +3639,7 @@ function getRouter() {
2234
3639
  */
2235
3640
 
2236
3641
 
3642
+
2237
3643
  class Store {
2238
3644
  constructor(config = {}) {
2239
3645
  this._subscribers = new Map(); // key → Set<fn>
@@ -2250,9 +3656,15 @@ class Store {
2250
3656
  this.state = reactive(initial, (key, value, old) => {
2251
3657
  // Notify key-specific subscribers
2252
3658
  const subs = this._subscribers.get(key);
2253
- if (subs) subs.forEach(fn => 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
+ });
2254
3663
  // Notify wildcard subscribers
2255
- 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
+ });
2256
3668
  });
2257
3669
 
2258
3670
  // Build getters as computed properties
@@ -2273,23 +3685,32 @@ class Store {
2273
3685
  dispatch(name, ...args) {
2274
3686
  const action = this._actions[name];
2275
3687
  if (!action) {
2276
- console.warn(`zQuery Store: Unknown action "${name}"`);
3688
+ reportError(ErrorCode.STORE_ACTION, `Unknown action "${name}"`, { action: name, args });
2277
3689
  return;
2278
3690
  }
2279
3691
 
2280
3692
  // Run middleware
2281
3693
  for (const mw of this._middleware) {
2282
- const result = mw(name, args, this.state);
2283
- 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
+ }
2284
3701
  }
2285
3702
 
2286
3703
  if (this._debug) {
2287
3704
  console.log(`%c[Store] ${name}`, 'color: #4CAF50; font-weight: bold;', ...args);
2288
3705
  }
2289
3706
 
2290
- const result = action(this.state, ...args);
2291
- this._history.push({ action: name, args, timestamp: Date.now() });
2292
- 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
+ }
2293
3714
  }
2294
3715
 
2295
3716
  /**
@@ -2408,6 +3829,9 @@ const _interceptors = {
2408
3829
  * Core request function
2409
3830
  */
2410
3831
  async function request(method, url, data, options = {}) {
3832
+ if (!url || typeof url !== 'string') {
3833
+ throw new Error(`HTTP request requires a URL string, got ${typeof url}`);
3834
+ }
2411
3835
  let fullURL = url.startsWith('http') ? url : _config.baseURL + url;
2412
3836
  let headers = { ..._config.headers, ...options.headers };
2413
3837
  let body = undefined;
@@ -2463,16 +3887,21 @@ async function request(method, url, data, options = {}) {
2463
3887
  const contentType = response.headers.get('Content-Type') || '';
2464
3888
  let responseData;
2465
3889
 
2466
- if (contentType.includes('application/json')) {
2467
- responseData = await response.json();
2468
- } else if (contentType.includes('text/')) {
2469
- responseData = await response.text();
2470
- } else if (contentType.includes('application/octet-stream') || contentType.includes('image/')) {
2471
- responseData = await response.blob();
2472
- } else {
2473
- // Try JSON first, fall back to text
2474
- const text = await response.text();
2475
- 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);
2476
3905
  }
2477
3906
 
2478
3907
  const result = {
@@ -2846,21 +4275,24 @@ const bus = new EventBus();
2846
4275
 
2847
4276
 
2848
4277
 
4278
+
4279
+
4280
+
2849
4281
  // ---------------------------------------------------------------------------
2850
4282
  // $ — The main function & namespace
2851
4283
  // ---------------------------------------------------------------------------
2852
4284
 
2853
4285
  /**
2854
- * Main selector function
4286
+ * Main selector function — always returns a ZQueryCollection (like jQuery).
2855
4287
  *
2856
- * $('selector') → single Element (querySelector)
2857
- * $('<div>hello</div>') → create element (first created node)
2858
- * $(element) → return element as-is
4288
+ * $('selector') → ZQueryCollection (querySelectorAll)
4289
+ * $('<div>hello</div>') → ZQueryCollection from created elements
4290
+ * $(element) → ZQueryCollection wrapping the element
2859
4291
  * $(fn) → DOMContentLoaded shorthand
2860
4292
  *
2861
4293
  * @param {string|Element|NodeList|Function} selector
2862
4294
  * @param {string|Element} [context]
2863
- * @returns {Element|null}
4295
+ * @returns {ZQueryCollection}
2864
4296
  */
2865
4297
  function $(selector, context) {
2866
4298
  // $(fn) → DOM ready shorthand
@@ -2880,8 +4312,6 @@ $.tag = query.tag;
2880
4312
  Object.defineProperty($, 'name', {
2881
4313
  value: query.name, writable: true, configurable: true
2882
4314
  });
2883
- $.attr = query.attr;
2884
- $.data = query.data;
2885
4315
  $.children = query.children;
2886
4316
 
2887
4317
  // --- Collection selector ---------------------------------------------------
@@ -2923,6 +4353,8 @@ $.getInstance = getInstance;
2923
4353
  $.destroy = destroy;
2924
4354
  $.components = getRegistry;
2925
4355
  $.style = style;
4356
+ $.morph = morph;
4357
+ $.safeEval = safeEval;
2926
4358
 
2927
4359
  // --- Router ----------------------------------------------------------------
2928
4360
  $.router = createRouter;
@@ -2961,8 +4393,13 @@ $.storage = storage;
2961
4393
  $.session = session;
2962
4394
  $.bus = bus;
2963
4395
 
4396
+ // --- Error handling --------------------------------------------------------
4397
+ $.onError = onError;
4398
+ $.ZQueryError = ZQueryError;
4399
+ $.ErrorCode = ErrorCode;
4400
+
2964
4401
  // --- Meta ------------------------------------------------------------------
2965
- $.version = '0.5.2';
4402
+ $.version = '0.6.3';
2966
4403
  $.meta = {}; // populated at build time by CLI bundler
2967
4404
 
2968
4405
  $.noConflict = () => {