UNPKG

@aegisjsproject/router

Version:
1,861 lines (1,643 loc) 86.7 kB
'use strict'; var state = require('@aegisjsproject/state'); function stringify(thing) { switch(typeof thing) { case 'string': return thing; case 'function': throw new TypeError('Functions are not supported.'); case 'undefined': return ''; case 'object': if (thing === null) { return ''; } else if (thing instanceof Date) { return thing.toISOString(); } else if (Array.isArray(thing)) { return thing.map(stringify).join(','); } else if (thing instanceof ArrayBuffer && Uint8Array.prototype.toBase64 instanceof Function) { return new Uint8Array(thing).toBase64(); } else if (ArrayBuffer.isView(thing) && thing.toBase64 instanceof Function) { return thing.toBase64(); } else if (thing instanceof Blob) { return URL.createObjectURL(thing); } else { return thing.toString(); } default: return thing.toString(); } } /** * Escapes a component of a URL, also protecting against path traversal * * @param {string} str * @returns {string} The URL-safe string */ function escape(str) { return encodeURIComponent(stringify(str).trim()).replaceAll('..%2F', '%2E%2E%2F').replaceAll('.%2F', '%2E%2E%2F'); } /** * Creates a URL parser tagged template with a custom base * * @param {string} [base=document.baseURI] Base to parse relative URLs from * @returns {Function} A URL parsing tagged template */ function createURLParser(base = globalThis?.document?.baseURI) { return function url(strings, value, ...values) { if (value instanceof Blob && strings.length === 2 && strings[0] === '' && strings[1] === '') { return new URL(URL.createObjectURL(value)); } else if (URL.canParse(value)) { return URL.parse(String.raw(strings, '', ...values.map(escape)), value); } else if (strings[0].startsWith('/')) { return URL.parse(String.raw(strings, escape(value), ...values.map(escape)), base); } else if (strings[0].startsWith('./') || strings[0].startsWith('../')) { return URL.parse(String.raw(strings, escape(value), ...values.map(escape)), base); } else { return URL.parse(String.raw(strings, escape(value), ...values.map(escape))); } }; } /** * A function for creating URL objects from tagged template literals. * * @param {TemplateStringsArray} strings - The template string parts. * @param {...any} args - The template string substitutions. * @returns {URL | null} - A URL object if the URL is valid, otherwise null. */ const url = createURLParser(); const $$ = (selector, base = document) => base.querySelectorAll(selector); const $ = (selector, base = document) => base.querySelector(selector); const requestFullscreen = { data: 'requestFullscreenSelector' }; const toggleFullsceen = { data: 'toggleFullscreenSelector' }; const FUNCS = { debug: { log: 'aegis:debug:log', info: 'aegis:debug:info', warn: 'aegis:debug:warn', error: 'aegis:debug:error', }, navigate: { back: 'aegis:navigate:back', forward: 'aegis:navigate:forward', reload: 'aegis:navigate:reload', close: 'aegis:navigate:close', link: 'aegis:navigate:go', popup: 'aegis:navigate:popup', }, ui: { print: 'aegis:ui:print', remove: 'aegis:ui:remove', hide: 'aegis:ui:hide', unhide: 'aegis:ui:unhide', showModal: 'aegis:ui:showModal', closeModal: 'aegis:ui:closeModal', showPopover: 'aegis:ui:showPopover', hidePopover: 'aegis:ui:hidePopover', togglePopover: 'aegis:ui:togglePopover', enable: 'aegis:ui:enable', disable: 'aegis:ui:disable', scrollTo: 'aegis:ui:scrollTo', prevent: 'aegis:ui:prevent', revokeObjectURL: 'aegis:ui:revokeObjectURL', cancelAnimationFrame: 'aegis:ui:cancelAnimationFrame', clearInterval: 'aegis:clearInterval', clearTimeout: 'aegis:clearTimeout', requestFullscreen: 'aegis:ui:requestFullscreen', toggleFullscreen: 'aegis:ui:toggleFullsceen', exitFullsceen: 'aegis:ui:exitFullscreen', open: 'aegis:ui:open', close: 'aegis:ui:close', abortController: 'aegis:ui:controller:abort', }, }; /** * @type {Map<string, function>} */ const registry = new Map([ [FUNCS.debug.log, console.log], [FUNCS.debug.warn, console.warn], [FUNCS.debug.error, console.error], [FUNCS.debug.info, console.info], [FUNCS.navigate.back, () => history.back()], [FUNCS.navigate.forward, () => history.forward()], [FUNCS.navigate.reload, () => history.go(0)], [FUNCS.navigate.close, () => globalThis.close()], [FUNCS.navigate.link, event => { if (event.isTrusted) { event.preventDefault(); location.href = event.currentTarget.dataset.url; } }], [FUNCS.navigate.popup, event => { if (event.isTrusted) { event.preventDefault(); globalThis.open(event.currentTarget.dataset.url); } }], [FUNCS.ui.hide, ({ currentTarget }) => { $$(currentTarget.dataset.hideSelector).forEach(el => el.hidden = true); }], [FUNCS.ui.unhide, ({ currentTarget }) => { $$(currentTarget.dataset.unhideSelector).forEach(el => el.hidden = false); }], [FUNCS.ui.disable, ({ currentTarget }) => { $$(currentTarget.dataset.disableSelector).forEach(el => el.disabled = true); }], [FUNCS.ui.enable, ({ currentTarget }) => { $$(currentTarget.dataset.enableSelector).forEach(el => el.disabled = false); }], [FUNCS.ui.remove, ({ currentTarget }) => { $$(currentTarget.dataset.removeSelector).forEach(el => el.remove()); }], [FUNCS.ui.scrollTo, ({ currentTarget }) => { const target = $(currentTarget.dataset.scrollToSelector); if (target instanceof Element) { target.scrollIntoView({ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth', }); } }], [FUNCS.ui.revokeObjectURL, ({ currentTarget }) => URL.revokeObjectURL(currentTarget.src)], [FUNCS.ui.cancelAnimationFrame, ({ currentTarget }) => cancelAnimationFrame(parseInt(currentTarget.dataset.animationFrame))], [FUNCS.ui.clearInterval, ({ currentTarget }) => clearInterval(parseInt(currentTarget.dataset.clearInterval))], [FUNCS.ui.clearTimeout, ({ currentTarget }) => clearTimeout(parseInt(currentTarget.dataset.timeout))], [FUNCS.ui.abortController, ({ currentTarget }) => abortController(currentTarget.dataset.aegisEventController, currentTarget.dataset.aegisControllerReason)], [FUNCS.ui.open, ({ currentTarget }) => document.querySelector(currentTarget.dataset.openSelector).open = true], [FUNCS.ui.close, ({ currentTarget }) => document.querySelector(currentTarget.dataset.closeSelector).open = false], [FUNCS.ui.showModal, ({ currentTarget }) => { const target = $(currentTarget.dataset.showModalSelector); if (target instanceof HTMLDialogElement) { target.showModal(); } }], [FUNCS.ui.closeModal, ({ currentTarget }) => { const target = $(currentTarget.dataset.closeModalSelector); if (target instanceof HTMLDialogElement) { target.close(); } }], [FUNCS.ui.showPopover, ({ currentTarget }) => { const target = $(currentTarget.dataset.showPopoverSelector); if (target instanceof HTMLElement) { target.showPopover(); } }], [FUNCS.ui.hidePopover, ({ currentTarget }) => { const target = $(currentTarget.dataset.hidePopoverSelector); if (target instanceof HTMLElement) { target.hidePopover(); } }], [FUNCS.ui.togglePopover, ({ currentTarget }) => { const target = $(currentTarget.dataset.togglePopoverSelector); if (target instanceof HTMLElement) { target.togglePopover(); } }], [FUNCS.ui.print, () => globalThis.print()], [FUNCS.ui.prevent, event => event.preventDefault()], [FUNCS.ui.requestFullscreen, ({ currentTarget}) => { if (currentTarget.dataset.hasOwnProperty(requestFullscreen.data)) { document.getElementById(currentTarget.dataset[requestFullscreen.data]).requestFullscreen(); } else { currentTarget.requestFullscreen(); } }], [FUNCS.ui.toggleFullscreen, ({ currentTarget }) => { const target = currentTarget.dataset.hasOwnProperty(toggleFullsceen.data) ? document.getElementById(currentTarget.dataset[toggleFullsceen.data]) : currentTarget; if (target.isSameNode(document.fullscreenElement)) { document.exitFullscreen(); } else { target.requestFullscreen(); } }], [FUNCS.ui.exitFullsceen, () => document.exitFullscreen()], ]); /** * Check if a callback is registered * * @param {CallbackRegistryKey|string} name The name/key to check for in callback registry * @returns {boolean} Whether or not a callback is registered */ const hasCallback = name => registry.has(name?.toString()); /** * Get a callback from the registry by name/key * * @param {CallbackRegistryKey|string} name The name/key of the callback to get * @returns {Function|undefined} The corresponding function registered under that name/key */ const getCallback = name => registry.get(name?.toString()); // @ts-check /** * Internal slot for the callback to call to nnotify of changes * @type {unique symbol} */ const notify = Symbol('signal:watcher:notify'); /** * @type {unique symbol} */ const currentComputed = Symbol('signal:currentComputed'); /** * Callback called when isWatched becomes true, if it was previously false (`Signal.subtle.watched`) * * @type {unique symbol} */ const watched = Symbol('Signal:subtle:watched'); /** * Callback called whenever isWatched becomes false, if it was previously true (`Signal.subtle.unwatched`) * * @type {unique symbol} */ const unwatched = Symbol('Signal:subtle:unwatched'); /** * For equality checks in Computed, it must be a unique value * @type {unique symbol} */ const initial = Symbol('signal:initial'); /** * Internal slot for determining calling `Signal.subtle.watched` and `Signal.subtle.unwatched` callbacks * @type {unique symbol} */ const isWatched = Symbol('Signal:isWatched'); /** * Internal slot for State|Computed to store the `Signal.subtle.watched` callback * @type {unique symbol} */ const onWatch = Symbol('Signal:onWatch'); /** * Internal slot for State|Computed to store the `Signal.subtle.unwatched` callback * @type {unique symbol} */ const onUnwatch = Symbol('Signal:onUnwatch'); /** * @type {unique symbol} */ const sources = Symbol('Signal:sources'); /** * @type {unique symbol} */ const sinks = Symbol('Signal:sinks'); /** * @typedef {(t: any, t2: any) => boolean} EqualityCheck */ /** * Custom comparison function between old and new value. Default: Object.is. * The signal is passed in as the this value for context. * * @type {EqualityCheck} */ const equals = Object.is; /** * @typedef {{ * equals?: EqualityCheck, * [watched]?: (this: AnySignal<any>) => void, * [unwatched]?: (this: AnySignal<any>) => void, * }} SignalOptions */ /** * @type {SignalOptions} */ const opts = Object.freeze({ equals }); /** * @template T * @typedef {State<T> | Computed<T>} AnySignal<T> */ /** * A read-write Signal * @template T */ class State { /** * @type {T} */ #value; /** * @type {EqualityCheck} */ #equals; /** * @type {VoidFunction|null} */ [unwatched] = null; /** * @type {VoidFunction|null} */ [onWatch] = null; /** * @type {VoidFunction|null} */ [onUnwatch] = null; /** * @type {boolean} */ [isWatched] = false; /** * @type {Set<Computed<any>|Watcher>} */ [sinks] = new Set(); /** * @type {Set<Set<any>|Computed<any>>} */ [sources] = new Set(); /** * Create a state Signal starting with the value T * @param {T} value - The initial value. * @param {SignalOptions} options */ constructor(value, options = opts) { if (typeof options !== 'object') { throw new TypeError('Invalid options.'); } else { this.#equals = options.equals ?? equals; this.#value = value; if (typeof options?.[watched] === 'function') { this[onWatch] = options[watched].bind(this); } if (typeof options?.[unwatched] === 'function') { this[onUnwatch] = options[unwatched].bind(this); } } } /** * Get the value of the signal * * @returns {T} */ get() { const currentComputed = Signal.subtle.currentComputed(); if (currentComputed instanceof Computed && ! currentComputed[sources].has(this)) { currentComputed[sources].add(this); this[sinks].add(currentComputed); } return this.#value; } /** * Set the state Signal value to T * * @param {T} newValue */ set(newValue) { if (! this.#equals(this.#value, newValue)) { this.#value = newValue; for (const sink of Signal.subtle.introspectSinks(this)) { sink[notify](this); } } } } /** * A Signal which is a formula based on other Signals * * @template T */ class Computed { /** * @type {EqualityCheck} */ #equals; /** * @type {() => T} */ #computation; /** * @type {boolean} */ #dirty = true; /** * @type {T|initial} */ #value = initial; /** * @type {VoidFunction|null} */ [watched] = null; /** * @type {VoidFunction|null} */ [unwatched] = null; /** * @type {VoidFunction|null} */ [onWatch] = null; /** * @type {VoidFunction|null} */ [onUnwatch] = null; /** * @type {boolean} */ [isWatched] = false; /** * @type {Set<Computed<any>|Watcher>} */ [sinks] = new Set(); /** * @type {Set<State<any>|Computed<any>>} */ [sources] = new Set(); /** * Create a Signal which evaluates to the value returned by the callback. * Callback is called with this signal as the this value. * * @param {() => T} computation - The function to calculate the value. * @param {SignalOptions} [options] */ constructor(computation, options = opts) { if (typeof computation !== 'function') { throw new TypeError('Computation must be a function.'); } else if (typeof options !== 'object') { throw new TypeError('Invalid options.'); } else { this.#equals = options.equals ?? equals; this.#computation = computation; if (typeof options[watched] === 'function') { this[onWatch] = options[watched].bind(this); } if (typeof options[unwatched] === 'function') { this[onUnwatch] = options[unwatched].bind(this); } } } /** * Get the value of the signal * * @this {Computed<T>} * @returns {T|initial} */ get() { const oldComputed = Signal.subtle.currentComputed(); try { if (oldComputed !== this && oldComputed !== null) { oldComputed[sources].add(this); this[sinks].add(oldComputed); } Signal[currentComputed] = this; if (this.#dirty) { // Must clear prior dependencies BEFORE computation for (const source of Signal.subtle.introspectSources(this)) { source[sinks].delete(this); } this[sources].clear(); const val = this.#computation(); if (! this.#equals(val, this.#value)) { this.#value = val; for (const sink of Signal.subtle.introspectSinks(this)) { sink[notify](this); } } this.#dirty = false; return val; } else { return this.#value; } } finally { // Restore previous context Signal[currentComputed] = oldComputed; } } /** * Notifiies a `Signal.Computed` when a source has changed * * @param {State<any>|Computed<any>} source */ [notify](source) { this.#dirty = true; this[sources].add(source); source[sinks].add(this); for (const sink of Signal.subtle.introspectSinks(this)) { sink[notify](this); } } } /** * Watches for changes to specific signals. * @memberof Signal.subtle */ class Watcher { /** * @type {boolean} */ #isWatched = false; /** * @type {Set<AnySignal<any>>} */ #pending = new Set(); /** * @type {(this: Watcher) => void} */ #notify; /** * @type {Set<AnySignal<any>>} */ [sources] = new Set(); /** * When a (recursive) source of Watcher is written to, call this callback, * if it hasn't already been called since the last `watch` call. * No signals may be read or written during the notify. * * @param {(this: Watcher) => void} notify - Called synchronously when a watched signal becomes dirty. */ constructor(notify) { if (typeof notify !== 'function') { throw new TypeError(`Notify must be a function but got a ${typeof notify}.`); } else { this.#notify = notify; } } /** * Add these signals to the Watcher's set, and set the watcher to run its * notify callback next time any signal in the set (or one of its dependencies) changes. * Can be called with no arguments just to reset the "notified" state, so that * the notify callback will be invoked again. * * @param {...AnySignal<any>} signals */ watch(...signals) { this.#isWatched = true; for (const signal of signals) { if (! (signal instanceof State || signal instanceof Computed)) { throw new TypeError('Signal must be an instance of `Signal.State` or `Signal.Computed`.'); } else if (! this[sources].has(signal)) { if (typeof signal[onWatch] === 'function' && ! signal[isWatched]) { signal[isWatched] = true; signal[onWatch].call(signal); } this[sources].add(signal); signal[sinks].add(this); } } } /** * Remove these signals from the watched set (e.g., for an effect which is disposed) * @template T * @param {...AnySignal<T>} signals */ unwatch(...signals) { for (const signal of signals) { if (! (signal instanceof State || signal instanceof Computed)) { throw new TypeError('Signal must be an instance of `Signal.State` or `Signal.Computed`.'); } else { if (typeof signal[onUnwatch] === 'function' && signal[isWatched]) { const watchers = Signal.subtle.introspectSinks(signal).filter(sink => sink instanceof Signal.subtle.Watcher); if (watchers.length === 1) { signal[isWatched] = false; signal[onUnwatch].call(signal); } } signal[sinks].delete(this); this[sources].delete(signal); } } } /** * Returns the set of sources in the Watcher's set which are still dirty, or is a computed signal * with a source which is dirty or pending and hasn't yet been re-evaluated * * @returns {Array<AnySignal<any>>} */ getPending() { const pending = Array.from(this.#pending); this.#pending.clear(); return pending; } /** * Notify a `Signal.subtle.Watcher` when a source has changed * * @template T * @param {AnySignal<T>} signal */ [notify](signal) { this.#pending.add(signal); if (this.#isWatched) { this.#notify.call(this); } } } /** * This namespace includes "advanced" features that are better to * leave for framework authors rather than application developers. * Analogous to `crypto.subtle` * * @namespace subtle */ const subtle = { Watcher, /** * Hook to observe being watched * @type {unique symbol} */ watched, /** * Hook to observe no longer watched * @type {unique s} */ unwatched, /** * Run a callback with all tracking disabled * * @template T * @param {() => T} cb * @returns {T} */ untrack(cb) { if (typeof cb !== 'function') { throw new TypeError('Callback must be a function.'); } else { const prev = Signal.subtle.currentComputed(); Signal[currentComputed] = null; try { return cb(); } finally { Signal[currentComputed] = prev; } } }, /** * Get the current computed signal which is tracking any signal reads, if any * * @returns {Computed<any>|null} */ currentComputed() { return Signal[currentComputed]; }, /** * Returns ordered list of all signals which this one referenced * during the last time it was evaluated. * For a Watcher, lists the set of signals which it is watching. * * @param {Computed<any>|Watcher} s * @returns {(State<any>|Computed<any>)[]} */ introspectSources(s) { if (! (s instanceof Computed || s instanceof Watcher)) { throw new TypeError('Expected a `Signal.Watcher` or `Signal.Computed`.'); } else { return Array.from(s[sources]); } }, /** * Returns the Watchers that this signal is contained in, plus any * Computed signals which read this signal last time they were evaluated, * if that computed signal is (recursively) watched. * * @param {State<any>|Computed<any>} s * @returns {(Computed<any>|Watcher)[]} */ introspectSinks(s) { if (! (s instanceof State || s instanceof Computed)) { throw new TypeError('Expected a `Signal.State` or `Signal.Computed`.'); } else { return Array.from(s[sinks]); } }, /** * True if this signal is "live", in that it is watched by a Watcher, * or it is read by a Computed signal which is (recursively) live. * * @param {State<any>|Computed<any>} s * @return {boolean} */ hasSinks(s) { if (! (s instanceof State || s instanceof Computed)) { throw new TypeError('Expected a `Signal.State` or `Signal.Computed`.'); } else { return s[sinks].size !== 0; } }, /** * True if this element is "reactive", in that it depends * on some other signal. A Computed where hasSources is false * will always return the same constant. * * @param {Computed<any>|Watcher} s * @returns {boolean} */ hasSources(s) { if (! (s instanceof Computed || s instanceof Watcher)) { throw new TypeError('Expected a `Signal.Watcher` or `Signal.Computed`.'); } else { return s[sources].size !== 0; } }, }; /** * The core Signals namespace. * @namespace Signal */ const Signal = { State, Computed, subtle, /** * @type {Computed<any>|null} */ [currentComputed]: null, }; const PREFIX = 'data-aegis-event-'; const EVENT_PREFIX = PREFIX + 'on-'; const EVENT_PREFIX_LENGTH = EVENT_PREFIX.length; const DATA_PREFIX = 'aegisEventOn'; const DATA_PREFIX_LENGTH = DATA_PREFIX.length; const signalRegistry = new Map(); const controllerRegistry = new Map(); const once = PREFIX + 'once'; const passive = PREFIX + 'passive'; const capture = PREFIX + 'capture'; const signal = PREFIX + 'signal'; const onAbort = EVENT_PREFIX + 'abort'; const onBlur = EVENT_PREFIX + 'blur'; const onFocus = EVENT_PREFIX + 'focus'; const onCancel = EVENT_PREFIX + 'cancel'; const onAuxclick = EVENT_PREFIX + 'auxclick'; const onBeforeinput = EVENT_PREFIX + 'beforeinput'; const onBeforetoggle = EVENT_PREFIX + 'beforetoggle'; const onCanplay = EVENT_PREFIX + 'canplay'; const onCanplaythrough = EVENT_PREFIX + 'canplaythrough'; const onChange = EVENT_PREFIX + 'change'; const onClick = EVENT_PREFIX + 'click'; const onClose = EVENT_PREFIX + 'close'; const onCommand = EVENT_PREFIX + 'command'; const onContextmenu = EVENT_PREFIX + 'contextmenu'; const onCopy = EVENT_PREFIX + 'copy'; const onCuechange = EVENT_PREFIX + 'cuechange'; const onCut = EVENT_PREFIX + 'cut'; const onDblclick = EVENT_PREFIX + 'dblclick'; const onDrag = EVENT_PREFIX + 'drag'; const onDragend = EVENT_PREFIX + 'dragend'; const onDragenter = EVENT_PREFIX + 'dragenter'; const onDragexit = EVENT_PREFIX + 'dragexit'; const onDragleave = EVENT_PREFIX + 'dragleave'; const onDragover = EVENT_PREFIX + 'dragover'; const onDragstart = EVENT_PREFIX + 'dragstart'; const onDrop = EVENT_PREFIX + 'drop'; const onDurationchange = EVENT_PREFIX + 'durationchange'; const onEmptied = EVENT_PREFIX + 'emptied'; const onEnded = EVENT_PREFIX + 'ended'; const onFormdata = EVENT_PREFIX + 'formdata'; const onInput = EVENT_PREFIX + 'input'; const onInvalid = EVENT_PREFIX + 'invalid'; const onKeydown = EVENT_PREFIX + 'keydown'; const onKeypress = EVENT_PREFIX + 'keypress'; const onKeyup = EVENT_PREFIX + 'keyup'; const onLoad = EVENT_PREFIX + 'load'; const onLoadeddata = EVENT_PREFIX + 'loadeddata'; const onLoadedmetadata = EVENT_PREFIX + 'loadedmetadata'; const onLoadstart = EVENT_PREFIX + 'loadstart'; const onMousedown = EVENT_PREFIX + 'mousedown'; const onMouseenter = EVENT_PREFIX + 'mouseenter'; const onMouseleave = EVENT_PREFIX + 'mouseleave'; const onMousemove = EVENT_PREFIX + 'mousemove'; const onMouseout = EVENT_PREFIX + 'mouseout'; const onMouseover = EVENT_PREFIX + 'mouseover'; const onMouseup = EVENT_PREFIX + 'mouseup'; const onWheel = EVENT_PREFIX + 'wheel'; const onPaste = EVENT_PREFIX + 'paste'; const onPause = EVENT_PREFIX + 'pause'; const onPlay = EVENT_PREFIX + 'play'; const onPlaying = EVENT_PREFIX + 'playing'; const onProgress = EVENT_PREFIX + 'progress'; const onRatechange = EVENT_PREFIX + 'ratechange'; const onReset = EVENT_PREFIX + 'reset'; const onResize = EVENT_PREFIX + 'resize'; const onScroll = EVENT_PREFIX + 'scroll'; const onScrollend = EVENT_PREFIX + 'scrollend'; const onSecuritypolicyviolation = EVENT_PREFIX + 'securitypolicyviolation'; const onSeeked = EVENT_PREFIX + 'seeked'; const onSeeking = EVENT_PREFIX + 'seeking'; const onSelect = EVENT_PREFIX + 'select'; const onSlotchange = EVENT_PREFIX + 'slotchange'; const onStalled = EVENT_PREFIX + 'stalled'; const onSubmit = EVENT_PREFIX + 'submit'; const onSuspend = EVENT_PREFIX + 'suspend'; const onTimeupdate = EVENT_PREFIX + 'timeupdate'; const onVolumechange = EVENT_PREFIX + 'volumechange'; const onWaiting = EVENT_PREFIX + 'waiting'; const onSelectstart = EVENT_PREFIX + 'selectstart'; const onSelectionchange = EVENT_PREFIX + 'selectionchange'; const onToggle = EVENT_PREFIX + 'toggle'; const onPointercancel = EVENT_PREFIX + 'pointercancel'; const onPointerdown = EVENT_PREFIX + 'pointerdown'; const onPointerup = EVENT_PREFIX + 'pointerup'; const onPointermove = EVENT_PREFIX + 'pointermove'; const onPointerout = EVENT_PREFIX + 'pointerout'; const onPointerover = EVENT_PREFIX + 'pointerover'; const onPointerenter = EVENT_PREFIX + 'pointerenter'; const onPointerleave = EVENT_PREFIX + 'pointerleave'; const onGotpointercapture = EVENT_PREFIX + 'gotpointercapture'; const onLostpointercapture = EVENT_PREFIX + 'lostpointercapture'; const onMozfullscreenchange = EVENT_PREFIX + 'mozfullscreenchange'; const onMozfullscreenerror = EVENT_PREFIX + 'mozfullscreenerror'; const onAnimationcancel = EVENT_PREFIX + 'animationcancel'; const onAnimationend = EVENT_PREFIX + 'animationend'; const onAnimationiteration = EVENT_PREFIX + 'animationiteration'; const onAnimationstart = EVENT_PREFIX + 'animationstart'; const onTransitioncancel = EVENT_PREFIX + 'transitioncancel'; const onTransitionend = EVENT_PREFIX + 'transitionend'; const onTransitionrun = EVENT_PREFIX + 'transitionrun'; const onTransitionstart = EVENT_PREFIX + 'transitionstart'; const onWebkitanimationend = EVENT_PREFIX + 'webkitanimationend'; const onWebkitanimationiteration = EVENT_PREFIX + 'webkitanimationiteration'; const onWebkitanimationstart = EVENT_PREFIX + 'webkitanimationstart'; const onWebkittransitionend = EVENT_PREFIX + 'webkittransitionend'; const onError = EVENT_PREFIX + 'error'; const eventAttrs = new Signal.State([ onAbort, onBlur, onFocus, onCancel, onAuxclick, onBeforeinput, onBeforetoggle, onCanplay, onCanplaythrough, onChange, onClick, onClose, onCommand, onContextmenu, onCopy, onCuechange, onCut, onDblclick, onDrag, onDragend, onDragenter, onDragexit, onDragleave, onDragover, onDragstart, onDrop, onDurationchange, onEmptied, onEnded, onFormdata, onInput, onInvalid, onKeydown, onKeypress, onKeyup, onLoad, onLoadeddata, onLoadedmetadata, onLoadstart, onMousedown, onMouseenter, onMouseleave, onMousemove, onMouseout, onMouseover, onMouseup, onWheel, onPaste, onPause, onPlay, onPlaying, onProgress, onRatechange, onReset, onResize, onScroll, onScrollend, onSecuritypolicyviolation, onSeeked, onSeeking, onSelect, onSlotchange, onStalled, onSubmit, onSuspend, onTimeupdate, onVolumechange, onWaiting, onSelectstart, onSelectionchange, onToggle, onPointercancel, onPointerdown, onPointerup, onPointermove, onPointerout, onPointerover, onPointerenter, onPointerleave, onGotpointercapture, onLostpointercapture, onMozfullscreenchange, onMozfullscreenerror, onAnimationcancel, onAnimationend, onAnimationiteration, onAnimationstart, onTransitioncancel, onTransitionend, onTransitionrun, onTransitionstart, onWebkitanimationend, onWebkitanimationiteration, onWebkitanimationstart, onWebkittransitionend, onError, ]); const selector = new Signal.Computed(() => eventAttrs.get().map(attr => `[${CSS.escape(attr)}]`).join(',')); const isEventDataAttr = ([name]) => name.startsWith(DATA_PREFIX); function _addListeners(el, { signal, attrFilter = EVENTS } = {}) { const dataset = el.dataset; for (const [attr, val] of Object.entries(dataset).filter(isEventDataAttr)) { try { const event = 'on' + attr.substring(DATA_PREFIX_LENGTH); if (attrFilter.hasOwnProperty(event) && hasCallback(val)) { el.addEventListener(event.substring(2).toLowerCase(), getCallback(val), { passive: dataset.hasOwnProperty('aegisEventPassive'), capture: dataset.hasOwnProperty('aegisEventCapture'), once: dataset.hasOwnProperty('aegisEventOnce'), signal: dataset.hasOwnProperty('aegisEventSignal') ? getSignal(dataset.aegisEventSignal) : signal, }); } } catch(err) { reportError(err); } } } new MutationObserver(records => { records.forEach(record => { switch(record.type) { case 'childList': [...record.addedNodes] .filter(node => node.nodeType === Node.ELEMENT_NODE) .forEach(node => attachListeners(node)); break; case 'attributes': if (typeof record.oldValue === 'string' && hasCallback(record.oldValue)) { record.target.removeEventListener( record.attributeName.substring(EVENT_PREFIX_LENGTH), getCallback(record.oldValue), { once: record.target.hasAttribute(once), capture: record.target.hasAttribute(capture), passive: record.target.hasAttribute(passive), } ); } if ( record.target.hasAttribute(record.attributeName) && hasCallback(record.target.getAttribute(record.attributeName)) ) { record.target.addEventListener( record.attributeName.substring(EVENT_PREFIX_LENGTH), getCallback(record.target.getAttribute(record.attributeName)), { once: record.target.hasAttribute(once), capture: record.target.hasAttribute(capture), passive: record.target.hasAttribute(passive), signal: record.target.hasAttribute(signal) ? getSignal(record.target.getAttribute(signal)) : undefined, } ); } break; } }); }); const EVENTS = { onAbort, onBlur, onFocus, onCancel, onAuxclick, onBeforeinput, onBeforetoggle, onCanplay, onCanplaythrough, onChange, onClick, onClose, onCommand, onContextmenu, onCopy, onCuechange, onCut, onDblclick, onDrag, onDragend, onDragenter, onDragexit, onDragleave, onDragover, onDragstart, onDrop, onDurationchange, onEmptied, onEnded, onFormdata, onInput, onInvalid, onKeydown, onKeypress, onKeyup, onLoad, onLoadeddata, onLoadedmetadata, onLoadstart, onMousedown, onMouseenter, onMouseleave, onMousemove, onMouseout, onMouseover, onMouseup, onWheel, onPaste, onPause, onPlay, onPlaying, onProgress, onRatechange, onReset, onResize, onScroll, onScrollend, onSecuritypolicyviolation, onSeeked, onSeeking, onSelect, onSlotchange, onStalled, onSubmit, onSuspend, onTimeupdate, onVolumechange, onWaiting, onSelectstart, onSelectionchange, onToggle, onPointercancel, onPointerdown, onPointerup, onPointermove, onPointerout, onPointerover, onPointerenter, onPointerleave, onGotpointercapture, onLostpointercapture, onMozfullscreenchange, onMozfullscreenerror, onAnimationcancel, onAnimationend, onAnimationiteration, onAnimationstart, onTransitioncancel, onTransitionend, onTransitionrun, onTransitionstart, onWebkitanimationend, onWebkitanimationiteration, onWebkitanimationstart, onWebkittransitionend, onError, once, passive, capture, }; /** * Get a registetd controller from the registry * * @param {string} key Generated key with which the controller was registered * @returns {AbortController|void} Any registered controller, if any */ const getController = key => controllerRegistry.get(key); function abortController(key, reason) { const controller = getController(key); if (! (controller instanceof AbortController)) { return false; } else if (typeof reason === 'string') { controller.abort(new Error(reason)); return true; } else { controller.abort(reason); return true; } } /** * Gets and `AbortSignal` from the registry * * @param {string} key The registered key for the signal * @returns {AbortSignal|void} The corresponding `AbortSignal`, if any */ const getSignal = key => signalRegistry.get(key); /** * Add listeners to an element and its children, matching a generated query based on registered attributes * * @param {Element|Document} target Root node to add listeners from * @param {object} options * @param {AbortSignal} [options.signal] Optional signal to remove event listeners * @returns {Element|Document} Returns the passed target node */ function attachListeners(target, { signal } = {}) { const nodes = target instanceof Element && target.matches(selector.get()) ? [target, ...target.querySelectorAll(selector.get())] : target.querySelectorAll(selector.get()); nodes.forEach(el => _addListeners(el, { signal })); return target; } const isModule = ! (document.currentScript instanceof HTMLScriptElement); const SUPPORTS_IMPORTMAP = HTMLScriptElement.supports('importmap'); const ROUTES_REGISTRY = new Map(); const NO_BODY_METHODS = ['GET', 'HEAD', 'DELETE', 'OPTIONS']; const DESC_SELECTOR = 'meta[name="description"], meta[itemprop="description"], meta[property="og:description"], meta[name="twitter:description"]'; const navObserver = new MutationObserver(entries => entries.forEach(entry => interceptNav(entry.target))); const preloadObserver = new MutationObserver(entries => entries.forEach(_handlePreloadMutations)); const ROOT_ID = 'root'; const EVENT_TARGET = document; const NAV_CLOSE_SYMBOL = Symbol.for('aegis:navigate:event:close'); const prefersReducedMotion = matchMedia('(prefers-reduced-motion: reduce)'); let rootEl = document.getElementById(ROOT_ID) ?? document.body; let rootSelector = '#' + ROOT_ID; const SUPPORTS_TRUSTED_TYPES = 'trustedTypes' in globalThis; const _isTrustedHTML = input => SUPPORTS_TRUSTED_TYPES && trustedTypes.isHTML(input); const startViewTransition = typeof document.startViewTransition === 'function' ? (update, types) => document.startViewTransition({ update, types }) : update => Promise.try(update); function _handlePreloadMutations(target) { if (target instanceof MutationRecord) { _handlePreloadMutations(target.target); } else if (target.tagName === 'A' && ! target.classList.contains('no-router') && ! target.hasAttribute(onClick)) { preloadOnHover(target, target.dataset); } else { target.querySelectorAll(`a:not(.no-router, [${onClick}])`).forEach(a => preloadOnHover(a, a.dataset)); } } const NAV_EVENT = 'aegis:navigate'; const EVENT_TYPES = { navigate: 'aegis:router:navigate', back: 'aegis:router:back', forward: 'aegis:router:forward', reload: 'aegis:router:reload', pop: 'aegis:router:pop', go: 'aegis:router:go', load: 'aegis:router:load', submit: 'aegis:router:submit', }; const DEFAULT_REASONS = [EVENT_TYPES.back, EVENT_TYPES.forward, EVENT_TYPES.navigate, EVENT_TYPES.submit, EVENT_TYPES.reload, EVENT_TYPES.go]; class AegisNavigationEvent extends CustomEvent { #reason; #url; #stack = new AsyncDisposableStack(); #controller = new AbortController(); #promises = []; #errors = []; constructor(name = NAV_EVENT, reason = 'unknown', { bubbles = false, cancelable = true, detail = { oldState: state.getStateObj(), oldURL: new URL(location.href), } } = {}) { super(name, { bubbles, cancelable, detail }); this.#reason = reason; this.#url = location.href; } get aborted() { return this.#controller.signal.aborted; } get disposed() { return this.#stack.disposed; } get error() { switch(this.#errors.length) { case 0: return null; case 1: return this.#errors[0]; default: return new AggregateError(this.#errors); } } get reason() { return this.#reason; } get signal() { return this.#controller.signal; } get stack() { return this.#stack; } get url() { return this.#url; } async [NAV_CLOSE_SYMBOL]() { const result = await Promise.allSettled(this.#promises).then(results => { this.#errors.push(...results.filter(result => result.status === 'rejected').map(result => result.reason)); return this.cancelable && this.defaultPrevented; }); this.#controller.abort(); return result; } adopt(obj, callback) { return this.#stack.adopt(obj, callback); } abort(reason) { this.#controller.abort(reason); } defer(callback) { this.#stack.defer(callback); } async disposeAsync() { await this[Symbol.asyncDispose](); } use(obj) { return this.#stack.use(obj); } waitUntil(promiseOrCallback, { signal } = {}) { const { promise, resolve, reject } = Promise.withResolvers(); this.#promises.push(promise); if (signal instanceof AbortSignal && ! signal.aborted) { signal.addEventListener('abort', ({ target }) =>{ reject(target.reason); if (this.cancelable && ! this.defaultPrevented) { super.preventDefault(); } }, { once: true, signal: this.#controller.signal, }); } if (this.#controller.signal.aborted) { reject(this.#controller.signal.reason); } else if (signal instanceof AbortSignal && signal.aborted) { reject(signal.reason); if (this.cancelable && ! this.defaultPrevented) { super.preventDefault(); } } else if (! this.defaultPrevented && promiseOrCallback instanceof Function) { Promise.try(() => promiseOrCallback(this, { signal: signal instanceof AbortSignal ? AbortSignal.any([signal, this.#controller.signal]) : this.#controller.signal, timestamp: performance.now(), stack: this.#stack, })).then(resolve, reject); } else if (! this.defaultPrevented && promiseOrCallback instanceof Promise) { promiseOrCallback.then(resolve, reject); } } [Symbol.toStringTag]() { return 'NavigationEvent'; } async [Symbol.asyncDispose]() { if (! this.#controller.signal.aborted) { this.#controller.abort(new DOMException('The stack of the event was disposed.', 'AbortError')); } if (! this.#stack.disposed) { await this.#stack.disposeAsync(); } } static get defaultType() { return NAV_EVENT; } static get reasons() { return EVENT_TYPES; } } // Need this to be "unsafe" to not be restrictive on what modifications can be made to a page const policy = SUPPORTS_TRUSTED_TYPES ? trustedTypes.createPolicy('aegis-router#html', { createHTML: input => input }) : Object.freeze({ createPolicy: input => input }); async function _popstateHandler(event) { const diff = state.diffState(event.state ?? {}); const navigate = new AegisNavigationEvent(NAV_EVENT, EVENT_TYPES.pop, { detail: { newState: event.state, oldState: null, oldURL: new URL(location.href), method: 'GET', formData: null }, }); try { EVENT_TARGET.dispatchEvent(navigate); if (! await navigate[NAV_CLOSE_SYMBOL]()) { const old = history.scrollRestoration; const [content] = await Promise.all([ getModule(new URL(location.href)), state.notifyStateChange(diff), ]); history.scrollRestoration = 'auto'; _updatePage(content); history.scrollRestoration = old; } } finally { requestAnimationFrame(navigate[Symbol.asyncDispose].bind(navigate)); } } function _addStyle(sheet) { if (sheet instanceof CSSStyleSheet && ! document.adoptedStyleSheets.includes(sheet)) { document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet]; } else if (Array.isArray(sheet) && sheet.length !== 0) { document.adoptedStyleSheets = [ ...document.adoptedStyleSheets, ...sheet.filter(s => s instanceof CSSStyleSheet && ! document.adoptedStyleSheets.includes(s)) ]; } } function _createMeta(props = {}) { const meta = document.createElement('meta'); Object.entries(props).forEach(([key, val]) => meta.setAttribute(key, val)); return meta; } function _loadLink(href, { relList = [], crossOrigin = 'anonymous', referrerPolicy = 'no-referrer', fetchPriority = 'auto', signal: passedSignal, as, integrity, media, type, } = {}) { const { promise, resolve, reject } = Promise.withResolvers(); const link = document.createElement('link'); if (passedSignal instanceof AbortSignal && passedSignal.aborted) { reject(passedSignal.reason); } else { link.relList.add(...relList); if (typeof fetchPriority === 'string') { link.fetchPriority = fetchPriority; } if (typeof crossOrigin === 'string') { link.crossOrigin = crossOrigin; } if (typeof type === 'string') { link.type = type; } if (typeof media === 'string') { link.media = media; } else if (media instanceof MediaQueryList) { link.media = media.media; } if (typeof as === 'string') { link.as = as; } if (typeof integrity === 'string') { link.integrity = integrity; } if (link.relList.contains('preload') || link.relList.contains('modulepreload')) { const controller = new AbortController(); const signal = passedSignal instanceof AbortSignal ? AbortSignal.any([controller.signal, passedSignal]) : controller.signal; if (passedSignal instanceof AbortSignal) { passedSignal.addEventListener('abort', ({ target }) => { reject(target.reason); }, { signal: controller.signal, once: true }); } link.referrerPolicy = referrerPolicy; link.addEventListener('load', () => { resolve(); controller.abort(); }, { signal }); link.addEventListener('error', () => { reject(new DOMException(`Error loading ${href}`, 'NotFoundError')); controller.abort(); }, { signal }); link.href = _resolveModule(href); document.head.append(link); return promise.then(() => link.remove()).catch(err => { if (link.isConnected) { link.remove(); } reportError(err); }); } else { link.href = href; document.head.append(link); resolve(); return promise; } } } function _isModuleURL(src) { switch(src[0]) { case '/': case '.': return true; case 'h': return src.substring(0, '4') === 'http' && URL.canParse(src); default: return false; } } function _resolveModule(src) { if (_isModuleURL(src)) { return URL.parse(src, document.baseURI); } else if (! SUPPORTS_IMPORTMAP) { throw new TypeError('Importmaps and module specifiers are not supported'); } else if (! isModule) { throw new TypeError('Cannot resolve a module specifier outside of a module script.'); } else { return undefined(src); } } function _getLinkStateData(a) { const entries = Object.entries(a.dataset) .filter(([name]) => name.startsWith('aegisState')) .map(([name, value]) => [name[10].toLowerCase() + name.substring(11), value]); return Object.fromEntries(entries); } function _interceptLinkClick(event) { if (event.target.classList.contains('no-router') || event.target.hasAttribute(onClick)) { event.target.removeEventListener(_interceptLinkClick); } else if ( event.isTrusted && event.currentTarget.href.startsWith(location.origin) && ! (event.metaKey || event.ctrlKey || event.shiftKey) ) { event.preventDefault(); const state = _getLinkStateData(event.currentTarget); navigate(event.currentTarget.href, state, { integrity: event.currentTarget.dataset.integrity, cache: event.currentTarget.dataset.cache, referrerPolicy: event.currentTarget.dataset.referrerPolicy, }); } } async function _interceptFormSubmit(event) { if (event.target.classList.contains('no-router') || event.target.hasAttribute(onSubmit)) { event.target.removeEventListener('submit', _interceptFormSubmit); } else if (event.isTrusted && event.target.action.startsWith(location.origin)) { event.preventDefault(); const { target, submitter } = event; const { method, action } = target; const formData = new FormData(target); const submit = new AegisNavigationEvent(NAV_EVENT, EVENT_TYPES.submit, { detail: { oldState: state.getStateObj(), oldURL: new URL(location.href), formData }, }); try { if (submitter instanceof HTMLButtonElement) { submitter.disabled = true; } EVENT_TARGET.dispatchEvent(submit); if (await submit[NAV_CLOSE_SYMBOL]()) { return; } else if (NO_BODY_METHODS.includes(method.toUpperCase())) { const url = new URL(action); const params = new URLSearchParams(formData); for (const [key, val] of params.entries()) { url.searchParams.append(key, val); } await navigate(url, state.getStateObj(), { method }); } else { await navigate(action, state.getStateObj(), { method, formData }); } } finally { if (submitter instanceof HTMLButtonElement) { submitter.disabled = false; } requestAnimationFrame(submit[Symbol.asyncDispose].bind(submit)); } } } async function _getHTML(url, { signal, method = 'GET', body, integrity, cache = 'default', referrerPolicy = 'no-referrer' } = {}) { const resp = await fetch(url, { method, body: NO_BODY_METHODS.includes(method.toUpperCase()) ? null : body, headers: { 'Accept': 'text/html' }, cache, referrerPolicy, integrity, signal, }).catch(err => err); if (resp.ok) { const html = await resp.text(); return Document.parseHTMLUnsafe(policy.createHTML(html)); } else if (resp instanceof Error) { return resp; } else { return _get404(url, method, { signal }); } } async function _updatePage(content) { const timestamp = performance.now(); await startViewTransition(() => { if (content instanceof Document) { if (content.head.childElementCount !== 0) { setTitle(content.title); setDescription(content.querySelector(DESC_SELECTOR)?.content); } const contentEl = typeof rootSelector === 'string' ? content.body.querySelector(rootSelector) ?? content.body : content.body; rootEl.replaceChildren(...contentEl.childNodes); } else if (content instanceof HTMLTemplateElement) { rootEl.replaceChildren(content.content); } else if (content instanceof Function && content.prototype instanceof HTMLElement) { rootEl.replaceChildren(new content({ state: state.getStateObj(), url: new URL(location.href), timestamp })); } else if (content instanceof Node) { rootEl.replaceChildren(content); } else if (content instanceof Function) { _updatePage(content()); } else if (typeof content === 'string') { rootEl.setHTMLUnsafe(policy.createHTML(content)); } else if (_isTrustedHTML(content)) { rootEl.setHTMLUnsafe(content); } else if (content instanceof Error) { reportError(content); rootEl.textContent = content.message; } else if (content instanceof URL) { navigate(content); } else if (! (content === null || typeof content === 'undefined')) { rootEl.textContent = content; } }); const ev = new AegisNavigationEvent(NAV_EVENT, EVENT_TYPES.load, { cancelable: false }); Promise.try(() => EVENT_TARGET.dispatchEvent(ev)).finally(ev[Symbol.asyncDispose].bind(ev)); if (history.scrollRestoration === 'manual') { if (location.hash.length > 1) { const target = document.getElementById(location.hash.substring(1)) ?? document.body; target.scrollIntoView({ behavior: prefersReducedMotion.matches ? 'instant' : 'smooth' }); } else { const autofocus = rootEl.querySelector('[autofocus]'); if (autofocus instanceof Element) { autofocus.focus(); } else { document.body.scrollIntoView({ behavior: prefersReducedMotion.matches ? 'instant' : 'smooth' }); } } } } async function _handleMetadata({ title, description } = {}, { state, matches, params, url, signal } = {}) { if (typeof title === 'string') { setTitle(title); } else if (typeof title === 'function') { setTitle(await title({ state, matches, params, url, signal })); } if (typeof description === 'string') { setDescription(description); } else if (typeof description === 'function') { setDescription(await description({ state, matches, params, url, signal })); } } async function _handleModule(moduleSrc, { state: state$1 = state.getStateObj(), matches = {}, params = {}, stack, signal, ...args } = {}) { const module = await Promise.try(() => { if (moduleSrc instanceof Function) { return moduleSrc(args); } else if (typeof moduleSrc === 'string' || module instanceof URL) { return _isModuleURL(moduleSrc) ? import(URL.parse(moduleSrc, document.baseURI)) : import(moduleSrc); } else { return new TypeError('Invalid module src.'); } }).catch(err => err); const url = new URL(location.href); const timestamp = performance.now(); if (module instanceof URL) { await navigate(module, state$1, args); } else if (module instanceof Error) { return module.message; } else if (! ('default' in module)) { return new Error(`${moduleSrc} has no default export.`); } else if (module.default instanceof Function && module.default.prototype instanceof HTMLElement) { if (typeof customElements.getName(module.default) !== 'string') { customElements.define( module.default[Symbol.for('tagName')] ?? `aegis-el-${crypto.randomUUID()}`, module.default ); } if (typeof module.styles !== 'undefined') { _addStyle(module.styles); } _handleMetadata(module, { state: state$1, matches, params, url, signal }); return new module.default({ url, matches, params, state: state$1, stack, timestamp, signal: getNavSignal({ signal }), ...args }); } else if (module.default instanceof Function) { if (typeof module.styles !== 'undefined') { _addStyle(module.styles); } _handleMetadata(module, { state: state$1, matches, params, url, signal }); return await module.default({ url, matches, params, state: state$1, stack, timestamp, signal: getNavSignal({ signal }), ...args }); } else if (module.default instanceof Node || module.default instanceof Error) { if (typeof module.styles !== 'undefined') { _addStyle(module.styles); } _handleMetadata(module, { state: state$1, matches, params, url, signal }); _updatePage(module.default); } else if (module.default instanceof URL && module.default.origin === location.origin) { navigate(module.default); } else { throw new TypeError(`${moduleSrc} has a missing or invalid default export.`); } } let view404 = ({ url = location