zero-query 0.9.0 → 0.9.5

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/src/expression.js CHANGED
@@ -169,6 +169,11 @@ function tokenize(expr) {
169
169
  tokens.push({ t: T.OP, v: ch });
170
170
  i++; continue;
171
171
  }
172
+ // Spread operator: ...
173
+ if (ch === '.' && i + 2 < len && expr[i + 1] === '.' && expr[i + 2] === '.') {
174
+ tokens.push({ t: T.OP, v: '...' });
175
+ i += 3; continue;
176
+ }
172
177
  if ('()[]{},.?:'.includes(ch)) {
173
178
  tokens.push({ t: T.PUNC, v: ch });
174
179
  i++; continue;
@@ -232,7 +237,7 @@ class Parser {
232
237
  this.next(); // consume ?
233
238
  const truthy = this.parseExpression(0);
234
239
  this.expect(T.PUNC, ':');
235
- const falsy = this.parseExpression(1);
240
+ const falsy = this.parseExpression(0);
236
241
  left = { type: 'ternary', cond: left, truthy, falsy };
237
242
  continue;
238
243
  }
@@ -373,7 +378,12 @@ class Parser {
373
378
  this.expect(T.PUNC, '(');
374
379
  const args = [];
375
380
  while (!(this.peek().t === T.PUNC && this.peek().v === ')') && this.peek().t !== T.EOF) {
376
- args.push(this.parseExpression(0));
381
+ if (this.peek().t === T.OP && this.peek().v === '...') {
382
+ this.next();
383
+ args.push({ type: 'spread', arg: this.parseExpression(0) });
384
+ } else {
385
+ args.push(this.parseExpression(0));
386
+ }
377
387
  if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
378
388
  }
379
389
  this.expect(T.PUNC, ')');
@@ -449,7 +459,12 @@ class Parser {
449
459
  this.next();
450
460
  const elements = [];
451
461
  while (!(this.peek().t === T.PUNC && this.peek().v === ']') && this.peek().t !== T.EOF) {
452
- elements.push(this.parseExpression(0));
462
+ if (this.peek().t === T.OP && this.peek().v === '...') {
463
+ this.next();
464
+ elements.push({ type: 'spread', arg: this.parseExpression(0) });
465
+ } else {
466
+ elements.push(this.parseExpression(0));
467
+ }
453
468
  if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
454
469
  }
455
470
  this.expect(T.PUNC, ']');
@@ -461,6 +476,14 @@ class Parser {
461
476
  this.next();
462
477
  const properties = [];
463
478
  while (!(this.peek().t === T.PUNC && this.peek().v === '}') && this.peek().t !== T.EOF) {
479
+ // Spread in object: { ...obj }
480
+ if (this.peek().t === T.OP && this.peek().v === '...') {
481
+ this.next();
482
+ properties.push({ spread: true, value: this.parseExpression(0) });
483
+ if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
484
+ continue;
485
+ }
486
+
464
487
  const keyTok = this.next();
465
488
  let key;
466
489
  if (keyTok.t === T.IDENT || keyTok.t === T.STR) key = keyTok.v;
@@ -492,7 +515,13 @@ class Parser {
492
515
 
493
516
  // new keyword
494
517
  if (tok.v === 'new') {
495
- const classExpr = this.parsePostfix();
518
+ let classExpr = this.parsePrimary();
519
+ // Handle member access (e.g. ns.MyClass) without consuming call args
520
+ while (this.peek().t === T.PUNC && this.peek().v === '.') {
521
+ this.next();
522
+ const prop = this.next();
523
+ classExpr = { type: 'member', obj: classExpr, prop: prop.v, computed: false };
524
+ }
496
525
  let args = [];
497
526
  if (this.peek().t === T.PUNC && this.peek().v === '(') {
498
527
  args = this._parseArgs();
@@ -560,6 +589,7 @@ function _isSafeAccess(obj, prop) {
560
589
  const BLOCKED = new Set([
561
590
  'constructor', '__proto__', 'prototype', '__defineGetter__',
562
591
  '__defineSetter__', '__lookupGetter__', '__lookupSetter__',
592
+ 'call', 'apply', 'bind',
563
593
  ]);
564
594
  if (typeof prop === 'string' && BLOCKED.has(prop)) return false;
565
595
 
@@ -603,6 +633,12 @@ function evaluate(node, scope) {
603
633
  if (name === 'encodeURIComponent') return encodeURIComponent;
604
634
  if (name === 'decodeURIComponent') return decodeURIComponent;
605
635
  if (name === 'console') return console;
636
+ if (name === 'Map') return Map;
637
+ if (name === 'Set') return Set;
638
+ if (name === 'RegExp') return RegExp;
639
+ if (name === 'Error') return Error;
640
+ if (name === 'URL') return URL;
641
+ if (name === 'URLSearchParams') return URLSearchParams;
606
642
  return undefined;
607
643
  }
608
644
 
@@ -641,10 +677,21 @@ function evaluate(node, scope) {
641
677
  }
642
678
 
643
679
  case 'optional_call': {
644
- const callee = evaluate(node.callee, scope);
680
+ const calleeNode = node.callee;
681
+ const args = _evalArgs(node.args, scope);
682
+ // Method call: obj?.method() — bind `this` to obj
683
+ if (calleeNode.type === 'member' || calleeNode.type === 'optional_member') {
684
+ const obj = evaluate(calleeNode.obj, scope);
685
+ if (obj == null) return undefined;
686
+ const prop = calleeNode.computed ? evaluate(calleeNode.prop, scope) : calleeNode.prop;
687
+ if (!_isSafeAccess(obj, prop)) return undefined;
688
+ const fn = obj[prop];
689
+ if (typeof fn !== 'function') return undefined;
690
+ return fn.apply(obj, args);
691
+ }
692
+ const callee = evaluate(calleeNode, scope);
645
693
  if (callee == null) return undefined;
646
694
  if (typeof callee !== 'function') return undefined;
647
- const args = node.args.map(a => evaluate(a, scope));
648
695
  return callee(...args);
649
696
  }
650
697
 
@@ -654,7 +701,7 @@ function evaluate(node, scope) {
654
701
  // Only allow safe constructors
655
702
  if (Ctor === Date || Ctor === Array || Ctor === Map || Ctor === Set ||
656
703
  Ctor === RegExp || Ctor === Error || Ctor === URL || Ctor === URLSearchParams) {
657
- const args = node.args.map(a => evaluate(a, scope));
704
+ const args = _evalArgs(node.args, scope);
658
705
  return new Ctor(...args);
659
706
  }
660
707
  return undefined;
@@ -684,13 +731,32 @@ function evaluate(node, scope) {
684
731
  return cond ? evaluate(node.truthy, scope) : evaluate(node.falsy, scope);
685
732
  }
686
733
 
687
- case 'array':
688
- return node.elements.map(e => evaluate(e, scope));
734
+ case 'array': {
735
+ const arr = [];
736
+ for (const e of node.elements) {
737
+ if (e.type === 'spread') {
738
+ const iterable = evaluate(e.arg, scope);
739
+ if (iterable != null && typeof iterable[Symbol.iterator] === 'function') {
740
+ for (const v of iterable) arr.push(v);
741
+ }
742
+ } else {
743
+ arr.push(evaluate(e, scope));
744
+ }
745
+ }
746
+ return arr;
747
+ }
689
748
 
690
749
  case 'object': {
691
750
  const obj = {};
692
- for (const { key, value } of node.properties) {
693
- obj[key] = evaluate(value, scope);
751
+ for (const prop of node.properties) {
752
+ if (prop.spread) {
753
+ const source = evaluate(prop.value, scope);
754
+ if (source != null && typeof source === 'object') {
755
+ Object.assign(obj, source);
756
+ }
757
+ } else {
758
+ obj[prop.key] = evaluate(prop.value, scope);
759
+ }
694
760
  }
695
761
  return obj;
696
762
  }
@@ -711,12 +777,30 @@ function evaluate(node, scope) {
711
777
  }
712
778
  }
713
779
 
780
+ /**
781
+ * Evaluate a list of argument AST nodes, flattening any spread elements.
782
+ */
783
+ function _evalArgs(argNodes, scope) {
784
+ const result = [];
785
+ for (const a of argNodes) {
786
+ if (a.type === 'spread') {
787
+ const iterable = evaluate(a.arg, scope);
788
+ if (iterable != null && typeof iterable[Symbol.iterator] === 'function') {
789
+ for (const v of iterable) result.push(v);
790
+ }
791
+ } else {
792
+ result.push(evaluate(a, scope));
793
+ }
794
+ }
795
+ return result;
796
+ }
797
+
714
798
  /**
715
799
  * Resolve and execute a function call safely.
716
800
  */
717
801
  function _resolveCall(node, scope) {
718
802
  const callee = node.callee;
719
- const args = node.args.map(a => evaluate(a, scope));
803
+ const args = _evalArgs(node.args, scope);
720
804
 
721
805
  // Method call: obj.method() — bind `this` to obj
722
806
  if (callee.type === 'member' || callee.type === 'optional_member') {
@@ -790,8 +874,9 @@ function _evalBinary(node, scope) {
790
874
  * @returns {*} — evaluation result, or undefined on error
791
875
  */
792
876
 
793
- // AST cache — avoids re-tokenizing and re-parsing the same expression string.
794
- // Bounded to prevent unbounded memory growth in long-lived apps.
877
+ // AST cache (LRU) — avoids re-tokenizing and re-parsing the same expression.
878
+ // Uses Map insertion-order: on hit, delete + re-set moves entry to the end.
879
+ // Eviction removes the least-recently-used (first) entry when at capacity.
795
880
  const _astCache = new Map();
796
881
  const _AST_CACHE_MAX = 512;
797
882
 
@@ -811,9 +896,12 @@ export function safeEval(expr, scope) {
811
896
  // Fall through to full parser for built-in globals (Math, JSON, etc.)
812
897
  }
813
898
 
814
- // Check AST cache
899
+ // Check AST cache (LRU: move to end on hit)
815
900
  let ast = _astCache.get(trimmed);
816
- if (!ast) {
901
+ if (ast) {
902
+ _astCache.delete(trimmed);
903
+ _astCache.set(trimmed, ast);
904
+ } else {
817
905
  const tokens = tokenize(trimmed);
818
906
  const parser = new Parser(tokens, scope);
819
907
  ast = parser.parse();
package/src/http.js CHANGED
@@ -65,11 +65,28 @@ async function request(method, url, data, options = {}) {
65
65
 
66
66
  // Timeout via AbortController
67
67
  const controller = new AbortController();
68
- fetchOpts.signal = options.signal || controller.signal;
69
68
  const timeout = options.timeout ?? _config.timeout;
70
69
  let timer;
70
+ // Combine user signal with internal controller for proper timeout support
71
+ if (options.signal) {
72
+ // If AbortSignal.any is available, combine both signals
73
+ if (typeof AbortSignal.any === 'function') {
74
+ fetchOpts.signal = AbortSignal.any([options.signal, controller.signal]);
75
+ } else {
76
+ // Fallback: forward user signal's abort to our controller
77
+ fetchOpts.signal = controller.signal;
78
+ if (options.signal.aborted) {
79
+ controller.abort(options.signal.reason);
80
+ } else {
81
+ options.signal.addEventListener('abort', () => controller.abort(options.signal.reason), { once: true });
82
+ }
83
+ }
84
+ } else {
85
+ fetchOpts.signal = controller.signal;
86
+ }
87
+ let _timedOut = false;
71
88
  if (timeout > 0) {
72
- timer = setTimeout(() => controller.abort(), timeout);
89
+ timer = setTimeout(() => { _timedOut = true; controller.abort(); }, timeout);
73
90
  }
74
91
 
75
92
  // Run request interceptors
@@ -129,7 +146,10 @@ async function request(method, url, data, options = {}) {
129
146
  } catch (err) {
130
147
  if (timer) clearTimeout(timer);
131
148
  if (err.name === 'AbortError') {
132
- throw new Error(`Request timeout after ${timeout}ms: ${method} ${fullURL}`);
149
+ if (_timedOut) {
150
+ throw new Error(`Request timeout after ${timeout}ms: ${method} ${fullURL}`);
151
+ }
152
+ throw new Error(`Request aborted: ${method} ${fullURL}`);
133
153
  }
134
154
  throw err;
135
155
  }
package/src/reactive.js CHANGED
@@ -134,7 +134,13 @@ export function signal(initial) {
134
134
  */
135
135
  export function computed(fn) {
136
136
  const s = new Signal(undefined);
137
- effect(() => { s._value = fn(); s._notify(); });
137
+ effect(() => {
138
+ const v = fn();
139
+ if (v !== s._value) {
140
+ s._value = v;
141
+ s._notify();
142
+ }
143
+ });
138
144
  return s;
139
145
  }
140
146
 
@@ -177,6 +183,6 @@ export function effect(fn) {
177
183
  }
178
184
  execute._deps.clear();
179
185
  }
180
- Signal._activeEffect = null;
186
+ // Don't clobber _activeEffect another effect may be running
181
187
  };
182
188
  }
package/src/router.js CHANGED
@@ -24,6 +24,23 @@ import { reportError, ErrorCode } from './errors.js';
24
24
  // Unique marker on history.state to identify zQuery-managed entries
25
25
  const _ZQ_STATE_KEY = '__zq';
26
26
 
27
+ /**
28
+ * Shallow-compare two flat objects (for params / query comparison).
29
+ * Avoids JSON.stringify overhead on every navigation.
30
+ */
31
+ function _shallowEqual(a, b) {
32
+ if (a === b) return true;
33
+ if (!a || !b) return false;
34
+ const keysA = Object.keys(a);
35
+ const keysB = Object.keys(b);
36
+ if (keysA.length !== keysB.length) return false;
37
+ for (let i = 0; i < keysA.length; i++) {
38
+ const k = keysA[i];
39
+ if (a[k] !== b[k]) return false;
40
+ }
41
+ return true;
42
+ }
43
+
27
44
  class Router {
28
45
  constructor(config = {}) {
29
46
  this._el = null;
@@ -71,11 +88,12 @@ class Router {
71
88
  config.routes.forEach(r => this.add(r));
72
89
  }
73
90
 
74
- // Listen for navigation
91
+ // Listen for navigation — store handler references for cleanup in destroy()
75
92
  if (this._mode === 'hash') {
76
- window.addEventListener('hashchange', () => this._resolve());
93
+ this._onNavEvent = () => this._resolve();
94
+ window.addEventListener('hashchange', this._onNavEvent);
77
95
  } else {
78
- window.addEventListener('popstate', (e) => {
96
+ this._onNavEvent = (e) => {
79
97
  // Check for substate pop first — if a listener handles it, don't route
80
98
  const st = e.state;
81
99
  if (st && st[_ZQ_STATE_KEY] === 'substate') {
@@ -89,11 +107,12 @@ class Router {
89
107
  this._fireSubstate(null, null, 'reset');
90
108
  }
91
109
  this._resolve();
92
- });
110
+ };
111
+ window.addEventListener('popstate', this._onNavEvent);
93
112
  }
94
113
 
95
114
  // Intercept link clicks for SPA navigation
96
- document.addEventListener('click', (e) => {
115
+ this._onLinkClick = (e) => {
97
116
  // Don't intercept modified clicks (Ctrl/Cmd+click = new tab)
98
117
  if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
99
118
  const link = e.target.closest('[z-link]');
@@ -115,7 +134,8 @@ class Router {
115
134
  const scrollBehavior = link.getAttribute('z-to-top') || 'instant';
116
135
  window.scrollTo({ top: 0, behavior: scrollBehavior });
117
136
  }
118
- });
137
+ };
138
+ document.addEventListener('click', this._onLinkClick);
119
139
 
120
140
  // Initial resolve
121
141
  if (this._el) {
@@ -487,8 +507,8 @@ class Router {
487
507
  // with the same params, skip the full destroy/mount cycle and just
488
508
  // update props. This prevents flashing and unnecessary DOM churn.
489
509
  if (from && this._instance && matched.component === from.route.component) {
490
- const sameParams = JSON.stringify(params) === JSON.stringify(from.params);
491
- const sameQuery = JSON.stringify(query) === JSON.stringify(from.query);
510
+ const sameParams = _shallowEqual(params, from.params);
511
+ const sameQuery = _shallowEqual(query, from.query);
492
512
  if (sameParams && sameQuery) {
493
513
  // Identical navigation — nothing to do
494
514
  return;
@@ -563,7 +583,12 @@ class Router {
563
583
  if (typeof matched.component === 'string') {
564
584
  const container = document.createElement(matched.component);
565
585
  this._el.appendChild(container);
566
- this._instance = mount(container, matched.component, props);
586
+ try {
587
+ this._instance = mount(container, matched.component, props);
588
+ } catch (err) {
589
+ reportError(ErrorCode.COMP_NOT_FOUND, `Failed to mount component for route "${matched.path}"`, { component: matched.component, path: matched.path }, err);
590
+ return;
591
+ }
567
592
  if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', matched.component);
568
593
  }
569
594
  // If component is a render function
@@ -585,6 +610,15 @@ class Router {
585
610
  // --- Destroy -------------------------------------------------------------
586
611
 
587
612
  destroy() {
613
+ // Remove window/document event listeners to prevent memory leaks
614
+ if (this._onNavEvent) {
615
+ window.removeEventListener(this._mode === 'hash' ? 'hashchange' : 'popstate', this._onNavEvent);
616
+ this._onNavEvent = null;
617
+ }
618
+ if (this._onLinkClick) {
619
+ document.removeEventListener('click', this._onLinkClick);
620
+ this._onLinkClick = null;
621
+ }
588
622
  if (this._instance) this._instance.destroy();
589
623
  this._listeners.clear();
590
624
  this._substateListeners = [];
package/src/ssr.js CHANGED
@@ -192,7 +192,7 @@ class SSRApp {
192
192
  ${meta}
193
193
  ${styleLinks}
194
194
  </head>
195
- <body ${bodyAttrs}>
195
+ <body ${bodyAttrs.replace(/on\w+\s*=/gi, '').replace(/javascript\s*:/gi, '')}>
196
196
  <div id="app">${content}</div>
197
197
  ${scriptTags}
198
198
  </body>
package/src/store.js CHANGED
@@ -36,6 +36,7 @@ class Store {
36
36
  this._getters = config.getters || {};
37
37
  this._middleware = [];
38
38
  this._history = []; // action log
39
+ this._maxHistory = config.maxHistory || 1000;
39
40
  this._debug = config.debug || false;
40
41
 
41
42
  // Create reactive state
@@ -95,6 +96,10 @@ class Store {
95
96
  try {
96
97
  const result = action(this.state, ...args);
97
98
  this._history.push({ action: name, args, timestamp: Date.now() });
99
+ // Cap history to prevent unbounded memory growth
100
+ if (this._history.length > this._maxHistory) {
101
+ this._history.splice(0, this._history.length - this._maxHistory);
102
+ }
98
103
  return result;
99
104
  } catch (err) {
100
105
  reportError(ErrorCode.STORE_ACTION, `Action "${name}" threw`, { action: name, args }, err);
package/src/utils.js CHANGED
@@ -79,6 +79,10 @@ export function escapeHtml(str) {
79
79
  return String(str).replace(/[&<>"']/g, c => map[c]);
80
80
  }
81
81
 
82
+ export function stripHtml(str) {
83
+ return String(str).replace(/<[^>]*>/g, '');
84
+ }
85
+
82
86
  /**
83
87
  * Template tag for auto-escaping interpolated values
84
88
  * Usage: $.html`<div>${userInput}</div>`
@@ -94,7 +98,7 @@ export function html(strings, ...values) {
94
98
  /**
95
99
  * Mark HTML as trusted (skip escaping in $.html template)
96
100
  */
97
- class TrustedHTML {
101
+ export class TrustedHTML {
98
102
  constructor(html) { this._html = html; }
99
103
  toString() { return this._html; }
100
104
  }
@@ -124,7 +128,10 @@ export function camelCase(str) {
124
128
  * CamelCase to kebab-case
125
129
  */
126
130
  export function kebabCase(str) {
127
- return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
131
+ return str
132
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
133
+ .replace(/([a-z\d])([A-Z])/g, '$1-$2')
134
+ .toLowerCase();
128
135
  }
129
136
 
130
137
 
@@ -144,30 +151,40 @@ export function deepClone(obj) {
144
151
  * Deep merge objects
145
152
  */
146
153
  export function deepMerge(target, ...sources) {
147
- for (const source of sources) {
148
- for (const key of Object.keys(source)) {
149
- if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
150
- if (!target[key] || typeof target[key] !== 'object') target[key] = {};
151
- deepMerge(target[key], source[key]);
154
+ const seen = new WeakSet();
155
+ function merge(tgt, src) {
156
+ if (seen.has(src)) return tgt;
157
+ seen.add(src);
158
+ for (const key of Object.keys(src)) {
159
+ if (src[key] && typeof src[key] === 'object' && !Array.isArray(src[key])) {
160
+ if (!tgt[key] || typeof tgt[key] !== 'object') tgt[key] = {};
161
+ merge(tgt[key], src[key]);
152
162
  } else {
153
- target[key] = source[key];
163
+ tgt[key] = src[key];
154
164
  }
155
165
  }
166
+ return tgt;
156
167
  }
168
+ for (const source of sources) merge(target, source);
157
169
  return target;
158
170
  }
159
171
 
160
172
  /**
161
173
  * Simple object equality check
162
174
  */
163
- export function isEqual(a, b) {
175
+ export function isEqual(a, b, _seen) {
164
176
  if (a === b) return true;
165
177
  if (typeof a !== typeof b) return false;
166
178
  if (typeof a !== 'object' || a === null || b === null) return false;
179
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
180
+ // Guard against circular references
181
+ if (!_seen) _seen = new Set();
182
+ if (_seen.has(a)) return true;
183
+ _seen.add(a);
167
184
  const keysA = Object.keys(a);
168
185
  const keysB = Object.keys(b);
169
186
  if (keysA.length !== keysB.length) return false;
170
- return keysA.every(k => isEqual(a[k], b[k]));
187
+ return keysA.every(k => isEqual(a[k], b[k], _seen));
171
188
  }
172
189
 
173
190
 
@@ -243,7 +260,7 @@ export const session = {
243
260
  // ---------------------------------------------------------------------------
244
261
  // Event bus (pub/sub)
245
262
  // ---------------------------------------------------------------------------
246
- class EventBus {
263
+ export class EventBus {
247
264
  constructor() { this._handlers = new Map(); }
248
265
 
249
266
  on(event, fn) {
@@ -269,3 +286,178 @@ class EventBus {
269
286
  }
270
287
 
271
288
  export const bus = new EventBus();
289
+
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Array utilities
293
+ // ---------------------------------------------------------------------------
294
+
295
+ export function range(startOrEnd, end, step) {
296
+ let s, e, st;
297
+ if (end === undefined) { s = 0; e = startOrEnd; st = 1; }
298
+ else { s = startOrEnd; e = end; st = step !== undefined ? step : 1; }
299
+ if (st === 0) return [];
300
+ const result = [];
301
+ if (st > 0) { for (let i = s; i < e; i += st) result.push(i); }
302
+ else { for (let i = s; i > e; i += st) result.push(i); }
303
+ return result;
304
+ }
305
+
306
+ export function unique(arr, keyFn) {
307
+ if (!keyFn) return [...new Set(arr)];
308
+ const seen = new Set();
309
+ return arr.filter(item => {
310
+ const k = keyFn(item);
311
+ if (seen.has(k)) return false;
312
+ seen.add(k);
313
+ return true;
314
+ });
315
+ }
316
+
317
+ export function chunk(arr, size) {
318
+ const result = [];
319
+ for (let i = 0; i < arr.length; i += size) result.push(arr.slice(i, i + size));
320
+ return result;
321
+ }
322
+
323
+ export function groupBy(arr, keyFn) {
324
+ const result = {};
325
+ for (const item of arr) {
326
+ const k = keyFn(item);
327
+ (result[k] ??= []).push(item);
328
+ }
329
+ return result;
330
+ }
331
+
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Object utilities
335
+ // ---------------------------------------------------------------------------
336
+
337
+ export function pick(obj, keys) {
338
+ const result = {};
339
+ for (const k of keys) { if (k in obj) result[k] = obj[k]; }
340
+ return result;
341
+ }
342
+
343
+ export function omit(obj, keys) {
344
+ const exclude = new Set(keys);
345
+ const result = {};
346
+ for (const k of Object.keys(obj)) { if (!exclude.has(k)) result[k] = obj[k]; }
347
+ return result;
348
+ }
349
+
350
+ export function getPath(obj, path, fallback) {
351
+ const keys = path.split('.');
352
+ let cur = obj;
353
+ for (const k of keys) {
354
+ if (cur == null || typeof cur !== 'object') return fallback;
355
+ cur = cur[k];
356
+ }
357
+ return cur === undefined ? fallback : cur;
358
+ }
359
+
360
+ export function setPath(obj, path, value) {
361
+ const keys = path.split('.');
362
+ let cur = obj;
363
+ for (let i = 0; i < keys.length - 1; i++) {
364
+ const k = keys[i];
365
+ if (cur[k] == null || typeof cur[k] !== 'object') cur[k] = {};
366
+ cur = cur[k];
367
+ }
368
+ cur[keys[keys.length - 1]] = value;
369
+ return obj;
370
+ }
371
+
372
+ export function isEmpty(val) {
373
+ if (val == null) return true;
374
+ if (typeof val === 'string' || Array.isArray(val)) return val.length === 0;
375
+ if (val instanceof Map || val instanceof Set) return val.size === 0;
376
+ if (typeof val === 'object') return Object.keys(val).length === 0;
377
+ return false;
378
+ }
379
+
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // String utilities
383
+ // ---------------------------------------------------------------------------
384
+
385
+ export function capitalize(str) {
386
+ if (!str) return '';
387
+ return str[0].toUpperCase() + str.slice(1).toLowerCase();
388
+ }
389
+
390
+ export function truncate(str, maxLen, suffix = '…') {
391
+ if (str.length <= maxLen) return str;
392
+ const end = Math.max(0, maxLen - suffix.length);
393
+ return str.slice(0, end) + suffix;
394
+ }
395
+
396
+
397
+ // ---------------------------------------------------------------------------
398
+ // Number utilities
399
+ // ---------------------------------------------------------------------------
400
+
401
+ export function clamp(val, min, max) {
402
+ return val < min ? min : val > max ? max : val;
403
+ }
404
+
405
+
406
+ // ---------------------------------------------------------------------------
407
+ // Function utilities
408
+ // ---------------------------------------------------------------------------
409
+
410
+ export function memoize(fn, keyFnOrOpts) {
411
+ let keyFn, maxSize = 0;
412
+ if (typeof keyFnOrOpts === 'function') keyFn = keyFnOrOpts;
413
+ else if (keyFnOrOpts && typeof keyFnOrOpts === 'object') maxSize = keyFnOrOpts.maxSize || 0;
414
+
415
+ const cache = new Map();
416
+
417
+ const memoized = (...args) => {
418
+ const key = keyFn ? keyFn(...args) : args[0];
419
+ if (cache.has(key)) return cache.get(key);
420
+ const result = fn(...args);
421
+ cache.set(key, result);
422
+ if (maxSize > 0 && cache.size > maxSize) {
423
+ cache.delete(cache.keys().next().value);
424
+ }
425
+ return result;
426
+ };
427
+
428
+ memoized.clear = () => cache.clear();
429
+ return memoized;
430
+ }
431
+
432
+
433
+ // ---------------------------------------------------------------------------
434
+ // Async utilities
435
+ // ---------------------------------------------------------------------------
436
+
437
+ export function retry(fn, opts = {}) {
438
+ const { attempts = 3, delay = 1000, backoff = 1 } = opts;
439
+ return new Promise((resolve, reject) => {
440
+ let attempt = 0, currentDelay = delay;
441
+ const tryOnce = () => {
442
+ attempt++;
443
+ fn(attempt).then(resolve, (err) => {
444
+ if (attempt >= attempts) return reject(err);
445
+ const d = currentDelay;
446
+ currentDelay *= backoff;
447
+ setTimeout(tryOnce, d);
448
+ });
449
+ };
450
+ tryOnce();
451
+ });
452
+ }
453
+
454
+ export function timeout(promise, ms, message) {
455
+ let timer;
456
+ const race = Promise.race([
457
+ promise,
458
+ new Promise((_, reject) => {
459
+ timer = setTimeout(() => reject(new Error(message || `Timed out after ${ms}ms`)), ms);
460
+ })
461
+ ]);
462
+ return race.finally(() => clearTimeout(timer));
463
+ }