zero-query 1.0.9 → 1.2.0
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/LICENSE +21 -21
- package/README.md +2 -0
- package/cli/args.js +33 -33
- package/cli/commands/build-api.js +443 -0
- package/cli/commands/build.js +254 -216
- package/cli/commands/bundle.js +1228 -1183
- package/cli/commands/create.js +137 -121
- package/cli/commands/dev/devtools/index.js +56 -56
- package/cli/commands/dev/devtools/js/components.js +49 -49
- package/cli/commands/dev/devtools/js/core.js +423 -423
- package/cli/commands/dev/devtools/js/elements.js +421 -421
- package/cli/commands/dev/devtools/js/network.js +166 -166
- package/cli/commands/dev/devtools/js/performance.js +73 -73
- package/cli/commands/dev/devtools/js/router.js +105 -105
- package/cli/commands/dev/devtools/js/source.js +132 -132
- package/cli/commands/dev/devtools/js/stats.js +35 -35
- package/cli/commands/dev/devtools/js/tabs.js +79 -79
- package/cli/commands/dev/devtools/panel.html +95 -95
- package/cli/commands/dev/devtools/styles.css +244 -244
- package/cli/commands/dev/index.js +107 -107
- package/cli/commands/dev/logger.js +75 -75
- package/cli/commands/dev/overlay.js +858 -858
- package/cli/commands/dev/server.js +220 -167
- package/cli/commands/dev/validator.js +94 -94
- package/cli/commands/dev/watcher.js +172 -172
- package/cli/help.js +114 -112
- package/cli/index.js +52 -52
- package/cli/scaffold/default/LICENSE +21 -21
- package/cli/scaffold/default/app/app.js +207 -207
- package/cli/scaffold/default/app/components/about.js +201 -201
- package/cli/scaffold/default/app/components/api-demo.js +143 -143
- package/cli/scaffold/default/app/components/contact-card.js +231 -231
- package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
- package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
- package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
- package/cli/scaffold/default/app/components/counter.js +127 -127
- package/cli/scaffold/default/app/components/home.js +249 -249
- package/cli/scaffold/default/app/components/not-found.js +16 -16
- package/cli/scaffold/default/app/components/playground/playground.css +115 -115
- package/cli/scaffold/default/app/components/playground/playground.html +161 -161
- package/cli/scaffold/default/app/components/playground/playground.js +116 -116
- package/cli/scaffold/default/app/components/todos.js +225 -225
- package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
- package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
- package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
- package/cli/scaffold/default/app/routes.js +15 -15
- package/cli/scaffold/default/app/store.js +101 -101
- package/cli/scaffold/default/global.css +552 -552
- package/cli/scaffold/default/index.html +99 -99
- package/cli/scaffold/minimal/app/app.js +85 -85
- package/cli/scaffold/minimal/app/components/about.js +68 -68
- package/cli/scaffold/minimal/app/components/counter.js +122 -122
- package/cli/scaffold/minimal/app/components/home.js +68 -68
- package/cli/scaffold/minimal/app/components/not-found.js +16 -16
- package/cli/scaffold/minimal/app/routes.js +9 -9
- package/cli/scaffold/minimal/app/store.js +36 -36
- package/cli/scaffold/minimal/global.css +300 -300
- package/cli/scaffold/minimal/index.html +44 -44
- package/cli/scaffold/ssr/app/app.js +41 -41
- package/cli/scaffold/ssr/app/components/about.js +55 -55
- package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
- package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
- package/cli/scaffold/ssr/app/components/home.js +37 -37
- package/cli/scaffold/ssr/app/components/not-found.js +15 -15
- package/cli/scaffold/ssr/app/routes.js +8 -8
- package/cli/scaffold/ssr/global.css +228 -228
- package/cli/scaffold/ssr/index.html +37 -37
- package/cli/scaffold/ssr/package.json +8 -8
- package/cli/scaffold/ssr/server/data/posts.js +144 -144
- package/cli/scaffold/ssr/server/index.js +213 -213
- package/cli/scaffold/webrtc/app/app.js +11 -0
- package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
- package/cli/scaffold/webrtc/app/lib/room.js +252 -0
- package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
- package/cli/scaffold/webrtc/global.css +250 -0
- package/cli/scaffold/webrtc/index.html +21 -0
- package/cli/utils.js +305 -287
- package/dist/API.md +7264 -0
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +10313 -6252
- package/dist/zquery.min.js +8 -601
- package/index.d.ts +570 -365
- package/index.js +311 -232
- package/package.json +76 -69
- package/src/component.js +1709 -1454
- package/src/core.js +921 -921
- package/src/diff.js +497 -497
- package/src/errors.js +209 -209
- package/src/expression.js +922 -922
- package/src/http.js +242 -242
- package/src/package.json +1 -1
- package/src/reactive.js +255 -254
- package/src/router.js +843 -773
- package/src/ssr.js +418 -418
- package/src/store.js +318 -272
- package/src/utils.js +515 -515
- package/src/webrtc/e2ee.js +351 -0
- package/src/webrtc/errors.js +116 -0
- package/src/webrtc/ice.js +301 -0
- package/src/webrtc/index.js +131 -0
- package/src/webrtc/joinToken.js +119 -0
- package/src/webrtc/observe.js +172 -0
- package/src/webrtc/peer.js +351 -0
- package/src/webrtc/reactive.js +268 -0
- package/src/webrtc/room.js +625 -0
- package/src/webrtc/sdp.js +302 -0
- package/src/webrtc/sfu/index.js +43 -0
- package/src/webrtc/sfu/livekit.js +131 -0
- package/src/webrtc/sfu/mediasoup.js +150 -0
- package/src/webrtc/signaling.js +373 -0
- package/src/webrtc/turn.js +237 -0
- package/tests/_helpers/webrtcFakes.js +289 -0
- package/tests/audit.test.js +4158 -4158
- package/tests/cli.test.js +1136 -1023
- package/tests/compare.test.js +497 -0
- package/tests/component.test.js +3969 -3938
- package/tests/core.test.js +1910 -1910
- package/tests/dev-server.test.js +489 -0
- package/tests/diff.test.js +1416 -1416
- package/tests/docs.test.js +1664 -0
- package/tests/electron-features.test.js +864 -0
- package/tests/errors.test.js +619 -619
- package/tests/expression.test.js +1056 -1056
- package/tests/http.test.js +648 -648
- package/tests/reactive.test.js +819 -819
- package/tests/router.test.js +2327 -2327
- package/tests/ssr.test.js +870 -870
- package/tests/store.test.js +830 -830
- package/tests/test-minifier.js +153 -153
- package/tests/test-ssr.js +27 -27
- package/tests/utils.test.js +1377 -1377
- package/tests/webrtc/e2ee.test.js +283 -0
- package/tests/webrtc/ice.test.js +202 -0
- package/tests/webrtc/joinToken.test.js +89 -0
- package/tests/webrtc/observe.test.js +111 -0
- package/tests/webrtc/peer.test.js +373 -0
- package/tests/webrtc/reactive.test.js +235 -0
- package/tests/webrtc/room.test.js +406 -0
- package/tests/webrtc/sdp.test.js +151 -0
- package/tests/webrtc/sfu-livekit.test.js +119 -0
- package/tests/webrtc/sfu.test.js +160 -0
- package/tests/webrtc/signaling.test.js +251 -0
- package/tests/webrtc/turn.test.js +256 -0
- package/types/collection.d.ts +383 -383
- package/types/component.d.ts +186 -186
- package/types/errors.d.ts +135 -135
- package/types/http.d.ts +92 -92
- package/types/misc.d.ts +201 -201
- package/types/reactive.d.ts +98 -98
- package/types/router.d.ts +190 -190
- package/types/ssr.d.ts +102 -102
- package/types/store.d.ts +146 -145
- package/types/utils.d.ts +245 -245
- package/types/webrtc.d.ts +653 -0
package/src/utils.js
CHANGED
|
@@ -1,515 +1,515 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* zQuery Utils - Common utility functions
|
|
3
|
-
*
|
|
4
|
-
* Quality-of-life helpers that every frontend project needs.
|
|
5
|
-
* Attached to $ namespace for convenience.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
// Function utilities
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Debounce - delays execution until after `ms` of inactivity
|
|
14
|
-
*/
|
|
15
|
-
export function debounce(fn, ms = 250) {
|
|
16
|
-
let timer;
|
|
17
|
-
const debounced = (...args) => {
|
|
18
|
-
clearTimeout(timer);
|
|
19
|
-
timer = setTimeout(() => fn(...args), ms);
|
|
20
|
-
};
|
|
21
|
-
debounced.cancel = () => clearTimeout(timer);
|
|
22
|
-
return debounced;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Throttle - limits execution to once per `ms`
|
|
27
|
-
*/
|
|
28
|
-
export function throttle(fn, ms = 250) {
|
|
29
|
-
let last = 0;
|
|
30
|
-
let timer;
|
|
31
|
-
return (...args) => {
|
|
32
|
-
const now = Date.now();
|
|
33
|
-
const remaining = ms - (now - last);
|
|
34
|
-
clearTimeout(timer);
|
|
35
|
-
if (remaining <= 0) {
|
|
36
|
-
last = now;
|
|
37
|
-
fn(...args);
|
|
38
|
-
} else {
|
|
39
|
-
timer = setTimeout(() => { last = Date.now(); fn(...args); }, remaining);
|
|
40
|
-
}
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Pipe - compose functions left-to-right
|
|
46
|
-
*/
|
|
47
|
-
export function pipe(...fns) {
|
|
48
|
-
return (input) => fns.reduce((val, fn) => fn(val), input);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Once - function that only runs once
|
|
53
|
-
*/
|
|
54
|
-
export function once(fn) {
|
|
55
|
-
let called = false, result;
|
|
56
|
-
return (...args) => {
|
|
57
|
-
if (!called) { called = true; result = fn(...args); }
|
|
58
|
-
return result;
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Sleep - promise-based delay
|
|
64
|
-
*/
|
|
65
|
-
export function sleep(ms) {
|
|
66
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
// ---------------------------------------------------------------------------
|
|
71
|
-
// String utilities
|
|
72
|
-
// ---------------------------------------------------------------------------
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Escape HTML entities
|
|
76
|
-
*/
|
|
77
|
-
export function escapeHtml(str) {
|
|
78
|
-
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
79
|
-
return String(str).replace(/[&<>"']/g, c => map[c]);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function stripHtml(str) {
|
|
83
|
-
return String(str).replace(/<[^>]*>/g, '');
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Template tag for auto-escaping interpolated values
|
|
88
|
-
* Usage: $.html`<div>${userInput}</div>`
|
|
89
|
-
*/
|
|
90
|
-
export function html(strings, ...values) {
|
|
91
|
-
return strings.reduce((result, str, i) => {
|
|
92
|
-
const val = values[i - 1];
|
|
93
|
-
const escaped = (val instanceof TrustedHTML) ? val.toString() : escapeHtml(val ?? '');
|
|
94
|
-
return result + escaped + str;
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Mark HTML as trusted (skip escaping in $.html template)
|
|
100
|
-
*/
|
|
101
|
-
export class TrustedHTML {
|
|
102
|
-
constructor(html) { this._html = html; }
|
|
103
|
-
toString() { return this._html; }
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export function trust(htmlStr) {
|
|
107
|
-
return new TrustedHTML(htmlStr);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Generate UUID v4
|
|
112
|
-
*/
|
|
113
|
-
export function uuid() {
|
|
114
|
-
if (crypto?.randomUUID) return crypto.randomUUID();
|
|
115
|
-
// Fallback using crypto.getRandomValues (wider support than randomUUID)
|
|
116
|
-
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
117
|
-
const buf = new Uint8Array(1);
|
|
118
|
-
crypto.getRandomValues(buf);
|
|
119
|
-
const r = buf[0] & 15;
|
|
120
|
-
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Kebab-case to camelCase
|
|
126
|
-
*/
|
|
127
|
-
export function camelCase(str) {
|
|
128
|
-
return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* CamelCase to kebab-case
|
|
133
|
-
*/
|
|
134
|
-
export function kebabCase(str) {
|
|
135
|
-
return str
|
|
136
|
-
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
|
|
137
|
-
.replace(/([a-z\d])([A-Z])/g, '$1-$2')
|
|
138
|
-
.toLowerCase();
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
// ---------------------------------------------------------------------------
|
|
143
|
-
// Object utilities
|
|
144
|
-
// ---------------------------------------------------------------------------
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Deep clone via structuredClone (handles circular refs, Dates, etc.).
|
|
148
|
-
* Falls back to a manual deep clone that preserves Date, RegExp, Map, Set,
|
|
149
|
-
* ArrayBuffer, TypedArrays, undefined values, and circular references.
|
|
150
|
-
*/
|
|
151
|
-
export function deepClone(obj) {
|
|
152
|
-
if (typeof structuredClone === 'function') return structuredClone(obj);
|
|
153
|
-
|
|
154
|
-
const seen = new Map();
|
|
155
|
-
function clone(val) {
|
|
156
|
-
if (val === null || typeof val !== 'object') return val;
|
|
157
|
-
if (seen.has(val)) return seen.get(val);
|
|
158
|
-
if (val instanceof Date) return new Date(val.getTime());
|
|
159
|
-
if (val instanceof RegExp) return new RegExp(val.source, val.flags);
|
|
160
|
-
if (val instanceof Map) {
|
|
161
|
-
const m = new Map();
|
|
162
|
-
seen.set(val, m);
|
|
163
|
-
val.forEach((v, k) => m.set(clone(k), clone(v)));
|
|
164
|
-
return m;
|
|
165
|
-
}
|
|
166
|
-
if (val instanceof Set) {
|
|
167
|
-
const s = new Set();
|
|
168
|
-
seen.set(val, s);
|
|
169
|
-
val.forEach(v => s.add(clone(v)));
|
|
170
|
-
return s;
|
|
171
|
-
}
|
|
172
|
-
if (ArrayBuffer.isView(val)) return new val.constructor(val.buffer.slice(0));
|
|
173
|
-
if (val instanceof ArrayBuffer) return val.slice(0);
|
|
174
|
-
if (Array.isArray(val)) {
|
|
175
|
-
const arr = [];
|
|
176
|
-
seen.set(val, arr);
|
|
177
|
-
for (let i = 0; i < val.length; i++) arr[i] = clone(val[i]);
|
|
178
|
-
return arr;
|
|
179
|
-
}
|
|
180
|
-
const result = Object.create(Object.getPrototypeOf(val));
|
|
181
|
-
seen.set(val, result);
|
|
182
|
-
for (const key of Object.keys(val)) result[key] = clone(val[key]);
|
|
183
|
-
return result;
|
|
184
|
-
}
|
|
185
|
-
return clone(obj);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Keys that must never be written through data-merge or path-set operations
|
|
189
|
-
const _UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Deep merge objects
|
|
193
|
-
*/
|
|
194
|
-
export function deepMerge(target, ...sources) {
|
|
195
|
-
const seen = new WeakSet();
|
|
196
|
-
function merge(tgt, src) {
|
|
197
|
-
if (seen.has(src)) return tgt;
|
|
198
|
-
seen.add(src);
|
|
199
|
-
for (const key of Object.keys(src)) {
|
|
200
|
-
if (_UNSAFE_KEYS.has(key)) continue;
|
|
201
|
-
if (src[key] && typeof src[key] === 'object' && !Array.isArray(src[key])) {
|
|
202
|
-
if (!tgt[key] || typeof tgt[key] !== 'object') tgt[key] = {};
|
|
203
|
-
merge(tgt[key], src[key]);
|
|
204
|
-
} else {
|
|
205
|
-
tgt[key] = src[key];
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
return tgt;
|
|
209
|
-
}
|
|
210
|
-
for (const source of sources) merge(target, source);
|
|
211
|
-
return target;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Simple object equality check
|
|
216
|
-
*/
|
|
217
|
-
export function isEqual(a, b, _seen) {
|
|
218
|
-
if (a === b) return true;
|
|
219
|
-
if (typeof a !== typeof b) return false;
|
|
220
|
-
if (typeof a !== 'object' || a === null || b === null) return false;
|
|
221
|
-
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
222
|
-
// Guard against circular references
|
|
223
|
-
if (!_seen) _seen = new Set();
|
|
224
|
-
if (_seen.has(a)) return true;
|
|
225
|
-
_seen.add(a);
|
|
226
|
-
const keysA = Object.keys(a);
|
|
227
|
-
const keysB = Object.keys(b);
|
|
228
|
-
if (keysA.length !== keysB.length) return false;
|
|
229
|
-
return keysA.every(k => isEqual(a[k], b[k], _seen));
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
// ---------------------------------------------------------------------------
|
|
234
|
-
// URL utilities
|
|
235
|
-
// ---------------------------------------------------------------------------
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Serialize object to URL query string
|
|
239
|
-
*/
|
|
240
|
-
export function param(obj) {
|
|
241
|
-
return new URLSearchParams(obj).toString();
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Parse URL query string to object
|
|
246
|
-
*/
|
|
247
|
-
export function parseQuery(str) {
|
|
248
|
-
return Object.fromEntries(new URLSearchParams(str));
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
// ---------------------------------------------------------------------------
|
|
253
|
-
// Storage helpers (localStorage wrapper with JSON support)
|
|
254
|
-
// ---------------------------------------------------------------------------
|
|
255
|
-
export const storage = {
|
|
256
|
-
get(key, fallback = null) {
|
|
257
|
-
try {
|
|
258
|
-
const raw = localStorage.getItem(key);
|
|
259
|
-
return raw !== null ? JSON.parse(raw) : fallback;
|
|
260
|
-
} catch {
|
|
261
|
-
return fallback;
|
|
262
|
-
}
|
|
263
|
-
},
|
|
264
|
-
|
|
265
|
-
set(key, value) {
|
|
266
|
-
localStorage.setItem(key, JSON.stringify(value));
|
|
267
|
-
},
|
|
268
|
-
|
|
269
|
-
remove(key) {
|
|
270
|
-
localStorage.removeItem(key);
|
|
271
|
-
},
|
|
272
|
-
|
|
273
|
-
clear() {
|
|
274
|
-
localStorage.clear();
|
|
275
|
-
},
|
|
276
|
-
};
|
|
277
|
-
|
|
278
|
-
export const session = {
|
|
279
|
-
get(key, fallback = null) {
|
|
280
|
-
try {
|
|
281
|
-
const raw = sessionStorage.getItem(key);
|
|
282
|
-
return raw !== null ? JSON.parse(raw) : fallback;
|
|
283
|
-
} catch {
|
|
284
|
-
return fallback;
|
|
285
|
-
}
|
|
286
|
-
},
|
|
287
|
-
|
|
288
|
-
set(key, value) {
|
|
289
|
-
sessionStorage.setItem(key, JSON.stringify(value));
|
|
290
|
-
},
|
|
291
|
-
|
|
292
|
-
remove(key) {
|
|
293
|
-
sessionStorage.removeItem(key);
|
|
294
|
-
},
|
|
295
|
-
|
|
296
|
-
clear() {
|
|
297
|
-
sessionStorage.clear();
|
|
298
|
-
},
|
|
299
|
-
};
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
// ---------------------------------------------------------------------------
|
|
303
|
-
// Event bus (pub/sub)
|
|
304
|
-
// ---------------------------------------------------------------------------
|
|
305
|
-
export class EventBus {
|
|
306
|
-
constructor() { this._handlers = new Map(); }
|
|
307
|
-
|
|
308
|
-
on(event, fn) {
|
|
309
|
-
if (!this._handlers.has(event)) this._handlers.set(event, new Set());
|
|
310
|
-
this._handlers.get(event).add(fn);
|
|
311
|
-
return () => this.off(event, fn);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
off(event, fn) {
|
|
315
|
-
this._handlers.get(event)?.delete(fn);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
emit(event, ...args) {
|
|
319
|
-
this._handlers.get(event)?.forEach(fn => fn(...args));
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
once(event, fn) {
|
|
323
|
-
const wrapper = (...args) => { fn(...args); this.off(event, wrapper); };
|
|
324
|
-
return this.on(event, wrapper);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
clear() { this._handlers.clear(); }
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
export const bus = new EventBus();
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
// ---------------------------------------------------------------------------
|
|
334
|
-
// Array utilities
|
|
335
|
-
// ---------------------------------------------------------------------------
|
|
336
|
-
|
|
337
|
-
export function range(startOrEnd, end, step) {
|
|
338
|
-
let s, e, st;
|
|
339
|
-
if (end === undefined) { s = 0; e = startOrEnd; st = 1; }
|
|
340
|
-
else { s = startOrEnd; e = end; st = step !== undefined ? step : 1; }
|
|
341
|
-
if (st === 0) return [];
|
|
342
|
-
const result = [];
|
|
343
|
-
if (st > 0) { for (let i = s; i < e; i += st) result.push(i); }
|
|
344
|
-
else { for (let i = s; i > e; i += st) result.push(i); }
|
|
345
|
-
return result;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
export function unique(arr, keyFn) {
|
|
349
|
-
if (!keyFn) return [...new Set(arr)];
|
|
350
|
-
const seen = new Set();
|
|
351
|
-
return arr.filter(item => {
|
|
352
|
-
const k = keyFn(item);
|
|
353
|
-
if (seen.has(k)) return false;
|
|
354
|
-
seen.add(k);
|
|
355
|
-
return true;
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
export function chunk(arr, size) {
|
|
360
|
-
const result = [];
|
|
361
|
-
for (let i = 0; i < arr.length; i += size) result.push(arr.slice(i, i + size));
|
|
362
|
-
return result;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
export function groupBy(arr, keyFn) {
|
|
366
|
-
const result = {};
|
|
367
|
-
for (const item of arr) {
|
|
368
|
-
const k = keyFn(item);
|
|
369
|
-
(result[k] ??= []).push(item);
|
|
370
|
-
}
|
|
371
|
-
return result;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
// ---------------------------------------------------------------------------
|
|
376
|
-
// Object utilities
|
|
377
|
-
// ---------------------------------------------------------------------------
|
|
378
|
-
|
|
379
|
-
export function pick(obj, keys) {
|
|
380
|
-
const result = {};
|
|
381
|
-
for (const k of keys) { if (k in obj) result[k] = obj[k]; }
|
|
382
|
-
return result;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
export function omit(obj, keys) {
|
|
386
|
-
const exclude = new Set(keys);
|
|
387
|
-
const result = {};
|
|
388
|
-
for (const k of Object.keys(obj)) { if (!exclude.has(k)) result[k] = obj[k]; }
|
|
389
|
-
return result;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
export function getPath(obj, path, fallback) {
|
|
393
|
-
const keys = path.split('.');
|
|
394
|
-
let cur = obj;
|
|
395
|
-
for (const k of keys) {
|
|
396
|
-
if (cur == null || typeof cur !== 'object') return fallback;
|
|
397
|
-
cur = cur[k];
|
|
398
|
-
}
|
|
399
|
-
return cur === undefined ? fallback : cur;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
export function setPath(obj, path, value) {
|
|
403
|
-
const keys = path.split('.');
|
|
404
|
-
let cur = obj;
|
|
405
|
-
for (let i = 0; i < keys.length - 1; i++) {
|
|
406
|
-
const k = keys[i];
|
|
407
|
-
if (_UNSAFE_KEYS.has(k)) return obj;
|
|
408
|
-
if (cur[k] == null || typeof cur[k] !== 'object') cur[k] = {};
|
|
409
|
-
cur = cur[k];
|
|
410
|
-
}
|
|
411
|
-
const lastKey = keys[keys.length - 1];
|
|
412
|
-
if (_UNSAFE_KEYS.has(lastKey)) return obj;
|
|
413
|
-
cur[lastKey] = value;
|
|
414
|
-
return obj;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
export function isEmpty(val) {
|
|
418
|
-
if (val == null) return true;
|
|
419
|
-
if (typeof val === 'string' || Array.isArray(val)) return val.length === 0;
|
|
420
|
-
if (val instanceof Map || val instanceof Set) return val.size === 0;
|
|
421
|
-
if (typeof val === 'object') return Object.keys(val).length === 0;
|
|
422
|
-
return false;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
// ---------------------------------------------------------------------------
|
|
427
|
-
// String utilities
|
|
428
|
-
// ---------------------------------------------------------------------------
|
|
429
|
-
|
|
430
|
-
export function capitalize(str) {
|
|
431
|
-
if (!str) return '';
|
|
432
|
-
return str[0].toUpperCase() + str.slice(1).toLowerCase();
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
export function truncate(str, maxLen, suffix = '…') {
|
|
436
|
-
if (str.length <= maxLen) return str;
|
|
437
|
-
const end = Math.max(0, maxLen - suffix.length);
|
|
438
|
-
return str.slice(0, end) + suffix;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
// ---------------------------------------------------------------------------
|
|
443
|
-
// Number utilities
|
|
444
|
-
// ---------------------------------------------------------------------------
|
|
445
|
-
|
|
446
|
-
export function clamp(val, min, max) {
|
|
447
|
-
return val < min ? min : val > max ? max : val;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
// ---------------------------------------------------------------------------
|
|
452
|
-
// Function utilities
|
|
453
|
-
// ---------------------------------------------------------------------------
|
|
454
|
-
|
|
455
|
-
export function memoize(fn, keyFnOrOpts) {
|
|
456
|
-
let keyFn, maxSize = 0;
|
|
457
|
-
if (typeof keyFnOrOpts === 'function') keyFn = keyFnOrOpts;
|
|
458
|
-
else if (keyFnOrOpts && typeof keyFnOrOpts === 'object') maxSize = keyFnOrOpts.maxSize || 0;
|
|
459
|
-
|
|
460
|
-
const cache = new Map();
|
|
461
|
-
|
|
462
|
-
const memoized = (...args) => {
|
|
463
|
-
const key = keyFn ? keyFn(...args) : args[0];
|
|
464
|
-
if (cache.has(key)) {
|
|
465
|
-
// LRU: promote to newest by re-inserting
|
|
466
|
-
const value = cache.get(key);
|
|
467
|
-
cache.delete(key);
|
|
468
|
-
cache.set(key, value);
|
|
469
|
-
return value;
|
|
470
|
-
}
|
|
471
|
-
const result = fn(...args);
|
|
472
|
-
cache.set(key, result);
|
|
473
|
-
// LRU eviction: drop the least-recently-used entry
|
|
474
|
-
if (maxSize > 0 && cache.size > maxSize) {
|
|
475
|
-
cache.delete(cache.keys().next().value);
|
|
476
|
-
}
|
|
477
|
-
return result;
|
|
478
|
-
};
|
|
479
|
-
|
|
480
|
-
memoized.clear = () => cache.clear();
|
|
481
|
-
return memoized;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
// ---------------------------------------------------------------------------
|
|
486
|
-
// Async utilities
|
|
487
|
-
// ---------------------------------------------------------------------------
|
|
488
|
-
|
|
489
|
-
export function retry(fn, opts = {}) {
|
|
490
|
-
const { attempts = 3, delay = 1000, backoff = 1 } = opts;
|
|
491
|
-
return new Promise((resolve, reject) => {
|
|
492
|
-
let attempt = 0, currentDelay = delay;
|
|
493
|
-
const tryOnce = () => {
|
|
494
|
-
attempt++;
|
|
495
|
-
fn(attempt).then(resolve, (err) => {
|
|
496
|
-
if (attempt >= attempts) return reject(err);
|
|
497
|
-
const d = currentDelay;
|
|
498
|
-
currentDelay *= backoff;
|
|
499
|
-
setTimeout(tryOnce, d);
|
|
500
|
-
});
|
|
501
|
-
};
|
|
502
|
-
tryOnce();
|
|
503
|
-
});
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
export function timeout(promise, ms, message) {
|
|
507
|
-
let timer;
|
|
508
|
-
const race = Promise.race([
|
|
509
|
-
promise,
|
|
510
|
-
new Promise((_, reject) => {
|
|
511
|
-
timer = setTimeout(() => reject(new Error(message || `Timed out after ${ms}ms`)), ms);
|
|
512
|
-
})
|
|
513
|
-
]);
|
|
514
|
-
return race.finally(() => clearTimeout(timer));
|
|
515
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* zQuery Utils - Common utility functions
|
|
3
|
+
*
|
|
4
|
+
* Quality-of-life helpers that every frontend project needs.
|
|
5
|
+
* Attached to $ namespace for convenience.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Function utilities
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Debounce - delays execution until after `ms` of inactivity
|
|
14
|
+
*/
|
|
15
|
+
export function debounce(fn, ms = 250) {
|
|
16
|
+
let timer;
|
|
17
|
+
const debounced = (...args) => {
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
timer = setTimeout(() => fn(...args), ms);
|
|
20
|
+
};
|
|
21
|
+
debounced.cancel = () => clearTimeout(timer);
|
|
22
|
+
return debounced;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Throttle - limits execution to once per `ms`
|
|
27
|
+
*/
|
|
28
|
+
export function throttle(fn, ms = 250) {
|
|
29
|
+
let last = 0;
|
|
30
|
+
let timer;
|
|
31
|
+
return (...args) => {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
const remaining = ms - (now - last);
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
if (remaining <= 0) {
|
|
36
|
+
last = now;
|
|
37
|
+
fn(...args);
|
|
38
|
+
} else {
|
|
39
|
+
timer = setTimeout(() => { last = Date.now(); fn(...args); }, remaining);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Pipe - compose functions left-to-right
|
|
46
|
+
*/
|
|
47
|
+
export function pipe(...fns) {
|
|
48
|
+
return (input) => fns.reduce((val, fn) => fn(val), input);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Once - function that only runs once
|
|
53
|
+
*/
|
|
54
|
+
export function once(fn) {
|
|
55
|
+
let called = false, result;
|
|
56
|
+
return (...args) => {
|
|
57
|
+
if (!called) { called = true; result = fn(...args); }
|
|
58
|
+
return result;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Sleep - promise-based delay
|
|
64
|
+
*/
|
|
65
|
+
export function sleep(ms) {
|
|
66
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// String utilities
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Escape HTML entities
|
|
76
|
+
*/
|
|
77
|
+
export function escapeHtml(str) {
|
|
78
|
+
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
79
|
+
return String(str).replace(/[&<>"']/g, c => map[c]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function stripHtml(str) {
|
|
83
|
+
return String(str).replace(/<[^>]*>/g, '');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Template tag for auto-escaping interpolated values
|
|
88
|
+
* Usage: $.html`<div>${userInput}</div>`
|
|
89
|
+
*/
|
|
90
|
+
export function html(strings, ...values) {
|
|
91
|
+
return strings.reduce((result, str, i) => {
|
|
92
|
+
const val = values[i - 1];
|
|
93
|
+
const escaped = (val instanceof TrustedHTML) ? val.toString() : escapeHtml(val ?? '');
|
|
94
|
+
return result + escaped + str;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Mark HTML as trusted (skip escaping in $.html template)
|
|
100
|
+
*/
|
|
101
|
+
export class TrustedHTML {
|
|
102
|
+
constructor(html) { this._html = html; }
|
|
103
|
+
toString() { return this._html; }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function trust(htmlStr) {
|
|
107
|
+
return new TrustedHTML(htmlStr);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Generate UUID v4
|
|
112
|
+
*/
|
|
113
|
+
export function uuid() {
|
|
114
|
+
if (crypto?.randomUUID) return crypto.randomUUID();
|
|
115
|
+
// Fallback using crypto.getRandomValues (wider support than randomUUID)
|
|
116
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
117
|
+
const buf = new Uint8Array(1);
|
|
118
|
+
crypto.getRandomValues(buf);
|
|
119
|
+
const r = buf[0] & 15;
|
|
120
|
+
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Kebab-case to camelCase
|
|
126
|
+
*/
|
|
127
|
+
export function camelCase(str) {
|
|
128
|
+
return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* CamelCase to kebab-case
|
|
133
|
+
*/
|
|
134
|
+
export function kebabCase(str) {
|
|
135
|
+
return str
|
|
136
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
|
|
137
|
+
.replace(/([a-z\d])([A-Z])/g, '$1-$2')
|
|
138
|
+
.toLowerCase();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Object utilities
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Deep clone via structuredClone (handles circular refs, Dates, etc.).
|
|
148
|
+
* Falls back to a manual deep clone that preserves Date, RegExp, Map, Set,
|
|
149
|
+
* ArrayBuffer, TypedArrays, undefined values, and circular references.
|
|
150
|
+
*/
|
|
151
|
+
export function deepClone(obj) {
|
|
152
|
+
if (typeof structuredClone === 'function') return structuredClone(obj);
|
|
153
|
+
|
|
154
|
+
const seen = new Map();
|
|
155
|
+
function clone(val) {
|
|
156
|
+
if (val === null || typeof val !== 'object') return val;
|
|
157
|
+
if (seen.has(val)) return seen.get(val);
|
|
158
|
+
if (val instanceof Date) return new Date(val.getTime());
|
|
159
|
+
if (val instanceof RegExp) return new RegExp(val.source, val.flags);
|
|
160
|
+
if (val instanceof Map) {
|
|
161
|
+
const m = new Map();
|
|
162
|
+
seen.set(val, m);
|
|
163
|
+
val.forEach((v, k) => m.set(clone(k), clone(v)));
|
|
164
|
+
return m;
|
|
165
|
+
}
|
|
166
|
+
if (val instanceof Set) {
|
|
167
|
+
const s = new Set();
|
|
168
|
+
seen.set(val, s);
|
|
169
|
+
val.forEach(v => s.add(clone(v)));
|
|
170
|
+
return s;
|
|
171
|
+
}
|
|
172
|
+
if (ArrayBuffer.isView(val)) return new val.constructor(val.buffer.slice(0));
|
|
173
|
+
if (val instanceof ArrayBuffer) return val.slice(0);
|
|
174
|
+
if (Array.isArray(val)) {
|
|
175
|
+
const arr = [];
|
|
176
|
+
seen.set(val, arr);
|
|
177
|
+
for (let i = 0; i < val.length; i++) arr[i] = clone(val[i]);
|
|
178
|
+
return arr;
|
|
179
|
+
}
|
|
180
|
+
const result = Object.create(Object.getPrototypeOf(val));
|
|
181
|
+
seen.set(val, result);
|
|
182
|
+
for (const key of Object.keys(val)) result[key] = clone(val[key]);
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
return clone(obj);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Keys that must never be written through data-merge or path-set operations
|
|
189
|
+
const _UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Deep merge objects
|
|
193
|
+
*/
|
|
194
|
+
export function deepMerge(target, ...sources) {
|
|
195
|
+
const seen = new WeakSet();
|
|
196
|
+
function merge(tgt, src) {
|
|
197
|
+
if (seen.has(src)) return tgt;
|
|
198
|
+
seen.add(src);
|
|
199
|
+
for (const key of Object.keys(src)) {
|
|
200
|
+
if (_UNSAFE_KEYS.has(key)) continue;
|
|
201
|
+
if (src[key] && typeof src[key] === 'object' && !Array.isArray(src[key])) {
|
|
202
|
+
if (!tgt[key] || typeof tgt[key] !== 'object') tgt[key] = {};
|
|
203
|
+
merge(tgt[key], src[key]);
|
|
204
|
+
} else {
|
|
205
|
+
tgt[key] = src[key];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return tgt;
|
|
209
|
+
}
|
|
210
|
+
for (const source of sources) merge(target, source);
|
|
211
|
+
return target;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Simple object equality check
|
|
216
|
+
*/
|
|
217
|
+
export function isEqual(a, b, _seen) {
|
|
218
|
+
if (a === b) return true;
|
|
219
|
+
if (typeof a !== typeof b) return false;
|
|
220
|
+
if (typeof a !== 'object' || a === null || b === null) return false;
|
|
221
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
222
|
+
// Guard against circular references
|
|
223
|
+
if (!_seen) _seen = new Set();
|
|
224
|
+
if (_seen.has(a)) return true;
|
|
225
|
+
_seen.add(a);
|
|
226
|
+
const keysA = Object.keys(a);
|
|
227
|
+
const keysB = Object.keys(b);
|
|
228
|
+
if (keysA.length !== keysB.length) return false;
|
|
229
|
+
return keysA.every(k => isEqual(a[k], b[k], _seen));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// URL utilities
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Serialize object to URL query string
|
|
239
|
+
*/
|
|
240
|
+
export function param(obj) {
|
|
241
|
+
return new URLSearchParams(obj).toString();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Parse URL query string to object
|
|
246
|
+
*/
|
|
247
|
+
export function parseQuery(str) {
|
|
248
|
+
return Object.fromEntries(new URLSearchParams(str));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Storage helpers (localStorage wrapper with JSON support)
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
export const storage = {
|
|
256
|
+
get(key, fallback = null) {
|
|
257
|
+
try {
|
|
258
|
+
const raw = localStorage.getItem(key);
|
|
259
|
+
return raw !== null ? JSON.parse(raw) : fallback;
|
|
260
|
+
} catch {
|
|
261
|
+
return fallback;
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
set(key, value) {
|
|
266
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
remove(key) {
|
|
270
|
+
localStorage.removeItem(key);
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
clear() {
|
|
274
|
+
localStorage.clear();
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
export const session = {
|
|
279
|
+
get(key, fallback = null) {
|
|
280
|
+
try {
|
|
281
|
+
const raw = sessionStorage.getItem(key);
|
|
282
|
+
return raw !== null ? JSON.parse(raw) : fallback;
|
|
283
|
+
} catch {
|
|
284
|
+
return fallback;
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
set(key, value) {
|
|
289
|
+
sessionStorage.setItem(key, JSON.stringify(value));
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
remove(key) {
|
|
293
|
+
sessionStorage.removeItem(key);
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
clear() {
|
|
297
|
+
sessionStorage.clear();
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// Event bus (pub/sub)
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
export class EventBus {
|
|
306
|
+
constructor() { this._handlers = new Map(); }
|
|
307
|
+
|
|
308
|
+
on(event, fn) {
|
|
309
|
+
if (!this._handlers.has(event)) this._handlers.set(event, new Set());
|
|
310
|
+
this._handlers.get(event).add(fn);
|
|
311
|
+
return () => this.off(event, fn);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
off(event, fn) {
|
|
315
|
+
this._handlers.get(event)?.delete(fn);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
emit(event, ...args) {
|
|
319
|
+
this._handlers.get(event)?.forEach(fn => fn(...args));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
once(event, fn) {
|
|
323
|
+
const wrapper = (...args) => { fn(...args); this.off(event, wrapper); };
|
|
324
|
+
return this.on(event, wrapper);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
clear() { this._handlers.clear(); }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export const bus = new EventBus();
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// Array utilities
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
export function range(startOrEnd, end, step) {
|
|
338
|
+
let s, e, st;
|
|
339
|
+
if (end === undefined) { s = 0; e = startOrEnd; st = 1; }
|
|
340
|
+
else { s = startOrEnd; e = end; st = step !== undefined ? step : 1; }
|
|
341
|
+
if (st === 0) return [];
|
|
342
|
+
const result = [];
|
|
343
|
+
if (st > 0) { for (let i = s; i < e; i += st) result.push(i); }
|
|
344
|
+
else { for (let i = s; i > e; i += st) result.push(i); }
|
|
345
|
+
return result;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function unique(arr, keyFn) {
|
|
349
|
+
if (!keyFn) return [...new Set(arr)];
|
|
350
|
+
const seen = new Set();
|
|
351
|
+
return arr.filter(item => {
|
|
352
|
+
const k = keyFn(item);
|
|
353
|
+
if (seen.has(k)) return false;
|
|
354
|
+
seen.add(k);
|
|
355
|
+
return true;
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function chunk(arr, size) {
|
|
360
|
+
const result = [];
|
|
361
|
+
for (let i = 0; i < arr.length; i += size) result.push(arr.slice(i, i + size));
|
|
362
|
+
return result;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function groupBy(arr, keyFn) {
|
|
366
|
+
const result = {};
|
|
367
|
+
for (const item of arr) {
|
|
368
|
+
const k = keyFn(item);
|
|
369
|
+
(result[k] ??= []).push(item);
|
|
370
|
+
}
|
|
371
|
+
return result;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
// Object utilities
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
export function pick(obj, keys) {
|
|
380
|
+
const result = {};
|
|
381
|
+
for (const k of keys) { if (k in obj) result[k] = obj[k]; }
|
|
382
|
+
return result;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function omit(obj, keys) {
|
|
386
|
+
const exclude = new Set(keys);
|
|
387
|
+
const result = {};
|
|
388
|
+
for (const k of Object.keys(obj)) { if (!exclude.has(k)) result[k] = obj[k]; }
|
|
389
|
+
return result;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function getPath(obj, path, fallback) {
|
|
393
|
+
const keys = path.split('.');
|
|
394
|
+
let cur = obj;
|
|
395
|
+
for (const k of keys) {
|
|
396
|
+
if (cur == null || typeof cur !== 'object') return fallback;
|
|
397
|
+
cur = cur[k];
|
|
398
|
+
}
|
|
399
|
+
return cur === undefined ? fallback : cur;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function setPath(obj, path, value) {
|
|
403
|
+
const keys = path.split('.');
|
|
404
|
+
let cur = obj;
|
|
405
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
406
|
+
const k = keys[i];
|
|
407
|
+
if (_UNSAFE_KEYS.has(k)) return obj;
|
|
408
|
+
if (cur[k] == null || typeof cur[k] !== 'object') cur[k] = {};
|
|
409
|
+
cur = cur[k];
|
|
410
|
+
}
|
|
411
|
+
const lastKey = keys[keys.length - 1];
|
|
412
|
+
if (_UNSAFE_KEYS.has(lastKey)) return obj;
|
|
413
|
+
cur[lastKey] = value;
|
|
414
|
+
return obj;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function isEmpty(val) {
|
|
418
|
+
if (val == null) return true;
|
|
419
|
+
if (typeof val === 'string' || Array.isArray(val)) return val.length === 0;
|
|
420
|
+
if (val instanceof Map || val instanceof Set) return val.size === 0;
|
|
421
|
+
if (typeof val === 'object') return Object.keys(val).length === 0;
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
// String utilities
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
export function capitalize(str) {
|
|
431
|
+
if (!str) return '';
|
|
432
|
+
return str[0].toUpperCase() + str.slice(1).toLowerCase();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function truncate(str, maxLen, suffix = '…') {
|
|
436
|
+
if (str.length <= maxLen) return str;
|
|
437
|
+
const end = Math.max(0, maxLen - suffix.length);
|
|
438
|
+
return str.slice(0, end) + suffix;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
// Number utilities
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
export function clamp(val, min, max) {
|
|
447
|
+
return val < min ? min : val > max ? max : val;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
// Function utilities
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
export function memoize(fn, keyFnOrOpts) {
|
|
456
|
+
let keyFn, maxSize = 0;
|
|
457
|
+
if (typeof keyFnOrOpts === 'function') keyFn = keyFnOrOpts;
|
|
458
|
+
else if (keyFnOrOpts && typeof keyFnOrOpts === 'object') maxSize = keyFnOrOpts.maxSize || 0;
|
|
459
|
+
|
|
460
|
+
const cache = new Map();
|
|
461
|
+
|
|
462
|
+
const memoized = (...args) => {
|
|
463
|
+
const key = keyFn ? keyFn(...args) : args[0];
|
|
464
|
+
if (cache.has(key)) {
|
|
465
|
+
// LRU: promote to newest by re-inserting
|
|
466
|
+
const value = cache.get(key);
|
|
467
|
+
cache.delete(key);
|
|
468
|
+
cache.set(key, value);
|
|
469
|
+
return value;
|
|
470
|
+
}
|
|
471
|
+
const result = fn(...args);
|
|
472
|
+
cache.set(key, result);
|
|
473
|
+
// LRU eviction: drop the least-recently-used entry
|
|
474
|
+
if (maxSize > 0 && cache.size > maxSize) {
|
|
475
|
+
cache.delete(cache.keys().next().value);
|
|
476
|
+
}
|
|
477
|
+
return result;
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
memoized.clear = () => cache.clear();
|
|
481
|
+
return memoized;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
// Async utilities
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
export function retry(fn, opts = {}) {
|
|
490
|
+
const { attempts = 3, delay = 1000, backoff = 1 } = opts;
|
|
491
|
+
return new Promise((resolve, reject) => {
|
|
492
|
+
let attempt = 0, currentDelay = delay;
|
|
493
|
+
const tryOnce = () => {
|
|
494
|
+
attempt++;
|
|
495
|
+
fn(attempt).then(resolve, (err) => {
|
|
496
|
+
if (attempt >= attempts) return reject(err);
|
|
497
|
+
const d = currentDelay;
|
|
498
|
+
currentDelay *= backoff;
|
|
499
|
+
setTimeout(tryOnce, d);
|
|
500
|
+
});
|
|
501
|
+
};
|
|
502
|
+
tryOnce();
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export function timeout(promise, ms, message) {
|
|
507
|
+
let timer;
|
|
508
|
+
const race = Promise.race([
|
|
509
|
+
promise,
|
|
510
|
+
new Promise((_, reject) => {
|
|
511
|
+
timer = setTimeout(() => reject(new Error(message || `Timed out after ${ms}ms`)), ms);
|
|
512
|
+
})
|
|
513
|
+
]);
|
|
514
|
+
return race.finally(() => clearTimeout(timer));
|
|
515
|
+
}
|