UNPKG

@aegisjsproject/router

Version:
1,600 lines (1,432 loc) 69.6 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 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', abortController: 'aegis:ui:controller:abort', }, }; 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.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()], ]); /** * Check if a callback is registered * * @param {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); /** * Get a callback from the registry by name/key * * @param {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); 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 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 = [ onAbort, onBlur, onFocus, onCancel, onAuxclick, onBeforeinput, onBeforetoggle, onCanplay, onCanplaythrough, onChange, onClick, onClose, 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, ]; let selector = eventAttrs.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, 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) ? [target, ...target.querySelectorAll(selector)] : target.querySelectorAll(selector); 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); 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; #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 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 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; } abort(reason) { this.#controller.abort(reason); } 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() })).then(resolve, reject); } else if (! this.defaultPrevented && promiseOrCallback instanceof Promise) { promiseOrCallback.then(resolve, reject); } } [Symbol.toStringTag]() { return 'NavigationEvent'; } 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 }, }); 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; } } 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; passedSignal.addEventListener('abort', ({ target }) => { reject(target.reason); }, { signal: controller.signal }); 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.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 { method, action } = event.target; const formData = new FormData(event.target); const submit = new AegisNavigationEvent(NAV_EVENT, EVENT_TYPES.submit, { detail: { oldState: state.getStateObj(), oldURL: new URL(location.href), formData }, }); 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 }); } } } 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 }); } } function _updatePage(content) { const timestamp = performance.now(); 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 === null || typeof content === 'undefined')) { rootEl.textContent = content; } EVENT_TARGET.dispatchEvent(new AegisNavigationEvent(NAV_EVENT, EVENT_TYPES.load, { cancelable: false })); 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 = {}, 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 ); } _handleMetadata(module, { state: state$1, matches, params, url, signal }); return new module.default({ url, matches, params, state: state$1, timestamp, signal: getNavSignal({ signal }), ...args }); } else if (module.default instanceof Function) { _handleMetadata(module, { state: state$1, matches, params, url, signal }); return await module.default({ url, matches, params, state: state$1, timestamp, signal: getNavSignal({ signal }), ...args }); } else if (module.default instanceof Node || module.default instanceof Error) { _handleMetadata(module, { state: state$1, matches, params, url, signal }); _updatePage(module.default); } else { throw new TypeError(`${moduleSrc} has a missing or invalid default export.`); } } let view404 = ({ url = location, method = 'GET' }) => { const div = document.createElement('div'); const p = document.createElement('p'); const a = document.createElement('a'); p.textContent = `${method.toUpperCase()} ${url.href} [404 Not Found]`; a.href = document.baseURI; a.textContent = 'Go Home'; a.addEventListener('click', _interceptLinkClick); div.append(p, a); return div; }; async function _get404(url = location, method = 'GET', { signal, formData, integrity } = {}) { const timestamp = performance.now(); if (typeof view404 === 'string') { return await _handleModule(view404, { url, matches: null, signal, method, formData, timestamp, integrity }); } else if (view404 instanceof Function) { _updatePage(view404({ timestamp, state: state.getStateObj(), url, matches: null, signal, method, formData, integrity })); } } /** * Finds the matching URL pattern for a given input. * * @param {string|URL} input - The input URL or path. * @returns {URLPattern|undefined} - The matching URL pattern, or undefined if no match is found. */ const findPath = input => ROUTES_REGISTRY.keys().find(pattern => pattern.test(input)); /** * Sets the 404 handler. * * @param {string} path - The path to the 404 handler module or the handler function itself. */ const set404 = path => view404 = path; /** * Intercepts navigation events within a target element. * * @param {HTMLElement|ShadowRoot|string} target - The element to intercept navigation events on. Defaults to document.body. * @param {Object} [options] - Optional options. * @param {AbortSignal} [options.signal] - An AbortSignal to cancel the interception. */ function interceptNav(target = document.body, { signal } = {}) { if (typeof target === 'string') { interceptNav(document.querySelector(target), { signal }); } else if (! (target instanceof HTMLElement || target instanceof ShadowRoot)) { throw new TypeError('Cannot intercept navigation on a non-Element. Element or selector is required.'); } else if (target instanceof HTMLAnchorElement && ! target.classList.contains('no-router') && ! target.hasAttribute(onClick) && target.href.startsWith(location.origin)) { target.addEventListener('click', _interceptLinkClick, { signal, passive: false }); } else if (target instanceof HTMLFormElement && ! target.classList.contains('no-router') && ! target.hasAttribute(onSubmit) && target.action.startsWith(location.origin)) { target.addEventListener('submit', _interceptFormSubmit, { signal, passive: false }); target.querySelectorAll(`a[href]:not([rel~="external"], [download], .no-router, [${onClick}])`).forEach(el => { if (el.href.startsWith(location.origin)) { el.addEventListener('click', _interceptLinkClick, { passive: false, signal }); } }); } else { target.querySelectorAll(`a[href]:not([rel~="external"], [download], .no-router, [${onClick}])`).forEach(el => { if (el.href.startsWith(location.origin)) { el.addEventListener('click', _interceptLinkClick, { passive: false, signal }); } }); target.querySelectorAll(`form:not(.no-router, [${onSubmit}])`).forEach(el => { el.addEventListener('submit', _interceptFormSubmit, { passive: false, signal }); }); } } /** * Sets the root element for the navigation system. * * @param {HTMLElement|string} target - The element to set as the root. */ function setRoot(target, selector) { if (target instanceof HTMLElement) { rootEl = target; rootSelector = typeof selector === 'string' ? selector : target.hasAttribute('id') ? `#${target.id}` : null; if (typeof rootEl.ariaLive !== 'string') { rootEl.ariaLive = 'assertive'; } } else if (typeof target === 'string') { setRoot(document.querySelector(target), target); } else { throw new TypeError('Cannot set root to a non-html element.'); } } /** * Observes links on an element for navigation. * * @param {HTMLElement|ShadowRoot|string} target - The element to observe links on. Defaults to document.body. * @param {object} [options] - Optional options. * @param {AbortSignal} [options.signal] - An AbortSignal to cancel the observation. */ function observeLinksOn(target = document.body, { signal } = {}) { if (signal instanceof AbortSignal && signal.aborted) { throw signal.reason; } else if (typeof target === 'string') { observeLinksOn(document.querySelector(target), { signal }); } else if (target instanceof HTMLElement || target instanceof ShadowRoot) { interceptNav(target, { signal }); navObserver.observe(target, { childList: true, subtree: true }); if (signal instanceof AbortSignal) { signal.addEventListener('abort', () => navObserver.disconnect(), { once: true }); } } else { throw new TypeError('Cannot observe link on a non-Element. Requires an Element or selector.'); } } /** * Creates a URLPattern object from the given path and base URL. * * @param {string|URL|URLPattern} path - The path to create the pattern from. * @param {string} [baseURL=location.origin] - The base URL to use for relative paths. Defaults to the current origin. * @returns {URLPattern|null} - The created URLPattern object, or `null` if the input is invalid. */ function getURLPattern(path, baseURL = location.origin) { if (path instanceof URLPattern) { return path; } else if (typeof path === 'string') { return new URLPattern(path, baseURL); } else if (path instanceof URL) { return new URLPattern(path.href); } else { return null; } } /** * Extracts a specific parameter value from a URL path. * * @param {string|URL|URLPattern} path - The path to extract the parameter from. * @param {string} param - The name of the parameter to extract. * @param {object} [options] - Optional options. * - `fallbackValue` {string} - The default value to return if the parameter is not found. * - `baseURL` {string} - The base URL to use for relative paths. * @returns {object} - An object with a `toString()` method to retrieve the parameter value as a string, and a `[Symbol.toPrimitive]()` method to convert it to a number or string. */ function getURLPath(path, param, { fallbackValue = '', baseURL = location.origin, } = {}) { const pattern = getURLPattern(path, baseURL); return Object.freeze({ toString() { return pattern.exec(location.href)?.pathname.groups?.[param] ?? fallbackValue; }, [Symbol.toPrimitive](hint = 'default') { return hint === 'number' ? parseFloat(this.toString()) : this.toString(); } }); } /** * Registers a URL pattern with its corresponding module source. * * @param {URLPattern|string|URL} path - The URL pattern or URL to register. * @param {string|URL|Function} moduleSrc - The module source URL/specifier or a function. */ async function registerPath(path, moduleSrc, { preload = false, signal, baseURL = location.origin, crossOrigin = 'anonymous', referrerPolicy = 'no-referrer', } = {}) { if (signal instanceof AbortSignal && signal.aborted) { throw signal.reason; } else if (typeof path === 'string') { await registerPath(new URLPattern(path, baseURL), moduleSrc, { preload, signal, crossOrigin, referrerPolicy }); } else if (path instanceof URL) { await registerPath(new URLPattern(path.href), moduleSrc, { preload, baseURL, signal, crossOrigin, referrerPolicy }); } else if (! (typeof moduleSrc === 'string' || moduleSrc instanceof Function || moduleSrc instanceof URL)) { throw new TypeError('Module source/handler must be a module specifier/url or handler function.'); } else if (path instanceof URLPattern) { ROUTES_REGISTRY.set(path, moduleSrc); if (preload && (typeof moduleSrc === 'string' || moduleSrc instanceof URL)) { await preloadModule(moduleSrc, { signal, crossOrigin, referrerPolicy }); } if (signal instanceof AbortSignal) { signal.addEventListener('abort', clearPaths, { once: true }); } } else { throw new TypeError(`Could not convert ${path} to a URLPattern.`); } } /** * Clears all registered paths */ function clearPaths() { ROUTES_REGISTRY.clear(); } /** * Fetches a module or retrieves its content based on a URL or path. * * @param {URL|string|null} input - The URL, path, or null to throw an error. Defaults to `location`. * @param {object} [options] - Optional options. * @param {AbortSignal} [options.signal] - An AbortSignal to cancel the fetch. * @param {string} [options.method] - The HTTP method to use for fetching the module. Defaults to 'GET'. * @param {FormData} [options.formData] - The form data to send with the request. Defaults to a new FormData object. * @returns {Promise<string|void>} - A promise that resolves with the module content or triggers navigation if a path match is found. * @throws {Error} - Throws an error if the input is null or cannot be parsed as a URL. */ async function getModule(input = location, { method = 'GET', state: state$1 = state.getStateObj(), formData = new FormData(), cache = 'default', referrerPolicy = 'no-referrer', integrity, signal, } = {}) { const timestamp = performance.now(); if (input === null) { throw new Error('Invalid path.'); } else if (! (input instanceof URL)) { return await getModule(URL.parse(input, document.baseURI), { signal, method, formData, state: state$1, integrity, cache, referrerPolicy }); } else { const match = findPath(input); if (! (match instanceof URLPattern)) { return await _getHTML(input, { method, signal: getNavSignal({ signal }), body: formData, integrity, cache, referrerPolicy }); } else { const handler = ROUTES_REGISTRY.get(match); const matches = match.exec(input); const params = typeof matches === 'object' ? { ...matches.protocol.groups, ...matches.username.groups, ...matches.password.groups, ...matches.hostname.groups, ...matches.port.groups, ...matches.pathname.groups, ...matches.search.groups, ...matches.hash.groups, } : {}; delete params['0']; return await _handleModule(handler, { url: input, matches, params, state: state$1, method, formData, integrity, timestamp, }); } } } /** * Navigates to a new URL. * * @param {string|URL} url - The URL to navigate to. * @param {object} [newState] - The new state object to push to the history. * @param {object} [options] - Optional options. * @param {AbortSignal} [options.signal] - An AbortSignal to cancel the navigation. * @param {string} [options.method="GET"] - The HTTP method to use for the navigation. * @param {FormData} [options.formData] - The form data to send with the request. * @returns {Promise<any>} - A promise that resolves with the new content or `null` if navigation is cancelled. */ async function navigate(url, newState = state.getStateObj(), { signal, method = 'GET', cache = 'default', referrerPolicy = 'no-referrer', formData, integrity, scrollRestoration = null, } = {}) { if (url === null) { throw new TypeError('URL cannot be null.'); } else if (signal instanceof AbortSignal && signal.aborted) { throw signal.reason; } else if (! (url instanceof URL)) { return await navigate(URL.parse(url, document.baseURI), newState, { signal, method, cache, referrerPolicy, formData, integrity }); } else if (formData instanceof FormData && NO_BODY_METHODS.includes(method.toUpperCase())) { const params = new URLSearchParams(formData); for (const [key, val] of params) { url.searchParams.append(key, val); } return await navigate(url, newState, { signal, method, cache, referrerPolicy, integrity }); } else if (url.href !== location.href) { try { const oldState = state.getStateObj(); const diff = state.diffState(newState, oldState); const navigate = new AegisNavigationEvent(NAV_EVENT, EVENT_TYPES.navigate, { detail: { newState, oldState, oldURL: new URL(location.href), newURL: url, method, formData }, }); EVENT_TARGET.dispatchEvent(navigate); if (! await navigate[NAV_CLOSE_SYMBOL]()) { if (typeof scrollRestoration === 'string') { history.scrollRestoration = scrollRestoration; } history.pushState(newState, '', url); const content = await getModule(url, { signal, method, cache, referrerPolicy, formData, state: newState, integrity }); await state.notifyStateChange(diff); _updatePage(content); return content; } else { return null; } } catch(err) { back(); reportError(err); } } } /** * Navigates back in the history. */ async function back({ signal } = {}) { const event = new AegisNavigationEvent(NAV_EVENT, EVENT_TYPES.back); EVENT_TARGET.dispatchEvent(event); await event[NAV_CLOSE_SYMBOL]().then(async prevented => { if (! prevented) { history.back(); await whenNavigated({ signal, reasons: [EVENT_TYPES.load] }); } }); } /** * Navigates forward in the history. */ async function forward({ signal } = {}) { const event = new AegisNavigationEvent(NAV_EVENT, EVENT_TYPES.forward); EVENT_TARGET.dispatchEvent(event); await event[NAV_CLOSE_SYMBOL]().then(async prevented => { if (! prevented) { history.forward(); await whenNavigated({ signal, reasons: [EVENT_TYPES.load] }); } }); } /** * Navigates to a specific history entry. * * @param {number} [delta=0] - The number of entries to go back or forward. 0 to reload. */ async function go(delta = 0, { signal } = {}) { const event = new AegisNavigationEvent(NAV_EVENT, EVENT_TYPES.go); EVENT_TARGET.dispatchEvent(event); await event[NAV_CLOSE_SYMBOL]().then(async prevented => { if (! prevented) { history.go(delta); await whenNavigated({ signal, reasons: [EVENT_TYPES.load] }); } }); } /** * Reloads the current page. */ function reload() { const event = new AegisNavigationEvent(NAV_EVENT, EVENT_TYPES.reload); EVENT_TARGET.dispatchEvent(event); event[NAV_CLOSE_SYMBOL]().then(prevented => { if (! prevented) { history.go(0); } }); } /** * Adds a popstate listener to the window. * * @param {object} [options] - Optional options. * @param {AbortSignal} [options.signal] - An AbortSignal to cancel the listener. */ function addPopstateListener({ signal } = {}) { globalThis.addEventListener('popstate', _popstateHandler, { signal }); } /** * Removes a popstate listener to the window. */ function removeListener() { globalThis.removeEventListener('popstate', _popstateHandler); } /** * Set default scroll restoration behavior on history navigation. * * @param {string} value (auto or manual) */ function setScrollRestoration(value = 'auto') { history.scrollRestoration = value; } /** * Get the current value of scroll restoration * * @returns string */ function getScrollRestoration() { return history.scrollRestoration; } /** * Sets the page title * * @param {string} title New title for page */ function setTitle(title) { if (typeof title === 'string') { document.title = title; } } /** * Setts the page description * * @param {string} description New description for page */ function setDescription(description) { if (typeof description === 'string' && description.length !== 0) { const descs = document.head.querySelectorAll(DESC_SELECTOR); descs.forEach(meta => meta.remove()); document.head.append( _createMeta({ name: 'description', content: description }), _createMeta({ itemprop: 'description', content: description }), _createMeta({ property: 'og:description', content: description }), _createMeta({ name: 'twitter:description', content: description }), ); } } /** * Initializes the navigation system/router. * * @param {object|string|HTMLScriptElement} routes - An object mapping URL patterns to module source URLs or specifiers, or a script/id to script * @param {object} [options] - Optional options. * @param {boolean} [options.preload=false] - Whether to preload all modules. * @param {boolean} [options.observePreloads=false] - If true, modules will be preloaded on link hover * @param {HTMLElement|ShadowRoot|string} [options.inteceptRoot] - The element to intercept link clicks on. * @param {string} [options.baseURL] - The base URL for URL patterns. * @param {string} [options.notFound] - The 404 handler. * @param {HTMLElement|ShadowRoot|string} [options.rootNode] - The root element for the navigation system. * @param {object} [options.transition] - Config for optional animations on navigation events * @param {Keyframe} [options.transition.keyframes] - Keyframes for an animation during transitions/navigation * @param {KeyframeAnimationOptions} [options.transition.options] - Options such as duration and easing for navigation animations