fusee-framework 2.7.1 → 2.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,70 +0,0 @@
1
- export type CometTriggerSpec = {
2
- event?: string | null
3
- poll?: number
4
- once?: boolean
5
- changed?: boolean
6
- delay?: number
7
- throttle?: number
8
- from?: string | null
9
- }
10
-
11
- export type CometRequestConfig = {
12
- method: string
13
- url: string | null
14
- }
15
-
16
- export type CometOobSwap = {
17
- el: Element
18
- mode: string
19
- targetSelector: string | null
20
- }
21
-
22
- export type CometConfig = {
23
- defaultSwap: string
24
- attrPrefix: string
25
- requestingClass: string
26
- timeout: number
27
- hydrator: ((node: Node, detail: any) => void) | null
28
- cleanup: ((target: Element, mode: string) => void) | null
29
- }
30
-
31
- export type CometApi = {
32
- init(root: Element): void
33
- process(element: Element): void
34
- config: CometConfig
35
- parseTriggerSpec(raw: string | null): CometTriggerSpec[]
36
- getRequestConfig(element: Element): CometRequestConfig | null
37
- gatherParams(element: Element): URLSearchParams
38
- swap(target: Element, html: string, mode: string): Node[]
39
- extractOOBSwaps(html: string, document: Document): {
40
- mainHTML: string
41
- oobSwaps: CometOobSwap[]
42
- }
43
- setHydrator(fn: ((node: Node, detail: any) => void) | null): void
44
- setCleanup(fn: ((target: Element, mode: string) => void) | null): void
45
- }
46
-
47
- export declare const config: CometConfig
48
- export declare const ATTR: Record<string, string>
49
-
50
- export function parseTriggerSpec(raw: string | null): CometTriggerSpec[]
51
- export function getRequestConfig(element: Element): CometRequestConfig | null
52
- export function gatherParams(element: Element): URLSearchParams
53
- export function swap(target: Element, html: string, mode: string): Node[]
54
- export function extractOOBSwaps(html: string, document: Document): {
55
- mainHTML: string
56
- oobSwaps: CometOobSwap[]
57
- }
58
- export function performRequest(element: Element, triggerEvent: Event | null): Promise<void>
59
- export function wireElement(element: Element): void
60
- export function process(element: Element): void
61
- export function init(root: Element): void
62
- export function stopObserving(): void
63
- export function setHydrator(fn: ((node: Node, detail: any) => void) | null): void
64
- export function setCleanup(fn: ((target: Element, mode: string) => void) | null): void
65
-
66
- declare global {
67
- interface Window {
68
- Comet?: CometApi
69
- }
70
- }
@@ -1,534 +0,0 @@
1
- import { config } from "./comet-config.js";
2
- import { ATTR, METHOD_ATTRS, DEFAULT_TRIGGER_BY_TAG } from "./comet-methods.js";
3
-
4
- export { config, ATTR };
5
-
6
- // --- Event dispatch ---------------------------------------------------
7
-
8
- /**
9
- * Dispatches a `comet:<name>` CustomEvent on el, bubbling, cancelable.
10
- * Returns the event so callers can check `.defaultPrevented`.
11
- */
12
- function fire(el, name, detail) {
13
- const event = new CustomEvent(`comet:${name}`, {
14
- detail,
15
- bubbles: true,
16
- cancelable: true,
17
- })
18
- el.dispatchEvent(event)
19
- return event
20
- }
21
-
22
- // --- Trigger spec parsing ---------------------------------------------
23
-
24
- /**
25
- * Parses a comet-trigger value into an array of trigger specs.
26
- * Supports comma-separated multiple triggers, "every Xs" polling,
27
- * "load" (fire once on init), and modifiers: delay:Xms, throttle:Xms,
28
- * changed, once, from:<selector>.
29
- *
30
- * "keyup changed delay:500ms" -> { event: 'keyup', changed: true, delay: 500 }
31
- * "every 2s" -> { poll: 2000 }
32
- * "load" -> { event: 'load' } (handled specially: fires immediately)
33
- */
34
- export function parseTriggerSpec(raw) {
35
- if (!raw || !raw.trim()) return [{ event: null }]
36
- return raw.split(',').map(part => parseSingleTrigger(part.trim())).filter(Boolean)
37
- }
38
-
39
- function parseSingleTrigger(part) {
40
- if (!part) return null
41
- const tokens = part.split(/\s+/)
42
- const first = tokens[0]
43
-
44
- if (first === 'every') {
45
- const ms = parseDuration(tokens[1]) ?? 1000
46
- return { poll: ms }
47
- }
48
-
49
- const spec = { event: first, once: false, changed: false, delay: 0, throttle: 0, from: null }
50
- for (const tok of tokens.slice(1)) {
51
- if (tok === 'once') spec.once = true
52
- else if (tok === 'changed') spec.changed = true
53
- else if (tok.startsWith('delay:')) spec.delay = parseDuration(tok.slice(6)) ?? 0
54
- else if (tok.startsWith('throttle:')) spec.throttle = parseDuration(tok.slice(9)) ?? 0
55
- else if (tok.startsWith('from:')) spec.from = tok.slice(5)
56
- }
57
- return spec
58
- }
59
-
60
- function parseDuration(s) {
61
- if (!s) return null
62
- const m = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(s.trim())
63
- if (!m) return null
64
- const n = parseFloat(m[1])
65
- return m[2] === 's' ? n * 1000 : n
66
- }
67
-
68
- // --- Request config -----------------------------------------------------
69
-
70
- /** Returns { method, url } from whichever comet-<method> attribute is present, or null. */
71
- export function getRequestConfig(el) {
72
- for (const [method, attr] of METHOD_ATTRS) {
73
- if (el.hasAttribute(attr)) {
74
- return { method, url: el.getAttribute(attr) }
75
- }
76
- }
77
- return null
78
- }
79
-
80
- function resolveDefaultEvent(el) {
81
- return DEFAULT_TRIGGER_BY_TAG[el.tagName] || 'click'
82
- }
83
-
84
- // --- Param gathering ------------------------------------------------------
85
-
86
- /**
87
- * Gathers request parameters for el: form fields (if el is a form, or
88
- * the triggering element is inside one and no comet-get/post is on the
89
- * form itself — kept simple in Phase 1: only el's OWN form, if el IS a
90
- * form), comet-vals (JSON), comet-include (selector, gathers name/value
91
- * pairs from matches), and el's own name/value if it has both.
92
- */
93
- export function gatherParams(el) {
94
- const params = new URLSearchParams()
95
-
96
- if (el.tagName === 'FORM') {
97
- const fd = new FormData(el)
98
- for (const [k, v] of fd.entries()) {
99
- if (typeof v === 'string') params.append(k, v)
100
- }
101
- } else if (el.name && el.value !== undefined) {
102
- params.append(el.name, el.value)
103
- }
104
-
105
- const includeSel = el.getAttribute(ATTR.include)
106
- if (includeSel) {
107
- const root = el.ownerDocument
108
- root.querySelectorAll(includeSel).forEach(node => {
109
- if (node.name && node.value !== undefined) {
110
- params.append(node.name, node.value)
111
- }
112
- })
113
- }
114
-
115
- const valsRaw = el.getAttribute(ATTR.vals)
116
- if (valsRaw) {
117
- try {
118
- const vals = JSON.parse(valsRaw)
119
- for (const [k, v] of Object.entries(vals)) {
120
- params.append(k, String(v))
121
- }
122
- } catch (e) {
123
- console.warn(`[comet] invalid comet-vals JSON on`, el, e)
124
- }
125
- }
126
-
127
- return params
128
- }
129
-
130
- // --- Swap ------------------------------------------------------------
131
-
132
- /**
133
- * Performs the actual DOM swap. `mode` is one of: innerHTML, outerHTML,
134
- * beforebegin, afterbegin, beforeend, afterend, delete, none.
135
- * Returns the list of newly-inserted top-level nodes (for re-processing),
136
- * or [] for delete/none/innerHTML-of-nothing-new-at-top-level cases where
137
- * re-processing should instead happen on `target` itself (innerHTML) or
138
- * nothing (delete/none).
139
- */
140
- export function swap(target, html, mode) {
141
- switch (mode) {
142
- case 'none':
143
- return []
144
- case 'delete':
145
- target.remove()
146
- return []
147
- case 'outerHTML': {
148
- const parent = target.parentNode
149
- const tpl = target.ownerDocument.createElement('template')
150
- tpl.innerHTML = html
151
- const nodes = Array.from(tpl.content.childNodes)
152
- parent.replaceChild(tpl.content, target)
153
- return nodes
154
- }
155
- case 'beforebegin':
156
- case 'afterbegin':
157
- case 'beforeend':
158
- case 'afterend': {
159
- const tpl = target.ownerDocument.createElement('template')
160
- tpl.innerHTML = html
161
- const nodes = Array.from(tpl.content.childNodes)
162
- target.insertAdjacentHTML(mode, html)
163
- return nodes
164
- }
165
- case 'innerHTML':
166
- default:
167
- target.innerHTML = html
168
- return [target]
169
- }
170
- }
171
-
172
- /**
173
- * Finds every element in `html` carrying comet-swap-oob, removes it from
174
- * the main fragment, and returns { mainHTML, oobSwaps: [{el, mode, targetSelector}] }
175
- * so the caller can perform the OOB swaps separately from the main one.
176
- *
177
- * comet-swap-oob="true" -> mode "outerHTML", target "#<the element's own id>"
178
- * comet-swap-oob="innerHTML" -> mode "innerHTML", target "#<the element's own id>"
179
- * comet-swap-oob="innerHTML:#foo" -> mode "innerHTML", target "#foo"
180
- */
181
- export function extractOOBSwaps(html, doc) {
182
- const tpl = doc.createElement('template')
183
- tpl.innerHTML = html
184
-
185
- const oobEls = Array.from(tpl.content.querySelectorAll(`[${ATTR.swapOob}]`))
186
- const oobSwaps = []
187
-
188
- for (const el of oobEls) {
189
- const raw = el.getAttribute(ATTR.swapOob)
190
- let mode = 'outerHTML'
191
- let targetSelector = el.id ? `#${el.id}` : null
192
-
193
- if (raw && raw !== 'true') {
194
- const parts = raw.split(':')
195
- mode = parts[0]
196
- if (parts[1]) targetSelector = parts[1]
197
- }
198
-
199
- el.removeAttribute(ATTR.swapOob)
200
- el.remove()
201
- oobSwaps.push({ el, mode, targetSelector })
202
- }
203
-
204
- return { mainHTML: tpl.innerHTML, oobSwaps }
205
- }
206
-
207
- // --- Indicator / confirm ------------------------------------------------
208
-
209
- function getIndicatorEl(el) {
210
- const sel = el.getAttribute(ATTR.indicator)
211
- if (!sel) return el
212
- return el.ownerDocument.querySelector(sel) || el
213
- }
214
-
215
- // --- Core request pipeline ---------------------------------------------
216
-
217
- const inFlightPollers = new WeakMap() // element -> interval id, for cleanup
218
-
219
- /**
220
- * Fires the request configured on `el`, using `triggerEvent` (may be
221
- * null for programmatic/poll-driven calls) for header/context info.
222
- */
223
- export async function performRequest(el, triggerEvent) {
224
- const reqConfig = getRequestConfig(el)
225
- if (!reqConfig) return
226
-
227
- const confirmMsg = el.getAttribute(ATTR.confirm)
228
- if (confirmMsg && typeof el.ownerDocument.defaultView?.confirm === 'function') {
229
- if (!el.ownerDocument.defaultView.confirm(confirmMsg)) return
230
- }
231
-
232
- const targetSel = el.getAttribute(ATTR.target)
233
- let target = targetSel ? el.ownerDocument.querySelector(targetSel) : el
234
- if (!target) target = el
235
-
236
- const swapMode = el.getAttribute(ATTR.swap) || config.defaultSwap
237
-
238
- const params = gatherParams(el)
239
-
240
- const requestDetail = {
241
- method: reqConfig.method,
242
- url: reqConfig.url,
243
- params,
244
- headers: {
245
- 'Comet-Request': 'true',
246
- 'Comet-Trigger': triggerEvent ? triggerEvent.type : '',
247
- 'Comet-Target': targetSel || '',
248
- },
249
- target,
250
- swapMode,
251
- }
252
-
253
- const configEvent = fire(el, 'configRequest', requestDetail)
254
- if (configEvent.defaultPrevented) return
255
-
256
- const beforeEvent = fire(el, 'beforeRequest', requestDetail)
257
- if (beforeEvent.defaultPrevented) return
258
-
259
- const indicatorEl = getIndicatorEl(el)
260
- indicatorEl.classList.add(config.requestingClass)
261
-
262
- let url = requestDetail.url
263
- let fetchOpts = {
264
- method: requestDetail.method.toUpperCase(),
265
- headers: requestDetail.headers,
266
- }
267
-
268
- if (requestDetail.method === 'get') {
269
- const qs = params.toString()
270
- if (qs) url += (url.includes('?') ? '&' : '?') + qs
271
- } else {
272
- fetchOpts.body = params
273
- }
274
-
275
- let controller = null
276
- if (config.timeout > 0) {
277
- controller = new AbortController()
278
- fetchOpts.signal = controller.signal
279
- setTimeout(() => controller.abort(), config.timeout)
280
- }
281
-
282
- let response
283
- try {
284
- response = await fetch(url, fetchOpts)
285
- } catch (err) {
286
- indicatorEl.classList.remove(config.requestingClass)
287
- fire(el, 'sendError', { ...requestDetail, error: err })
288
- return
289
- }
290
-
291
- indicatorEl.classList.remove(config.requestingClass)
292
-
293
- fire(el, 'afterRequest', { ...requestDetail, response })
294
-
295
- if (!response.ok) {
296
- const errEvent = fire(el, 'responseError', { ...requestDetail, response })
297
- if (errEvent.defaultPrevented) return
298
- console.warn(`[comet] request to ${url} failed with status ${response.status}`)
299
- return
300
- }
301
-
302
- await handleResponse(el, response, requestDetail)
303
- }
304
-
305
- async function handleResponse(el, response, requestDetail) {
306
- const doc = el.ownerDocument
307
-
308
- const redirect = response.headers.get('Comet-Redirect')
309
- if (redirect) {
310
- doc.defaultView.location.assign(redirect)
311
- return
312
- }
313
-
314
- let target = requestDetail.target
315
- const retarget = response.headers.get('Comet-Retarget')
316
- if (retarget) {
317
- target = doc.querySelector(retarget) || target
318
- }
319
-
320
- let swapMode = requestDetail.swapMode
321
- const reswap = response.headers.get('Comet-Reswap')
322
- if (reswap) swapMode = reswap
323
-
324
- const html = await response.text()
325
-
326
- const { mainHTML, oobSwaps } = extractOOBSwaps(html, doc)
327
-
328
- const swapEvent = fire(el, 'beforeSwap', { ...requestDetail, target, swapMode, html: mainHTML })
329
- if (swapEvent.defaultPrevented) return
330
-
331
- cleanupTarget(target, swapMode, el, requestDetail)
332
-
333
- const newNodes = swap(target, mainHTML, swapMode)
334
- for (const node of newNodes) {
335
- hydrateNode(node, el, requestDetail)
336
- }
337
-
338
- for (const { el: oobEl, mode, targetSelector } of oobSwaps) {
339
- if (!targetSelector) continue
340
- const oobTarget = doc.querySelector(targetSelector)
341
- if (!oobTarget) continue
342
-
343
- cleanupTarget(oobTarget, mode, el, requestDetail)
344
-
345
- const oobHTML = mode === 'outerHTML' ? oobEl.outerHTML : oobEl.innerHTML
346
- const oobNewNodes = swap(oobTarget, oobHTML, mode)
347
- for (const node of oobNewNodes) {
348
- hydrateNode(node, el, requestDetail)
349
- }
350
- }
351
-
352
- fire(el, 'afterSwap', { ...requestDetail, target, swapMode })
353
-
354
- const pushUrl = response.headers.get('Comet-Push-Url') || el.getAttribute(ATTR.pushUrl)
355
- if (pushUrl && pushUrl !== 'false') {
356
- const url = pushUrl === 'true' ? requestDetail.url : pushUrl
357
- doc.defaultView.history.pushState({ comet: true }, '', url)
358
- }
359
- }
360
-
361
- function cleanupTarget(target, mode, el, detail) {
362
- if (!target) return
363
- fire(target, 'cleanup', { ...detail, target, mode, triggerEl: el })
364
- if (typeof config.cleanup === 'function') {
365
- try {
366
- config.cleanup(target, mode)
367
- } catch (err) {
368
- console.error('[comet] error in cleanup hook:', err)
369
- }
370
- }
371
- }
372
-
373
- function hydrateNode(node, el, detail) {
374
- if (node.nodeType === 1) {
375
- if (typeof config.hydrator === 'function') {
376
- try {
377
- config.hydrator(node, { ...detail, triggerEl: el })
378
- } catch (err) {
379
- console.error('[comet] error in hydrator hook:', err)
380
- }
381
- }
382
- fire(node, 'hydrate', { ...detail, node, triggerEl: el })
383
- process(node)
384
- }
385
- }
386
-
387
- /** Registers a custom hydrator hook function. */
388
- export function setHydrator(fn) {
389
- config.hydrator = fn
390
- }
391
-
392
- /** Registers a custom cleanup hook function. */
393
- export function setCleanup(fn) {
394
- config.cleanup = fn
395
- }
396
-
397
- // --- Wiring --------------------------------------------------------------
398
-
399
- const wired = new WeakSet()
400
-
401
- /**
402
- * Wires a single element that has a comet-<method> attribute: attaches
403
- * its trigger listener(s), or starts its poller, or fires immediately
404
- * for a "load" trigger. Idempotent — calling this twice on the same
405
- * element is a no-op the second time.
406
- */
407
- export function wireElement(el) {
408
- if (wired.has(el)) return
409
- wired.add(el)
410
-
411
- const reqConfig = getRequestConfig(el)
412
- if (!reqConfig) return
413
-
414
- const specs = parseTriggerSpec(el.getAttribute(ATTR.trigger))
415
-
416
- for (const spec of specs) {
417
- if (spec.poll !== undefined) {
418
- startPolling(el, spec.poll)
419
- continue
420
- }
421
-
422
- const eventName = spec.event || resolveDefaultEvent(el)
423
-
424
- if (eventName === 'load') {
425
- queueMicrotask(() => performRequest(el, null))
426
- continue
427
- }
428
-
429
- attachTriggerListener(el, eventName, spec)
430
- }
431
- }
432
-
433
- function attachTriggerListener(el, eventName, spec) {
434
- let lastValue = el.value
435
- let throttling = false
436
- let debounceTimer = null
437
-
438
- const handler = (evt) => {
439
- if (spec.from && evt.target !== el && !(evt.target.closest && evt.target.closest(spec.from))) {
440
- return
441
- }
442
- if (spec.changed && el.value === lastValue) return
443
- lastValue = el.value
444
-
445
- if (eventName === 'submit') evt.preventDefault()
446
-
447
- const run = () => {
448
- if (spec.throttle > 0) {
449
- if (throttling) return
450
- throttling = true
451
- setTimeout(() => { throttling = false }, spec.throttle)
452
- }
453
- performRequest(el, evt)
454
- }
455
-
456
- if (spec.delay > 0) {
457
- clearTimeout(debounceTimer)
458
- debounceTimer = setTimeout(run, spec.delay)
459
- } else {
460
- run()
461
- }
462
-
463
- if (spec.once) el.removeEventListener(eventName, handler)
464
- }
465
-
466
- el.addEventListener(eventName, handler)
467
- }
468
-
469
- function startPolling(el, intervalMs) {
470
- if (inFlightPollers.has(el)) return
471
- const id = setInterval(() => {
472
- if (!el.isConnected) {
473
- clearInterval(id)
474
- inFlightPollers.delete(el)
475
- return
476
- }
477
- performRequest(el, null)
478
- }, intervalMs)
479
- inFlightPollers.set(el, id)
480
- }
481
-
482
- // --- Public scan/init API -------------------------------------------------
483
-
484
- const REQUEST_ATTR_SELECTOR = METHOD_ATTRS.map(([, attr]) => `[${attr}]`).join(',')
485
-
486
- /** Wires a single element and, separately, recurses into its descendants. Safe to call on already-wired elements/subtrees. */
487
- export function process(el) {
488
- if (el.nodeType !== 1) return
489
- if (el.matches && el.matches(REQUEST_ATTR_SELECTOR)) {
490
- wireElement(el)
491
- }
492
- if (el.querySelectorAll) {
493
- el.querySelectorAll(REQUEST_ATTR_SELECTOR).forEach(wireElement)
494
- }
495
- }
496
-
497
- let observer = null
498
-
499
- /** Scans root for comet-* elements and wires them, then starts a MutationObserver on root for future additions. */
500
- export function init(root) {
501
- if (!root) return
502
- process(root)
503
-
504
- if (typeof MutationObserver === 'undefined') return
505
- if (observer) observer.disconnect()
506
-
507
- observer = new MutationObserver(mutations => {
508
- for (const m of mutations) {
509
- m.addedNodes.forEach(node => {
510
- if (node.nodeType === 1) process(node)
511
- })
512
- }
513
- })
514
- observer.observe(root, { childList: true, subtree: true })
515
- }
516
-
517
- export function stopObserving() {
518
- if (observer) {
519
- observer.disconnect()
520
- observer = null
521
- }
522
- }
523
-
524
- // --- Auto-init in a real browser environment ------------------------------
525
-
526
- if (typeof window !== 'undefined' && typeof document !== 'undefined' && !window.__COMET_NO_AUTO_INIT__) {
527
- const start = () => init(document.body)
528
- if (document.readyState === 'loading') {
529
- document.addEventListener('DOMContentLoaded', start)
530
- } else {
531
- start()
532
- }
533
- window.Comet = { init, process, config, parseTriggerSpec, getRequestConfig, gatherParams, swap, extractOOBSwaps, setHydrator, setCleanup }
534
- }