@aegisjsproject/router
Version:
A simple but powerful router module
1,600 lines (1,432 loc) • 69.6 kB
JavaScript
'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