zero-query 0.1.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/src/core.js ADDED
@@ -0,0 +1,508 @@
1
+ /**
2
+ * zQuery Core — Selector engine & chainable DOM collection
3
+ *
4
+ * Extends the quick-ref pattern (Id, Class, Classes, Children)
5
+ * into a full jQuery-like chainable wrapper with modern APIs.
6
+ */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // ZQueryCollection — wraps an array of elements with chainable methods
10
+ // ---------------------------------------------------------------------------
11
+ export class ZQueryCollection {
12
+ constructor(elements) {
13
+ this.elements = Array.isArray(elements) ? elements : [elements];
14
+ this.length = this.elements.length;
15
+ this.elements.forEach((el, i) => { this[i] = el; });
16
+ }
17
+
18
+ // --- Iteration -----------------------------------------------------------
19
+
20
+ each(fn) {
21
+ this.elements.forEach((el, i) => fn.call(el, i, el));
22
+ return this;
23
+ }
24
+
25
+ map(fn) {
26
+ return this.elements.map((el, i) => fn.call(el, i, el));
27
+ }
28
+
29
+ first() { return this.elements[0] || null; }
30
+ last() { return this.elements[this.length - 1] || null; }
31
+ eq(i) { return new ZQueryCollection(this.elements[i] ? [this.elements[i]] : []); }
32
+ toArray(){ return [...this.elements]; }
33
+
34
+ [Symbol.iterator]() { return this.elements[Symbol.iterator](); }
35
+
36
+ // --- Traversal -----------------------------------------------------------
37
+
38
+ find(selector) {
39
+ const found = [];
40
+ this.elements.forEach(el => found.push(...el.querySelectorAll(selector)));
41
+ return new ZQueryCollection(found);
42
+ }
43
+
44
+ parent() {
45
+ const parents = [...new Set(this.elements.map(el => el.parentElement).filter(Boolean))];
46
+ return new ZQueryCollection(parents);
47
+ }
48
+
49
+ closest(selector) {
50
+ return new ZQueryCollection(
51
+ this.elements.map(el => el.closest(selector)).filter(Boolean)
52
+ );
53
+ }
54
+
55
+ children(selector) {
56
+ const kids = [];
57
+ this.elements.forEach(el => {
58
+ kids.push(...(selector
59
+ ? el.querySelectorAll(`:scope > ${selector}`)
60
+ : el.children));
61
+ });
62
+ return new ZQueryCollection([...kids]);
63
+ }
64
+
65
+ siblings() {
66
+ const sibs = [];
67
+ this.elements.forEach(el => {
68
+ sibs.push(...[...el.parentElement.children].filter(c => c !== el));
69
+ });
70
+ return new ZQueryCollection(sibs);
71
+ }
72
+
73
+ next() { return new ZQueryCollection(this.elements.map(el => el.nextElementSibling).filter(Boolean)); }
74
+ prev() { return new ZQueryCollection(this.elements.map(el => el.previousElementSibling).filter(Boolean)); }
75
+
76
+ filter(selector) {
77
+ if (typeof selector === 'function') {
78
+ return new ZQueryCollection(this.elements.filter(selector));
79
+ }
80
+ return new ZQueryCollection(this.elements.filter(el => el.matches(selector)));
81
+ }
82
+
83
+ not(selector) {
84
+ if (typeof selector === 'function') {
85
+ return new ZQueryCollection(this.elements.filter((el, i) => !selector.call(el, i, el)));
86
+ }
87
+ return new ZQueryCollection(this.elements.filter(el => !el.matches(selector)));
88
+ }
89
+
90
+ has(selector) {
91
+ return new ZQueryCollection(this.elements.filter(el => el.querySelector(selector)));
92
+ }
93
+
94
+ // --- Classes -------------------------------------------------------------
95
+
96
+ addClass(...names) {
97
+ const classes = names.flatMap(n => n.split(/\s+/));
98
+ return this.each((_, el) => el.classList.add(...classes));
99
+ }
100
+
101
+ removeClass(...names) {
102
+ const classes = names.flatMap(n => n.split(/\s+/));
103
+ return this.each((_, el) => el.classList.remove(...classes));
104
+ }
105
+
106
+ toggleClass(name, force) {
107
+ return this.each((_, el) => el.classList.toggle(name, force));
108
+ }
109
+
110
+ hasClass(name) {
111
+ return this.first()?.classList.contains(name) || false;
112
+ }
113
+
114
+ // --- Attributes ----------------------------------------------------------
115
+
116
+ attr(name, value) {
117
+ if (value === undefined) return this.first()?.getAttribute(name);
118
+ return this.each((_, el) => el.setAttribute(name, value));
119
+ }
120
+
121
+ removeAttr(name) {
122
+ return this.each((_, el) => el.removeAttribute(name));
123
+ }
124
+
125
+ prop(name, value) {
126
+ if (value === undefined) return this.first()?.[name];
127
+ return this.each((_, el) => { el[name] = value; });
128
+ }
129
+
130
+ data(key, value) {
131
+ if (value === undefined) {
132
+ if (key === undefined) return this.first()?.dataset;
133
+ const raw = this.first()?.dataset[key];
134
+ try { return JSON.parse(raw); } catch { return raw; }
135
+ }
136
+ return this.each((_, el) => { el.dataset[key] = typeof value === 'object' ? JSON.stringify(value) : value; });
137
+ }
138
+
139
+ // --- CSS / Dimensions ----------------------------------------------------
140
+
141
+ css(props) {
142
+ if (typeof props === 'string') {
143
+ return getComputedStyle(this.first())[props];
144
+ }
145
+ return this.each((_, el) => Object.assign(el.style, props));
146
+ }
147
+
148
+ width() { return this.first()?.getBoundingClientRect().width; }
149
+ height() { return this.first()?.getBoundingClientRect().height; }
150
+
151
+ offset() {
152
+ const r = this.first()?.getBoundingClientRect();
153
+ return r ? { top: r.top + window.scrollY, left: r.left + window.scrollX, width: r.width, height: r.height } : null;
154
+ }
155
+
156
+ position() {
157
+ const el = this.first();
158
+ return el ? { top: el.offsetTop, left: el.offsetLeft } : null;
159
+ }
160
+
161
+ // --- Content -------------------------------------------------------------
162
+
163
+ html(content) {
164
+ if (content === undefined) return this.first()?.innerHTML;
165
+ return this.each((_, el) => { el.innerHTML = content; });
166
+ }
167
+
168
+ text(content) {
169
+ if (content === undefined) return this.first()?.textContent;
170
+ return this.each((_, el) => { el.textContent = content; });
171
+ }
172
+
173
+ val(value) {
174
+ if (value === undefined) return this.first()?.value;
175
+ return this.each((_, el) => { el.value = value; });
176
+ }
177
+
178
+ // --- DOM Manipulation ----------------------------------------------------
179
+
180
+ append(content) {
181
+ return this.each((_, el) => {
182
+ if (typeof content === 'string') el.insertAdjacentHTML('beforeend', content);
183
+ else if (content instanceof ZQueryCollection) content.each((__, c) => el.appendChild(c));
184
+ else if (content instanceof Node) el.appendChild(content);
185
+ });
186
+ }
187
+
188
+ prepend(content) {
189
+ return this.each((_, el) => {
190
+ if (typeof content === 'string') el.insertAdjacentHTML('afterbegin', content);
191
+ else if (content instanceof Node) el.insertBefore(content, el.firstChild);
192
+ });
193
+ }
194
+
195
+ after(content) {
196
+ return this.each((_, el) => {
197
+ if (typeof content === 'string') el.insertAdjacentHTML('afterend', content);
198
+ else if (content instanceof Node) el.parentNode.insertBefore(content, el.nextSibling);
199
+ });
200
+ }
201
+
202
+ before(content) {
203
+ return this.each((_, el) => {
204
+ if (typeof content === 'string') el.insertAdjacentHTML('beforebegin', content);
205
+ else if (content instanceof Node) el.parentNode.insertBefore(content, el);
206
+ });
207
+ }
208
+
209
+ wrap(wrapper) {
210
+ return this.each((_, el) => {
211
+ const w = typeof wrapper === 'string' ? createFragment(wrapper).firstElementChild : wrapper.cloneNode(true);
212
+ el.parentNode.insertBefore(w, el);
213
+ w.appendChild(el);
214
+ });
215
+ }
216
+
217
+ remove() {
218
+ return this.each((_, el) => el.remove());
219
+ }
220
+
221
+ empty() {
222
+ return this.each((_, el) => { el.innerHTML = ''; });
223
+ }
224
+
225
+ clone(deep = true) {
226
+ return new ZQueryCollection(this.elements.map(el => el.cloneNode(deep)));
227
+ }
228
+
229
+ replaceWith(content) {
230
+ return this.each((_, el) => {
231
+ if (typeof content === 'string') {
232
+ el.insertAdjacentHTML('afterend', content);
233
+ el.remove();
234
+ } else if (content instanceof Node) {
235
+ el.parentNode.replaceChild(content, el);
236
+ }
237
+ });
238
+ }
239
+
240
+ // --- Visibility ----------------------------------------------------------
241
+
242
+ show(display = '') {
243
+ return this.each((_, el) => { el.style.display = display; });
244
+ }
245
+
246
+ hide() {
247
+ return this.each((_, el) => { el.style.display = 'none'; });
248
+ }
249
+
250
+ toggle(display = '') {
251
+ return this.each((_, el) => {
252
+ el.style.display = (el.style.display === 'none' || getComputedStyle(el).display === 'none') ? display : 'none';
253
+ });
254
+ }
255
+
256
+ // --- Events --------------------------------------------------------------
257
+
258
+ on(event, selectorOrHandler, handler) {
259
+ // Support multiple events: "click mouseenter"
260
+ const events = event.split(/\s+/);
261
+ return this.each((_, el) => {
262
+ events.forEach(evt => {
263
+ if (typeof selectorOrHandler === 'function') {
264
+ el.addEventListener(evt, selectorOrHandler);
265
+ } else {
266
+ // Delegated event
267
+ el.addEventListener(evt, (e) => {
268
+ const target = e.target.closest(selectorOrHandler);
269
+ if (target && el.contains(target)) handler.call(target, e);
270
+ });
271
+ }
272
+ });
273
+ });
274
+ }
275
+
276
+ off(event, handler) {
277
+ const events = event.split(/\s+/);
278
+ return this.each((_, el) => {
279
+ events.forEach(evt => el.removeEventListener(evt, handler));
280
+ });
281
+ }
282
+
283
+ one(event, handler) {
284
+ return this.each((_, el) => {
285
+ el.addEventListener(event, handler, { once: true });
286
+ });
287
+ }
288
+
289
+ trigger(event, detail) {
290
+ return this.each((_, el) => {
291
+ el.dispatchEvent(new CustomEvent(event, { detail, bubbles: true, cancelable: true }));
292
+ });
293
+ }
294
+
295
+ // Convenience event shorthands
296
+ click(fn) { return fn ? this.on('click', fn) : this.trigger('click'); }
297
+ submit(fn) { return fn ? this.on('submit', fn) : this.trigger('submit'); }
298
+ focus() { this.first()?.focus(); return this; }
299
+ blur() { this.first()?.blur(); return this; }
300
+
301
+ // --- Animation -----------------------------------------------------------
302
+
303
+ animate(props, duration = 300, easing = 'ease') {
304
+ return new Promise(resolve => {
305
+ const count = { done: 0 };
306
+ this.each((_, el) => {
307
+ el.style.transition = `all ${duration}ms ${easing}`;
308
+ requestAnimationFrame(() => {
309
+ Object.assign(el.style, props);
310
+ const onEnd = () => {
311
+ el.removeEventListener('transitionend', onEnd);
312
+ el.style.transition = '';
313
+ if (++count.done >= this.length) resolve(this);
314
+ };
315
+ el.addEventListener('transitionend', onEnd);
316
+ });
317
+ });
318
+ // Fallback in case transitionend doesn't fire
319
+ setTimeout(() => resolve(this), duration + 50);
320
+ });
321
+ }
322
+
323
+ fadeIn(duration = 300) {
324
+ return this.css({ opacity: '0', display: '' }).animate({ opacity: '1' }, duration);
325
+ }
326
+
327
+ fadeOut(duration = 300) {
328
+ return this.animate({ opacity: '0' }, duration).then(col => col.hide());
329
+ }
330
+
331
+ slideToggle(duration = 300) {
332
+ return this.each((_, el) => {
333
+ if (el.style.display === 'none' || getComputedStyle(el).display === 'none') {
334
+ el.style.display = '';
335
+ el.style.overflow = 'hidden';
336
+ const h = el.scrollHeight + 'px';
337
+ el.style.maxHeight = '0';
338
+ el.style.transition = `max-height ${duration}ms ease`;
339
+ requestAnimationFrame(() => { el.style.maxHeight = h; });
340
+ setTimeout(() => { el.style.maxHeight = ''; el.style.overflow = ''; el.style.transition = ''; }, duration);
341
+ } else {
342
+ el.style.overflow = 'hidden';
343
+ el.style.maxHeight = el.scrollHeight + 'px';
344
+ el.style.transition = `max-height ${duration}ms ease`;
345
+ requestAnimationFrame(() => { el.style.maxHeight = '0'; });
346
+ setTimeout(() => { el.style.display = 'none'; el.style.maxHeight = ''; el.style.overflow = ''; el.style.transition = ''; }, duration);
347
+ }
348
+ });
349
+ }
350
+
351
+ // --- Form helpers --------------------------------------------------------
352
+
353
+ serialize() {
354
+ const form = this.first();
355
+ if (!form || form.tagName !== 'FORM') return '';
356
+ return new URLSearchParams(new FormData(form)).toString();
357
+ }
358
+
359
+ serializeObject() {
360
+ const form = this.first();
361
+ if (!form || form.tagName !== 'FORM') return {};
362
+ const obj = {};
363
+ new FormData(form).forEach((v, k) => {
364
+ if (obj[k] !== undefined) {
365
+ if (!Array.isArray(obj[k])) obj[k] = [obj[k]];
366
+ obj[k].push(v);
367
+ } else {
368
+ obj[k] = v;
369
+ }
370
+ });
371
+ return obj;
372
+ }
373
+ }
374
+
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Helper — create document fragment from HTML string
378
+ // ---------------------------------------------------------------------------
379
+ function createFragment(html) {
380
+ const tpl = document.createElement('template');
381
+ tpl.innerHTML = html.trim();
382
+ return tpl.content;
383
+ }
384
+
385
+
386
+ // ---------------------------------------------------------------------------
387
+ // $() — main selector / creator function (returns single element for CSS selectors)
388
+ // ---------------------------------------------------------------------------
389
+ export function query(selector, context) {
390
+ // null / undefined
391
+ if (!selector) return null;
392
+
393
+ // Already a collection — return first element
394
+ if (selector instanceof ZQueryCollection) return selector.first();
395
+
396
+ // DOM element or Window — return as-is
397
+ if (selector instanceof Node || selector === window) {
398
+ return selector;
399
+ }
400
+
401
+ // NodeList / HTMLCollection / Array — return first element
402
+ if (selector instanceof NodeList || selector instanceof HTMLCollection || Array.isArray(selector)) {
403
+ const arr = Array.from(selector);
404
+ return arr[0] || null;
405
+ }
406
+
407
+ // HTML string → create elements, return first
408
+ if (typeof selector === 'string' && selector.trim().startsWith('<')) {
409
+ const fragment = createFragment(selector);
410
+ const els = [...fragment.childNodes].filter(n => n.nodeType === 1);
411
+ return els[0] || null;
412
+ }
413
+
414
+ // CSS selector string → querySelector (single element)
415
+ if (typeof selector === 'string') {
416
+ const root = context
417
+ ? (typeof context === 'string' ? document.querySelector(context) : context)
418
+ : document;
419
+ return root.querySelector(selector);
420
+ }
421
+
422
+ return null;
423
+ }
424
+
425
+
426
+ // ---------------------------------------------------------------------------
427
+ // $.all() — collection selector (returns ZQueryCollection for CSS selectors)
428
+ // ---------------------------------------------------------------------------
429
+ export function queryAll(selector, context) {
430
+ // null / undefined
431
+ if (!selector) return new ZQueryCollection([]);
432
+
433
+ // Already a collection
434
+ if (selector instanceof ZQueryCollection) return selector;
435
+
436
+ // DOM element or Window
437
+ if (selector instanceof Node || selector === window) {
438
+ return new ZQueryCollection([selector]);
439
+ }
440
+
441
+ // NodeList / HTMLCollection / Array
442
+ if (selector instanceof NodeList || selector instanceof HTMLCollection || Array.isArray(selector)) {
443
+ return new ZQueryCollection(Array.from(selector));
444
+ }
445
+
446
+ // HTML string → create elements
447
+ if (typeof selector === 'string' && selector.trim().startsWith('<')) {
448
+ const fragment = createFragment(selector);
449
+ return new ZQueryCollection([...fragment.childNodes].filter(n => n.nodeType === 1));
450
+ }
451
+
452
+ // CSS selector string → querySelectorAll (collection)
453
+ if (typeof selector === 'string') {
454
+ const root = context
455
+ ? (typeof context === 'string' ? document.querySelector(context) : context)
456
+ : document;
457
+ return new ZQueryCollection([...root.querySelectorAll(selector)]);
458
+ }
459
+
460
+ return new ZQueryCollection([]);
461
+ }
462
+
463
+
464
+ // ---------------------------------------------------------------------------
465
+ // Quick-ref shortcuts, on $ namespace)
466
+ // ---------------------------------------------------------------------------
467
+ query.id = (id) => document.getElementById(id);
468
+ query.class = (name) => document.querySelector(`.${name}`);
469
+ query.classes = (name) => Array.from(document.getElementsByClassName(name));
470
+ query.tag = (name) => Array.from(document.getElementsByTagName(name));
471
+ query.children = (parentId) => {
472
+ const p = document.getElementById(parentId);
473
+ return p ? Array.from(p.children) : [];
474
+ };
475
+
476
+ // Create element shorthand
477
+ query.create = (tag, attrs = {}, ...children) => {
478
+ const el = document.createElement(tag);
479
+ for (const [k, v] of Object.entries(attrs)) {
480
+ if (k === 'class') el.className = v;
481
+ else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
482
+ else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v);
483
+ else if (k === 'data' && typeof v === 'object') Object.entries(v).forEach(([dk, dv]) => { el.dataset[dk] = dv; });
484
+ else el.setAttribute(k, v);
485
+ }
486
+ children.flat().forEach(child => {
487
+ if (typeof child === 'string') el.appendChild(document.createTextNode(child));
488
+ else if (child instanceof Node) el.appendChild(child);
489
+ });
490
+ return el;
491
+ };
492
+
493
+ // DOM ready
494
+ query.ready = (fn) => {
495
+ if (document.readyState !== 'loading') fn();
496
+ else document.addEventListener('DOMContentLoaded', fn);
497
+ };
498
+
499
+ // Global event delegation
500
+ query.on = (event, selector, handler) => {
501
+ document.addEventListener(event, (e) => {
502
+ const target = e.target.closest(selector);
503
+ if (target) handler.call(target, e);
504
+ });
505
+ };
506
+
507
+ // Extend collection prototype (like $.fn in jQuery)
508
+ query.fn = ZQueryCollection.prototype;
package/src/http.js ADDED
@@ -0,0 +1,177 @@
1
+ /**
2
+ * zQuery HTTP — Lightweight fetch wrapper
3
+ *
4
+ * Clean API for GET/POST/PUT/PATCH/DELETE with:
5
+ * - Auto JSON serialization/deserialization
6
+ * - Request/response interceptors
7
+ * - Timeout support
8
+ * - Base URL configuration
9
+ * - Abort controller integration
10
+ *
11
+ * Usage:
12
+ * $.http.get('/api/users');
13
+ * $.http.post('/api/users', { name: 'Tony' });
14
+ * $.http.configure({ baseURL: 'https://api.example.com', headers: { Authorization: 'Bearer ...' } });
15
+ */
16
+
17
+ const _config = {
18
+ baseURL: '',
19
+ headers: { 'Content-Type': 'application/json' },
20
+ timeout: 30000,
21
+ };
22
+
23
+ const _interceptors = {
24
+ request: [],
25
+ response: [],
26
+ };
27
+
28
+
29
+ /**
30
+ * Core request function
31
+ */
32
+ async function request(method, url, data, options = {}) {
33
+ let fullURL = url.startsWith('http') ? url : _config.baseURL + url;
34
+ let headers = { ..._config.headers, ...options.headers };
35
+ let body = undefined;
36
+
37
+ // Build fetch options
38
+ const fetchOpts = {
39
+ method: method.toUpperCase(),
40
+ headers,
41
+ ...options,
42
+ };
43
+
44
+ // Handle body
45
+ if (data !== undefined && method !== 'GET' && method !== 'HEAD') {
46
+ if (data instanceof FormData) {
47
+ body = data;
48
+ delete fetchOpts.headers['Content-Type']; // Let browser set multipart boundary
49
+ } else if (typeof data === 'object') {
50
+ body = JSON.stringify(data);
51
+ } else {
52
+ body = data;
53
+ }
54
+ fetchOpts.body = body;
55
+ }
56
+
57
+ // Query params for GET
58
+ if (data && (method === 'GET' || method === 'HEAD') && typeof data === 'object') {
59
+ const params = new URLSearchParams(data).toString();
60
+ fullURL += (fullURL.includes('?') ? '&' : '?') + params;
61
+ }
62
+
63
+ // Timeout via AbortController
64
+ const controller = new AbortController();
65
+ fetchOpts.signal = options.signal || controller.signal;
66
+ const timeout = options.timeout ?? _config.timeout;
67
+ let timer;
68
+ if (timeout > 0) {
69
+ timer = setTimeout(() => controller.abort(), timeout);
70
+ }
71
+
72
+ // Run request interceptors
73
+ for (const interceptor of _interceptors.request) {
74
+ const result = await interceptor(fetchOpts, fullURL);
75
+ if (result === false) throw new Error('Request blocked by interceptor');
76
+ if (result?.url) fullURL = result.url;
77
+ if (result?.options) Object.assign(fetchOpts, result.options);
78
+ }
79
+
80
+ try {
81
+ const response = await fetch(fullURL, fetchOpts);
82
+ if (timer) clearTimeout(timer);
83
+
84
+ // Parse response
85
+ const contentType = response.headers.get('Content-Type') || '';
86
+ let responseData;
87
+
88
+ if (contentType.includes('application/json')) {
89
+ responseData = await response.json();
90
+ } else if (contentType.includes('text/')) {
91
+ responseData = await response.text();
92
+ } else if (contentType.includes('application/octet-stream') || contentType.includes('image/')) {
93
+ responseData = await response.blob();
94
+ } else {
95
+ // Try JSON first, fall back to text
96
+ const text = await response.text();
97
+ try { responseData = JSON.parse(text); } catch { responseData = text; }
98
+ }
99
+
100
+ const result = {
101
+ ok: response.ok,
102
+ status: response.status,
103
+ statusText: response.statusText,
104
+ headers: Object.fromEntries(response.headers.entries()),
105
+ data: responseData,
106
+ response,
107
+ };
108
+
109
+ // Run response interceptors
110
+ for (const interceptor of _interceptors.response) {
111
+ await interceptor(result);
112
+ }
113
+
114
+ if (!response.ok) {
115
+ const err = new Error(`HTTP ${response.status}: ${response.statusText}`);
116
+ err.response = result;
117
+ throw err;
118
+ }
119
+
120
+ return result;
121
+ } catch (err) {
122
+ if (timer) clearTimeout(timer);
123
+ if (err.name === 'AbortError') {
124
+ throw new Error(`Request timeout after ${timeout}ms: ${method} ${fullURL}`);
125
+ }
126
+ throw err;
127
+ }
128
+ }
129
+
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Public API
133
+ // ---------------------------------------------------------------------------
134
+ export const http = {
135
+ get: (url, params, opts) => request('GET', url, params, opts),
136
+ post: (url, data, opts) => request('POST', url, data, opts),
137
+ put: (url, data, opts) => request('PUT', url, data, opts),
138
+ patch: (url, data, opts) => request('PATCH', url, data, opts),
139
+ delete: (url, data, opts) => request('DELETE', url, data, opts),
140
+
141
+ /**
142
+ * Configure defaults
143
+ */
144
+ configure(opts) {
145
+ if (opts.baseURL !== undefined) _config.baseURL = opts.baseURL;
146
+ if (opts.headers) Object.assign(_config.headers, opts.headers);
147
+ if (opts.timeout !== undefined) _config.timeout = opts.timeout;
148
+ },
149
+
150
+ /**
151
+ * Add request interceptor
152
+ * @param {Function} fn — (fetchOpts, url) → void | false | { url, options }
153
+ */
154
+ onRequest(fn) {
155
+ _interceptors.request.push(fn);
156
+ },
157
+
158
+ /**
159
+ * Add response interceptor
160
+ * @param {Function} fn — (result) → void
161
+ */
162
+ onResponse(fn) {
163
+ _interceptors.response.push(fn);
164
+ },
165
+
166
+ /**
167
+ * Create a standalone AbortController for manual cancellation
168
+ */
169
+ createAbort() {
170
+ return new AbortController();
171
+ },
172
+
173
+ /**
174
+ * Raw fetch pass-through (for edge cases)
175
+ */
176
+ raw: (url, opts) => fetch(url, opts),
177
+ };