From efbc2ffe6a1ee5ec38a4396305561d6515d0943e Mon Sep 17 00:00:00 2001 From: littleblack111 Date: Fri, 30 Aug 2024 17:21:18 +0800 Subject: node fixes, updates --- node_modules/swup/src/Swup.ts | 371 ++++++++++++++ node_modules/swup/src/config/version.ts | 12 + node_modules/swup/src/helpers.ts | 9 + node_modules/swup/src/helpers/Location.ts | 38 ++ node_modules/swup/src/helpers/classify.ts | 12 + node_modules/swup/src/helpers/delegateEvent.ts | 27 + node_modules/swup/src/helpers/getCurrentUrl.ts | 4 + node_modules/swup/src/helpers/history.ts | 37 ++ node_modules/swup/src/helpers/matchPath.ts | 23 + node_modules/swup/src/index.ts | 58 +++ node_modules/swup/src/modules/Cache.ts | 87 ++++ node_modules/swup/src/modules/Classes.ts | 54 ++ node_modules/swup/src/modules/Hooks.ts | 573 ++++++++++++++++++++++ node_modules/swup/src/modules/Visit.ts | 170 +++++++ node_modules/swup/src/modules/animatePageIn.ts | 32 ++ node_modules/swup/src/modules/animatePageOut.ts | 19 + node_modules/swup/src/modules/awaitAnimations.ts | 148 ++++++ node_modules/swup/src/modules/fetchPage.ts | 114 +++++ node_modules/swup/src/modules/getAnchorElement.ts | 32 ++ node_modules/swup/src/modules/navigate.ts | 234 +++++++++ node_modules/swup/src/modules/plugins.ts | 77 +++ node_modules/swup/src/modules/renderPage.ts | 51 ++ node_modules/swup/src/modules/replaceContent.ts | 51 ++ node_modules/swup/src/modules/resolveUrl.ts | 33 ++ node_modules/swup/src/modules/scrollToContent.ts | 38 ++ node_modules/swup/src/utils.ts | 4 + node_modules/swup/src/utils/index.ts | 68 +++ 27 files changed, 2376 insertions(+) create mode 100644 node_modules/swup/src/Swup.ts create mode 100644 node_modules/swup/src/config/version.ts create mode 100644 node_modules/swup/src/helpers.ts create mode 100755 node_modules/swup/src/helpers/Location.ts create mode 100755 node_modules/swup/src/helpers/classify.ts create mode 100755 node_modules/swup/src/helpers/delegateEvent.ts create mode 100755 node_modules/swup/src/helpers/getCurrentUrl.ts create mode 100755 node_modules/swup/src/helpers/history.ts create mode 100644 node_modules/swup/src/helpers/matchPath.ts create mode 100644 node_modules/swup/src/index.ts create mode 100755 node_modules/swup/src/modules/Cache.ts create mode 100644 node_modules/swup/src/modules/Classes.ts create mode 100644 node_modules/swup/src/modules/Hooks.ts create mode 100644 node_modules/swup/src/modules/Visit.ts create mode 100755 node_modules/swup/src/modules/animatePageIn.ts create mode 100755 node_modules/swup/src/modules/animatePageOut.ts create mode 100644 node_modules/swup/src/modules/awaitAnimations.ts create mode 100644 node_modules/swup/src/modules/fetchPage.ts create mode 100755 node_modules/swup/src/modules/getAnchorElement.ts create mode 100755 node_modules/swup/src/modules/navigate.ts create mode 100644 node_modules/swup/src/modules/plugins.ts create mode 100755 node_modules/swup/src/modules/renderPage.ts create mode 100755 node_modules/swup/src/modules/replaceContent.ts create mode 100755 node_modules/swup/src/modules/resolveUrl.ts create mode 100755 node_modules/swup/src/modules/scrollToContent.ts create mode 100644 node_modules/swup/src/utils.ts create mode 100644 node_modules/swup/src/utils/index.ts (limited to 'node_modules/swup/src') diff --git a/node_modules/swup/src/Swup.ts b/node_modules/swup/src/Swup.ts new file mode 100644 index 00000000..8d5d769e --- /dev/null +++ b/node_modules/swup/src/Swup.ts @@ -0,0 +1,371 @@ +import { type DelegateEvent } from 'delegate-it'; + +import version from './config/version.js'; + +import { delegateEvent, getCurrentUrl, Location, updateHistoryRecord } from './helpers.js'; +import { type DelegateEventUnsubscribe } from './helpers/delegateEvent.js'; + +import { Cache } from './modules/Cache.js'; +import { Classes } from './modules/Classes.js'; +import { type Visit, createVisit } from './modules/Visit.js'; +import { Hooks } from './modules/Hooks.js'; +import { getAnchorElement } from './modules/getAnchorElement.js'; +import { awaitAnimations } from './modules/awaitAnimations.js'; +import { navigate, performNavigation, type NavigationToSelfAction } from './modules/navigate.js'; +import { fetchPage } from './modules/fetchPage.js'; +import { animatePageOut } from './modules/animatePageOut.js'; +import { replaceContent } from './modules/replaceContent.js'; +import { scrollToContent } from './modules/scrollToContent.js'; +import { animatePageIn } from './modules/animatePageIn.js'; +import { renderPage } from './modules/renderPage.js'; +import { use, unuse, findPlugin, type Plugin } from './modules/plugins.js'; +import { isSameResolvedUrl, resolveUrl } from './modules/resolveUrl.js'; +import { nextTick } from './utils.js'; +import { type HistoryState } from './helpers/history.js'; + +/** Options for customizing swup's behavior. */ +export type Options = { + /** Whether history visits are animated. Default: `false` */ + animateHistoryBrowsing: boolean; + /** Selector for detecting animation timing. Default: `[class*="transition-"]` */ + animationSelector: string | false; + /** Elements on which to add animation classes. Default: `html` element */ + animationScope: 'html' | 'containers'; + /** Enable in-memory page cache. Default: `true` */ + cache: boolean; + /** Content containers to be replaced on page visits. Default: `['#swup']` */ + containers: string[]; + /** Callback for ignoring visits. Receives the element and event that triggered the visit. */ + ignoreVisit: (url: string, { el, event }: { el?: Element; event?: Event }) => boolean; + /** Selector for links that trigger visits. Default: `'a[href]'` */ + linkSelector: string; + /** How swup handles links to the same page. Default: `scroll` */ + linkToSelf: NavigationToSelfAction; + /** Enable native animations using the View Transitions API. */ + native: boolean; + /** Plugins to register on startup. */ + plugins: Plugin[]; + /** Custom headers sent along with fetch requests. */ + requestHeaders: Record; + /** Rewrite URLs before loading them. */ + resolveUrl: (url: string) => string; + /** Callback for telling swup to ignore certain popstate events. */ + skipPopStateHandling: (event: PopStateEvent) => boolean; + /** Request timeout in milliseconds. */ + timeout: number; +}; + +const defaults: Options = { + animateHistoryBrowsing: false, + animationSelector: '[class*="transition-"]', + animationScope: 'html', + cache: true, + containers: ['#swup'], + ignoreVisit: (url, { el } = {}) => !!el?.closest('[data-no-swup]'), + linkSelector: 'a[href]', + linkToSelf: 'scroll', + native: false, + plugins: [], + resolveUrl: (url) => url, + requestHeaders: { + 'X-Requested-With': 'swup', + 'Accept': 'text/html, application/xhtml+xml' + }, + skipPopStateHandling: (event) => (event.state as HistoryState)?.source !== 'swup', + timeout: 0 +}; + +/** Swup page transition library. */ +export default class Swup { + /** Library version */ + readonly version: string = version; + /** Options passed into the instance */ + options: Options; + /** Default options before merging user options */ + readonly defaults: Options = defaults; + /** Registered plugin instances */ + plugins: Plugin[] = []; + /** Data about the current visit */ + visit: Visit; + /** Cache instance */ + readonly cache: Cache; + /** Hook registry */ + readonly hooks: Hooks; + /** Animation class manager */ + readonly classes: Classes; + /** Location of the currently visible page */ + location: Location = Location.fromUrl(window.location.href); + /** URL of the currently visible page @deprecated Use swup.location.url instead */ + get currentPageUrl(): string { + return this.location.url; + } + /** Index of the current history entry */ + protected currentHistoryIndex: number; + /** Delegated event subscription handle */ + protected clickDelegate?: DelegateEventUnsubscribe; + /** Navigation status */ + protected navigating: boolean = false; + /** Run anytime a visit ends */ + protected onVisitEnd?: () => Promise; + + /** Install a plugin */ + use = use; + /** Uninstall a plugin */ + unuse = unuse; + /** Find a plugin by name or instance */ + findPlugin = findPlugin; + + /** Log a message. Has no effect unless debug plugin is installed */ + log: (message: string, context?: unknown) => void = () => {}; + + /** Navigate to a new URL */ + navigate = navigate; + /** Actually perform a navigation */ + protected performNavigation = performNavigation; + /** Create a new context for this visit */ + protected createVisit = createVisit; + /** Register a delegated event listener */ + delegateEvent = delegateEvent; + /** Fetch a page from the server */ + fetchPage = fetchPage; + /** Resolve when animations on the page finish */ + awaitAnimations = awaitAnimations; + protected renderPage = renderPage; + /** Replace the content after page load */ + replaceContent = replaceContent; + protected animatePageIn = animatePageIn; + protected animatePageOut = animatePageOut; + protected scrollToContent = scrollToContent; + /** Find the anchor element for a given hash */ + getAnchorElement = getAnchorElement; + + /** Get the current page URL */ + getCurrentUrl = getCurrentUrl; + /** Resolve a URL to its final location */ + resolveUrl = resolveUrl; + /** Check if two URLs resolve to the same location */ + protected isSameResolvedUrl = isSameResolvedUrl; + + constructor(options: Partial = {}) { + // Merge defaults and options + this.options = { ...this.defaults, ...options }; + + this.handleLinkClick = this.handleLinkClick.bind(this); + this.handlePopState = this.handlePopState.bind(this); + + this.cache = new Cache(this); + this.classes = new Classes(this); + this.hooks = new Hooks(this); + this.visit = this.createVisit({ to: '' }); + + this.currentHistoryIndex = (window.history.state as HistoryState)?.index ?? 1; + + this.enable(); + } + + /** Enable this instance, adding listeners and classnames. */ + async enable() { + // Add event listener + const { linkSelector } = this.options; + this.clickDelegate = this.delegateEvent(linkSelector, 'click', this.handleLinkClick); + + window.addEventListener('popstate', this.handlePopState); + + // Set scroll restoration to manual if animating history visits + if (this.options.animateHistoryBrowsing) { + window.history.scrollRestoration = 'manual'; + } + + // Initial save to cache + if (this.options.cache) { + // Disabled to avoid caching modified dom state: logic moved to preload plugin + // https://github.com/swup/swup/issues/475 + } + + // Sanitize/check native option + this.options.native = this.options.native && !!document.startViewTransition; + + // Mount plugins + this.options.plugins.forEach((plugin) => this.use(plugin)); + + // Create initial history record + if ((window.history.state as HistoryState)?.source !== 'swup') { + updateHistoryRecord(null, { index: this.currentHistoryIndex }); + } + + // Give consumers a chance to hook into enable + await nextTick(); + + // Trigger enable hook + await this.hooks.call('enable', undefined, undefined, () => { + const html = document.documentElement; + html.classList.add('swup-enabled'); + html.classList.toggle('swup-native', this.options.native); + }); + } + + /** Disable this instance, removing listeners and classnames. */ + async destroy() { + // remove delegated listener + this.clickDelegate!.destroy(); + + // remove popstate listener + window.removeEventListener('popstate', this.handlePopState); + + // empty cache + this.cache.clear(); + + // unmount plugins + this.options.plugins.forEach((plugin) => this.unuse(plugin)); + + // trigger disable hook + await this.hooks.call('disable', undefined, undefined, () => { + const html = document.documentElement; + html.classList.remove('swup-enabled'); + html.classList.remove('swup-native'); + }); + + // remove handlers + this.hooks.clear(); + } + + /** Determine if a visit should be ignored by swup, based on URL or trigger element. */ + shouldIgnoreVisit(href: string, { el, event }: { el?: Element; event?: Event } = {}) { + const { origin, url, hash } = Location.fromUrl(href); + + // Ignore if the new origin doesn't match the current one + if (origin !== window.location.origin) { + return true; + } + + // Ignore if the link/form would open a new window (or none at all) + if (el && this.triggerWillOpenNewWindow(el)) { + return true; + } + + // Ignore if the visit should be ignored as per user options + if (this.options.ignoreVisit(url + hash, { el, event })) { + return true; + } + + // Finally, allow the visit + return false; + } + + protected handleLinkClick(event: DelegateEvent) { + const el = event.delegateTarget as HTMLAnchorElement; + const { href, url, hash } = Location.fromElement(el); + + // Exit early if the link should be ignored + if (this.shouldIgnoreVisit(href, { el, event })) { + return; + } + + // Ignore if swup is currently navigating towards the link's URL + if (this.navigating && url === this.visit.to.url) { + event.preventDefault(); + return; + } + + const visit = this.createVisit({ to: url, hash, el, event }); + + // Exit early if control key pressed + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + this.hooks.callSync('link:newtab', visit, { href }); + return; + } + + // Exit early if other than left mouse button + if (event.button !== 0) { + return; + } + + this.hooks.callSync('link:click', visit, { el, event }, () => { + const from = visit.from.url ?? ''; + + event.preventDefault(); + + // Handle links to the same page + if (!url || url === from) { + if (hash) { + // With hash: scroll to anchor + this.hooks.callSync('link:anchor', visit, { hash }, () => { + updateHistoryRecord(url + hash); + this.scrollToContent(visit); + }); + } else { + // Without hash: scroll to top or load/reload page + this.hooks.callSync('link:self', visit, undefined, () => { + if (this.options.linkToSelf === 'navigate') { + this.performNavigation(visit); + } else { + updateHistoryRecord(url); + this.scrollToContent(visit); + } + }); + } + return; + } + + // Exit early if the resolved path hasn't changed + if (this.isSameResolvedUrl(url, from)) { + return; + } + + // Finally, proceed with loading the page + this.performNavigation(visit); + }); + } + + protected handlePopState(event: PopStateEvent) { + const href: string = (event.state as HistoryState)?.url ?? window.location.href; + + // Exit early if this event should be ignored + if (this.options.skipPopStateHandling(event)) { + return; + } + + // Exit early if the resolved path hasn't changed + if (this.isSameResolvedUrl(getCurrentUrl(), this.location.url)) { + return; + } + + const { url, hash } = Location.fromUrl(href); + + const visit = this.createVisit({ to: url, hash, event }); + + // Mark as history visit + visit.history.popstate = true; + + // Determine direction of history visit + const index = (event.state as HistoryState)?.index ?? 0; + if (index && index !== this.currentHistoryIndex) { + const direction = index - this.currentHistoryIndex > 0 ? 'forwards' : 'backwards'; + visit.history.direction = direction; + this.currentHistoryIndex = index; + } + + // Disable animation & scrolling for history visits + visit.animation.animate = false; + visit.scroll.reset = false; + visit.scroll.target = false; + + // Animated history visit: re-enable animation & scroll reset + if (this.options.animateHistoryBrowsing) { + visit.animation.animate = true; + visit.scroll.reset = true; + } + + this.hooks.callSync('history:popstate', visit, { event }, () => { + this.performNavigation(visit); + }); + } + + /** Determine whether an element will open a new tab when clicking/activating. */ + protected triggerWillOpenNewWindow(triggerEl: Element) { + if (triggerEl.matches('[download], [target="_blank"]')) { + return true; + } + return false; + } +} diff --git a/node_modules/swup/src/config/version.ts b/node_modules/swup/src/config/version.ts new file mode 100644 index 00000000..b59b1a2b --- /dev/null +++ b/node_modules/swup/src/config/version.ts @@ -0,0 +1,12 @@ +/** + * Import swup's current version from package.json + */ + +// This will only work in webpack 4 +// export { version as default } from '../../package.json'; + +// This will work in microbundle + webpack 5, but won't treeshake in webpack 4 +// @ts-ignore: package.json is outside of rootDir +import pckg from '../../package.json'; + +export default pckg.version; diff --git a/node_modules/swup/src/helpers.ts b/node_modules/swup/src/helpers.ts new file mode 100644 index 00000000..a4aef87d --- /dev/null +++ b/node_modules/swup/src/helpers.ts @@ -0,0 +1,9 @@ +// Re-export all helpers to allow custom package export path +// e.g. import { updateHistoryRecord } from 'swup' + +export { classify } from './helpers/classify.js'; +export { createHistoryRecord, updateHistoryRecord } from './helpers/history.js'; +export { delegateEvent } from './helpers/delegateEvent.js'; +export { getCurrentUrl } from './helpers/getCurrentUrl.js'; +export { Location } from './helpers/Location.js'; +export { matchPath } from './helpers/matchPath.js'; diff --git a/node_modules/swup/src/helpers/Location.ts b/node_modules/swup/src/helpers/Location.ts new file mode 100755 index 00000000..aca9d6b6 --- /dev/null +++ b/node_modules/swup/src/helpers/Location.ts @@ -0,0 +1,38 @@ +/** + * A helper for creating a Location from either an element + * or a URL object/string + * + */ +export class Location extends URL { + constructor(url: URL | string, base: string = document.baseURI) { + super(url.toString(), base); + // Fix Safari bug with extending native classes + Object.setPrototypeOf(this, Location.prototype); + } + + /** + * The full local path including query params. + */ + get url(): string { + return this.pathname + this.search; + } + + /** + * Instantiate a Location from an element's href attribute + * @param el + * @returns new Location instance + */ + static fromElement(el: Element): Location { + const href = el.getAttribute('href') || el.getAttribute('xlink:href') || ''; + return new Location(href); + } + + /** + * Instantiate a Location from a URL object or string + * @param url + * @returns new Location instance + */ + static fromUrl(url: URL | string): Location { + return new Location(url); + } +} diff --git a/node_modules/swup/src/helpers/classify.ts b/node_modules/swup/src/helpers/classify.ts new file mode 100755 index 00000000..3d9ecec6 --- /dev/null +++ b/node_modules/swup/src/helpers/classify.ts @@ -0,0 +1,12 @@ +/** Turn a string into a slug by lowercasing and replacing whitespace. */ +export const classify = (text: string, fallback?: string): string => { + const output = String(text) + .toLowerCase() + // .normalize('NFD') // split an accented letter in the base letter and the acent + // .replace(/[\u0300-\u036f]/g, '') // remove all previously split accents + .replace(/[\s/_.]+/g, '-') // replace spaces and _./ with '-' + .replace(/[^\w-]+/g, '') // remove all non-word chars + .replace(/--+/g, '-') // replace repeating '-' with single '-' + .replace(/^-+|-+$/g, ''); // trim '-' from edges + return output || fallback || ''; +}; diff --git a/node_modules/swup/src/helpers/delegateEvent.ts b/node_modules/swup/src/helpers/delegateEvent.ts new file mode 100755 index 00000000..eec44f50 --- /dev/null +++ b/node_modules/swup/src/helpers/delegateEvent.ts @@ -0,0 +1,27 @@ +import delegate, { + type DelegateEventHandler, + type DelegateOptions, + type EventType +} from 'delegate-it'; +import type { ParseSelector } from 'typed-query-selector/parser.js'; + +export type DelegateEventUnsubscribe = { + destroy: () => void; +}; + +/** Register a delegated event listener. */ +export const delegateEvent = < + Selector extends string, + TElement extends Element = ParseSelector, + TEvent extends EventType = EventType +>( + selector: Selector, + type: TEvent, + callback: DelegateEventHandler, + options?: DelegateOptions +): DelegateEventUnsubscribe => { + const controller = new AbortController(); + options = { ...options, signal: controller.signal }; + delegate(selector, type, callback, options); + return { destroy: () => controller.abort() }; +}; diff --git a/node_modules/swup/src/helpers/getCurrentUrl.ts b/node_modules/swup/src/helpers/getCurrentUrl.ts new file mode 100755 index 00000000..ab0f0d57 --- /dev/null +++ b/node_modules/swup/src/helpers/getCurrentUrl.ts @@ -0,0 +1,4 @@ +/** Get the current page URL: path name + query params. Optionally including hash. */ +export const getCurrentUrl = ({ hash }: { hash?: boolean } = {}): string => { + return window.location.pathname + window.location.search + (hash ? window.location.hash : ''); +}; diff --git a/node_modules/swup/src/helpers/history.ts b/node_modules/swup/src/helpers/history.ts new file mode 100755 index 00000000..e7ea2f6c --- /dev/null +++ b/node_modules/swup/src/helpers/history.ts @@ -0,0 +1,37 @@ +import { getCurrentUrl } from './getCurrentUrl.js'; + +export interface HistoryState { + url: string; + source: 'swup'; + random: number; + index?: number; + [key: string]: unknown; +} + +type HistoryData = Record; + +/** Create a new history record with a custom swup identifier. */ +export const createHistoryRecord = (url: string, data: HistoryData = {}): void => { + url = url || getCurrentUrl({ hash: true }); + const state: HistoryState = { + url, + random: Math.random(), + source: 'swup', + ...data + }; + window.history.pushState(state, '', url); +}; + +/** Update the current history record with a custom swup identifier. */ +export const updateHistoryRecord = (url: string | null = null, data: HistoryData = {}): void => { + url = url || getCurrentUrl({ hash: true }); + const currentState = (window.history.state as HistoryState) || {}; + const state: HistoryState = { + ...currentState, + url, + random: Math.random(), + source: 'swup', + ...data + }; + window.history.replaceState(state, '', url); +}; diff --git a/node_modules/swup/src/helpers/matchPath.ts b/node_modules/swup/src/helpers/matchPath.ts new file mode 100644 index 00000000..e1332911 --- /dev/null +++ b/node_modules/swup/src/helpers/matchPath.ts @@ -0,0 +1,23 @@ +import { match } from 'path-to-regexp'; + +import type { + Path, + ParseOptions, + TokensToRegexpOptions, + RegexpToFunctionOptions, + MatchFunction +} from 'path-to-regexp'; + +export { type Path }; + +/** Create a match function from a path pattern that checks if a URLs matches it. */ +export const matchPath =

( + path: Path, + options?: ParseOptions & TokensToRegexpOptions & RegexpToFunctionOptions +): MatchFunction

=> { + try { + return match

(path, options); + } catch (error) { + throw new Error(`[swup] Error parsing path "${String(path)}":\n${String(error)}`); + } +}; diff --git a/node_modules/swup/src/index.ts b/node_modules/swup/src/index.ts new file mode 100644 index 00000000..29fcee5b --- /dev/null +++ b/node_modules/swup/src/index.ts @@ -0,0 +1,58 @@ +import type { Path } from 'path-to-regexp'; +import type { DelegateEventUnsubscribe } from './helpers/delegateEvent.js'; +import type { DelegateEvent, DelegateEventHandler } from 'delegate-it'; +import Swup from './Swup.js'; +import type { Options } from './Swup.js'; +import type { CacheData } from './modules/Cache.js'; +import type { PageData } from './modules/fetchPage.js'; +import type { + Visit, + VisitFrom, + VisitTo, + VisitAnimation, + VisitScroll, + VisitHistory +} from './modules/Visit.js'; +import type { + HookName, + HookDefinitions, + HookArguments, + HookReturnValues, + HookHandler, + HookDefaultHandler, + HookOptions, + HookUnregister, + HookEvent +} from './modules/Hooks.js'; +import type { Plugin } from './modules/plugins.js'; + +export default Swup; +export * from './helpers.js'; +export * from './utils.js'; +export type { + Swup, + Options, + Plugin, + CacheData, + PageData, + Path, + Visit, + VisitFrom, + VisitTo, + VisitAnimation, + VisitScroll, + VisitHistory, + HookName, + HookDefinitions, + HookArguments, + HookReturnValues, + HookHandler, + HookHandler as Handler, // backwards compatibility + HookDefaultHandler, + HookOptions, + HookUnregister, + HookEvent, + DelegateEvent, + DelegateEventHandler, + DelegateEventUnsubscribe +}; diff --git a/node_modules/swup/src/modules/Cache.ts b/node_modules/swup/src/modules/Cache.ts new file mode 100755 index 00000000..913e10a9 --- /dev/null +++ b/node_modules/swup/src/modules/Cache.ts @@ -0,0 +1,87 @@ +import type Swup from '../Swup.js'; +import { Location } from '../helpers.js'; +import { type PageData } from './fetchPage.js'; + +export interface CacheData extends PageData {} + +/** + * In-memory page cache. + */ +export class Cache { + /** Swup instance this cache belongs to */ + protected swup: Swup; + + /** Cached pages, indexed by URL */ + protected pages: Map = new Map(); + + constructor(swup: Swup) { + this.swup = swup; + } + + /** Number of cached pages in memory. */ + get size(): number { + return this.pages.size; + } + + /** All cached pages. */ + get all() { + const copy = new Map(); + this.pages.forEach((page, key) => { + copy.set(key, { ...page }); + }); + return copy; + } + + /** Check if the given URL has been cached. */ + has(url: string): boolean { + return this.pages.has(this.resolve(url)); + } + + /** Return a shallow copy of the cached page object if available. */ + get(url: string): CacheData | undefined { + const result = this.pages.get(this.resolve(url)); + if (!result) return result; + return { ...result }; + } + + /** Create a cache record for the specified URL. */ + set(url: string, page: CacheData) { + url = this.resolve(url); + page = { ...page, url }; + this.pages.set(url, page); + this.swup.hooks.callSync('cache:set', undefined, { page }); + } + + /** Update a cache record, overwriting or adding custom data. */ + update(url: string, payload: object) { + url = this.resolve(url); + const page = { ...this.get(url), ...payload, url } as CacheData; + this.pages.set(url, page); + } + + /** Delete a cache record. */ + delete(url: string): void { + this.pages.delete(this.resolve(url)); + } + + /** Empty the cache. */ + clear(): void { + this.pages.clear(); + this.swup.hooks.callSync('cache:clear', undefined, undefined); + } + + /** Remove all cache entries that return true for a given predicate function. */ + prune(predicate: (url: string, page: CacheData) => boolean): void { + this.pages.forEach((page, url) => { + if (predicate(url, page)) { + this.delete(url); + } + }); + } + + /** Resolve URLs by making them local and letting swup resolve them. */ + protected resolve(urlToResolve: string): string { + const { url } = Location.fromUrl(urlToResolve); + return this.swup.resolveUrl(url); + } +} diff --git a/node_modules/swup/src/modules/Classes.ts b/node_modules/swup/src/modules/Classes.ts new file mode 100644 index 00000000..7e034511 --- /dev/null +++ b/node_modules/swup/src/modules/Classes.ts @@ -0,0 +1,54 @@ +import type Swup from '../Swup.js'; +import { queryAll } from '../utils.js'; + +export class Classes { + protected swup: Swup; + protected swupClasses = [ + 'to-', + 'is-changing', + 'is-rendering', + 'is-popstate', + 'is-animating', + 'is-leaving' + ]; + + constructor(swup: Swup) { + this.swup = swup; + } + + protected get selectors(): string[] { + const { scope } = this.swup.visit.animation; + if (scope === 'containers') return this.swup.visit.containers; + if (scope === 'html') return ['html']; + if (Array.isArray(scope)) return scope; + return []; + } + + protected get selector(): string { + return this.selectors.join(','); + } + + protected get targets(): HTMLElement[] { + if (!this.selector.trim()) return []; + return queryAll(this.selector); + } + + add(...classes: string[]): void { + this.targets.forEach((target) => target.classList.add(...classes)); + } + + remove(...classes: string[]): void { + this.targets.forEach((target) => target.classList.remove(...classes)); + } + + clear(): void { + this.targets.forEach((target) => { + const remove = target.className.split(' ').filter((c) => this.isSwupClass(c)); + target.classList.remove(...remove); + }); + } + + protected isSwupClass(className: string): boolean { + return this.swupClasses.some((c) => className.startsWith(c)); + } +} diff --git a/node_modules/swup/src/modules/Hooks.ts b/node_modules/swup/src/modules/Hooks.ts new file mode 100644 index 00000000..36af2bf7 --- /dev/null +++ b/node_modules/swup/src/modules/Hooks.ts @@ -0,0 +1,573 @@ +import type { DelegateEvent } from 'delegate-it'; + +import type Swup from '../Swup.js'; +import { isPromise, runAsPromise } from '../utils.js'; +import { Visit } from './Visit.js'; +import type { FetchOptions, PageData } from './fetchPage.js'; + +export interface HookDefinitions { + 'animation:out:start': undefined; + 'animation:out:await': { skip: boolean }; + 'animation:out:end': undefined; + 'animation:in:start': undefined; + 'animation:in:await': { skip: boolean }; + 'animation:in:end': undefined; + 'animation:skip': undefined; + 'cache:clear': undefined; + 'cache:set': { page: PageData }; + 'content:replace': { page: PageData }; + 'content:scroll': undefined; + 'enable': undefined; + 'disable': undefined; + 'fetch:request': { url: string; options: FetchOptions }; + 'fetch:error': { url: string; status: number; response: Response }; + 'fetch:timeout': { url: string }; + 'history:popstate': { event: PopStateEvent }; + 'link:click': { el: HTMLAnchorElement; event: DelegateEvent }; + 'link:self': undefined; + 'link:anchor': { hash: string }; + 'link:newtab': { href: string }; + 'page:load': { page?: PageData; cache?: boolean; options: FetchOptions }; + 'page:view': { url: string; title: string }; + 'scroll:top': { options: ScrollIntoViewOptions }; + 'scroll:anchor': { hash: string; options: ScrollIntoViewOptions }; + 'visit:start': undefined; + 'visit:transition': undefined; + 'visit:abort': undefined; + 'visit:end': undefined; +} + +export interface HookReturnValues { + 'content:scroll': Promise | boolean; + 'fetch:request': Promise; + 'page:load': Promise; + 'scroll:top': boolean; + 'scroll:anchor': boolean; +} + +export type HookArguments = HookDefinitions[T]; + +export type HookName = keyof HookDefinitions; + +/** A generic hook handler. */ +export type HookHandler = ( + /** Context about the current visit. */ + visit: Visit, + /** Local arguments passed into the handler. */ + args: HookArguments +) => Promise | unknown; + +/** A default hook handler with an expected return type. */ +export type HookDefaultHandler = ( + /** Context about the current visit. */ + visit: Visit, + /** Local arguments passed into the handler. */ + args: HookArguments, + /** Default handler to be executed. Available if replacing an internal hook handler. */ + defaultHandler?: HookDefaultHandler +) => T extends keyof HookReturnValues ? HookReturnValues[T] : Promise | unknown; + +export type Handlers = { + [K in HookName]: HookHandler[]; +}; + +/** Unregister a previously registered hook handler. */ +export type HookUnregister = () => void; + +/** Define when and how a hook handler is executed. */ +export type HookOptions = { + /** Execute the hook once, then remove the handler */ + once?: boolean; + /** Execute the hook before the internal default handler */ + before?: boolean; + /** Set a priority for when to execute this hook. Lower numbers execute first. Default: `0` */ + priority?: number; + /** Replace the internal default handler with this hook handler */ + replace?: boolean; +}; + +export type HookRegistration< + T extends HookName, + H extends HookHandler | HookDefaultHandler = HookHandler +> = { + id: number; + hook: T; + handler: H; + defaultHandler?: HookDefaultHandler; +} & HookOptions; + +type HookEventDetail = { + hook: HookName; + args: unknown; + visit: Visit; +}; + +export type HookEvent = CustomEvent; + +type HookLedger = Map, HookRegistration>; + +interface HookRegistry extends Map> { + get(key: K): HookLedger | undefined; + set(key: K, value: HookLedger): this; +} + +/** + * Hook registry. + * + * Create, trigger and handle hooks. + * + */ +export class Hooks { + /** Swup instance this registry belongs to */ + protected swup: Swup; + + /** Map of all registered hook handlers. */ + protected registry: HookRegistry = new Map(); + + // Can we deduplicate this somehow? Or make it error when not in sync with HookDefinitions? + // https://stackoverflow.com/questions/53387838/how-to-ensure-an-arrays-values-the-keys-of-a-typescript-interface/53395649 + protected readonly hooks: HookName[] = [ + 'animation:out:start', + 'animation:out:await', + 'animation:out:end', + 'animation:in:start', + 'animation:in:await', + 'animation:in:end', + 'animation:skip', + 'cache:clear', + 'cache:set', + 'content:replace', + 'content:scroll', + 'enable', + 'disable', + 'fetch:request', + 'fetch:error', + 'fetch:timeout', + 'history:popstate', + 'link:click', + 'link:self', + 'link:anchor', + 'link:newtab', + 'page:load', + 'page:view', + 'scroll:top', + 'scroll:anchor', + 'visit:start', + 'visit:transition', + 'visit:abort', + 'visit:end' + ]; + + constructor(swup: Swup) { + this.swup = swup; + this.init(); + } + + /** + * Create ledgers for all core hooks. + */ + protected init() { + this.hooks.forEach((hook) => this.create(hook)); + } + + /** + * Create a new hook type. + */ + create(hook: string) { + if (!this.registry.has(hook as HookName)) { + this.registry.set(hook as HookName, new Map()); + } + } + + /** + * Check if a hook type exists. + */ + exists(hook: HookName): boolean { + return this.registry.has(hook); + } + + /** + * Get the ledger with all registrations for a hook. + */ + protected get(hook: T): HookLedger | undefined { + const ledger = this.registry.get(hook); + if (ledger) { + return ledger; + } + console.error(`Unknown hook '${hook}'`); + } + + /** + * Remove all handlers of all hooks. + */ + clear() { + this.registry.forEach((ledger) => ledger.clear()); + } + + /** + * Register a new hook handler. + * @param hook Name of the hook to listen for + * @param handler The handler function to execute + * @param options Object to specify how and when the handler is executed + * Available options: + * - `once`: Only execute the handler once + * - `before`: Execute the handler before the default handler + * - `priority`: Specify the order in which the handlers are executed + * - `replace`: Replace the default handler with this handler + * @returns A function to unregister the handler + */ + + // Overload: replacing default handler + on(hook: T, handler: HookDefaultHandler, options: O & { replace: true }): HookUnregister; // prettier-ignore + // Overload: passed in handler options + on(hook: T, handler: HookHandler, options: O): HookUnregister; // prettier-ignore + // Overload: no handler options + on(hook: T, handler: HookHandler): HookUnregister; // prettier-ignore + // Implementation + on( + hook: T, + handler: O['replace'] extends true ? HookDefaultHandler : HookHandler, + options: Partial = {} + ): HookUnregister { + const ledger = this.get(hook); + if (!ledger) { + console.warn(`Hook '${hook}' not found.`); + return () => {}; + } + + const id = ledger.size + 1; + const registration: HookRegistration = { ...options, id, hook, handler }; + ledger.set(handler, registration); + + return () => this.off(hook, handler); + } + + /** + * Register a new hook handler to run before the default handler. + * Shortcut for `hooks.on(hook, handler, { before: true })`. + * @param hook Name of the hook to listen for + * @param handler The handler function to execute + * @param options Any other event options (see `hooks.on()` for details) + * @returns A function to unregister the handler + * @see on + */ + // Overload: passed in handler options + before(hook: T, handler: HookHandler, options: HookOptions): HookUnregister; // prettier-ignore + // Overload: no handler options + before(hook: T, handler: HookHandler): HookUnregister; + // Implementation + before( + hook: T, + handler: HookHandler, + options: HookOptions = {} + ): HookUnregister { + return this.on(hook, handler, { ...options, before: true }); + } + + /** + * Register a new hook handler to replace the default handler. + * Shortcut for `hooks.on(hook, handler, { replace: true })`. + * @param hook Name of the hook to listen for + * @param handler The handler function to execute instead of the default handler + * @param options Any other event options (see `hooks.on()` for details) + * @returns A function to unregister the handler + * @see on + */ + // Overload: passed in handler options + replace(hook: T, handler: HookDefaultHandler, options: HookOptions): HookUnregister; // prettier-ignore + // Overload: no handler options + replace(hook: T, handler: HookDefaultHandler): HookUnregister; // prettier-ignore + // Implementation + replace( + hook: T, + handler: HookDefaultHandler, + options: HookOptions = {} + ): HookUnregister { + return this.on(hook, handler, { ...options, replace: true }); + } + + /** + * Register a new hook handler to run once. + * Shortcut for `hooks.on(hook, handler, { once: true })`. + * @param hook Name of the hook to listen for + * @param handler The handler function to execute + * @param options Any other event options (see `hooks.on()` for details) + * @see on + */ + // Overload: passed in handler options + once(hook: T, handler: HookHandler, options: HookOptions): HookUnregister; // prettier-ignore + // Overload: no handler options + once(hook: T, handler: HookHandler): HookUnregister; + // Implementation + once( + hook: T, + handler: HookHandler, + options: HookOptions = {} + ): HookUnregister { + return this.on(hook, handler, { ...options, once: true }); + } + + /** + * Unregister a hook handler. + * @param hook Name of the hook the handler is registered for + * @param handler The handler function that was registered. + * If omitted, all handlers for the hook will be removed. + */ + // Overload: unregister a specific handler + off(hook: T, handler: HookHandler | HookDefaultHandler): void; + // Overload: unregister all handlers + off(hook: T): void; + // Implementation + off(hook: T, handler?: HookHandler | HookDefaultHandler): void { + const ledger = this.get(hook); + if (ledger && handler) { + const deleted = ledger.delete(handler); + if (!deleted) { + console.warn(`Handler for hook '${hook}' not found.`); + } + } else if (ledger) { + ledger.clear(); + } + } + + /** + * Trigger a hook asynchronously, executing its default handler and all registered handlers. + * Will execute all handlers in order and `await` any `Promise`s they return. + * @param hook Name of the hook to trigger + * @param visit The visit object this hook belongs to + * @param args Arguments to pass to the handler + * @param defaultHandler A default implementation of this hook to execute + * @returns The resolved return value of the executed default handler + */ + // Overload: default order of arguments + async call(hook: T, visit: Visit | undefined, args: HookArguments, defaultHandler?: HookDefaultHandler): Promise>>>; // prettier-ignore + // Overload: legacy order of arguments, with visit missing + async call(hook: T, args: HookArguments, defaultHandler?: HookDefaultHandler): Promise>>>; // prettier-ignore + // Implementation + async call( + hook: T, + arg1: Visit | HookArguments, + arg2: HookArguments | HookDefaultHandler, + arg3?: HookDefaultHandler + ): Promise>>> { + const [visit, args, defaultHandler] = this.parseCallArgs(hook, arg1, arg2, arg3); + + const { before, handler, after } = this.getHandlers(hook, defaultHandler); + await this.run(before, visit, args); + const [result] = await this.run(handler, visit, args, true); + await this.run(after, visit, args); + this.dispatchDomEvent(hook, visit, args); + return result; + } + + /** + * Trigger a hook synchronously, executing its default handler and all registered handlers. + * Will execute all handlers in order, but will **not** `await` any `Promise`s they return. + * @param hook Name of the hook to trigger + * @param visit The visit object this hook belongs to + * @param args Arguments to pass to the handler + * @param defaultHandler A default implementation of this hook to execute + * @returns The (possibly unresolved) return value of the executed default handler + */ + // Overload: default order of arguments + callSync(hook: T, visit: Visit | undefined, args: HookArguments, defaultHandler?: HookDefaultHandler): ReturnType>; // prettier-ignore + // Overload: legacy order of arguments, with visit missing + callSync(hook: T, args: HookArguments, defaultHandler?: HookDefaultHandler): ReturnType>; // prettier-ignore + // Implementation + callSync( + hook: T, + arg1: Visit | HookArguments, + arg2: HookArguments | HookDefaultHandler, + arg3?: HookDefaultHandler + ): ReturnType> { + const [visit, args, defaultHandler] = this.parseCallArgs(hook, arg1, arg2, arg3); + const { before, handler, after } = this.getHandlers(hook, defaultHandler); + this.runSync(before, visit, args); + const [result] = this.runSync(handler, visit, args, true); + this.runSync(after, visit, args); + this.dispatchDomEvent(hook, visit, args); + return result; + } + + /** + * Parse the call arguments for call() and callSync() to allow legacy argument order. + */ + protected parseCallArgs( + hook: T, + arg1: Visit | HookArguments | undefined, + arg2: HookArguments | HookDefaultHandler, + arg3?: HookDefaultHandler + ): [Visit | undefined, HookArguments, HookDefaultHandler | undefined] { + const isLegacyOrder = + !(arg1 instanceof Visit) && (typeof arg1 === 'object' || typeof arg2 === 'function'); + if (isLegacyOrder) { + // Legacy positioning: arguments in second or handler passed in third place + return [undefined, arg1 as HookArguments, arg2 as HookDefaultHandler]; + } else { + // Default positioning: visit passed in as first argument + return [arg1, arg2 as HookArguments, arg3]; + } + } + + /** + * Execute the handlers for a hook, in order, as `Promise`s that will be `await`ed. + * @param registrations The registrations (handler + options) to execute + * @param args Arguments to pass to the handler + */ + + // Overload: running HookDefaultHandler: expect HookDefaultHandler return type + protected async run(registrations: HookRegistration>[], visit: Visit | undefined, args: HookArguments, rethrow: true): Promise>>[]>; // prettier-ignore + // Overload: running user handler: expect no specific type + protected async run(registrations: HookRegistration[], visit: Visit | undefined, args: HookArguments): Promise; // prettier-ignore + // Implementation + protected async run[]>( + registrations: R, + visit: Visit | undefined = this.swup.visit, + args: HookArguments, + rethrow: boolean = false + ): Promise>> | unknown[]> { + const results = []; + for (const { hook, handler, defaultHandler, once } of registrations) { + if (visit?.done) continue; + if (once) this.off(hook, handler); + try { + const result = await runAsPromise(handler, [visit, args, defaultHandler]); + results.push(result); + } catch (error) { + if (rethrow) { + throw error; + } else { + console.error(`Error in hook '${hook}':`, error); + } + } + } + return results; + } + + /** + * Execute the handlers for a hook, in order, without `await`ing any returned `Promise`s. + * @param registrations The registrations (handler + options) to execute + * @param args Arguments to pass to the handler + */ + + // Overload: running HookDefaultHandler: expect HookDefaultHandler return type + protected runSync(registrations: HookRegistration>[], visit: Visit | undefined, args: HookArguments, rethrow: true): ReturnType>[]; // prettier-ignore + // Overload: running user handler: expect no specific type + protected runSync(registrations: HookRegistration[], visit: Visit | undefined, args: HookArguments): unknown[]; // prettier-ignore + // Implementation + protected runSync[]>( + registrations: R, + visit: Visit | undefined = this.swup.visit, + args: HookArguments, + rethrow: boolean = false + ): (ReturnType> | unknown)[] { + const results = []; + for (const { hook, handler, defaultHandler, once } of registrations) { + if (visit?.done) continue; + if (once) this.off(hook, handler); + try { + const result = (handler as HookDefaultHandler)(visit, args, defaultHandler); + results.push(result); + if (isPromise(result)) { + console.warn( + `Swup will not await Promises in handler for synchronous hook '${hook}'.` + ); + } + } catch (error) { + if (rethrow) { + throw error; + } else { + console.error(`Error in hook '${hook}':`, error); + } + } + } + return results; + } + + /** + * Get all registered handlers for a hook, sorted by priority and registration order. + * @param hook Name of the hook + * @param defaultHandler The optional default handler of this hook + * @returns An object with the handlers sorted into `before` and `after` arrays, + * as well as a flag indicating if the original handler was replaced + */ + protected getHandlers(hook: T, defaultHandler?: HookDefaultHandler) { + const ledger = this.get(hook); + if (!ledger) { + return { found: false, before: [], handler: [], after: [], replaced: false }; + } + + const registrations = Array.from(ledger.values()); + + // Let TypeScript know that replaced handlers are default handlers by filtering to true + const def = (T: HookRegistration): T is HookRegistration> => true; // prettier-ignore + const sort = this.sortRegistrations; + + // Filter into before, after, and replace handlers + const before = registrations.filter(({ before, replace }) => before && !replace).sort(sort); + const replace = registrations.filter(({ replace }) => replace).filter(def).sort(sort); // prettier-ignore + const after = registrations.filter(({ before, replace }) => !before && !replace).sort(sort); + const replaced = replace.length > 0; + + // Define main handler registration + // Created as HookRegistration[] array to allow passing it into hooks.run() directly + let handler: HookRegistration>[] = []; + if (defaultHandler) { + handler = [{ id: 0, hook, handler: defaultHandler }]; + if (replaced) { + const index = replace.length - 1; + const replacingHandler = replace[index].handler; + const createDefaultHandler = (index: number): HookDefaultHandler | undefined => { + const next = replace[index - 1]; + if (next) { + return (visit, args) => + next.handler(visit, args, createDefaultHandler(index - 1)); + } else { + return defaultHandler; + } + }; + const nestedDefaultHandler = createDefaultHandler(index); + handler = [ + { id: 0, hook, handler: replacingHandler, defaultHandler: nestedDefaultHandler } + ]; + } + } + + return { found: true, before, handler, after, replaced }; + } + + /** + * Sort two hook registrations by priority and registration order. + * @param a The registration object to compare + * @param b The other registration object to compare with + * @returns The sort direction + */ + protected sortRegistrations( + a: HookRegistration, + b: HookRegistration + ): number { + const priority = (a.priority ?? 0) - (b.priority ?? 0); + const id = a.id - b.id; + return priority || id || 0; + } + + /** + * Dispatch a custom event on the `document` for a hook. Prefixed with `swup:` + * @param hook Name of the hook. + */ + protected dispatchDomEvent( + hook: T, + visit: Visit | undefined, + args?: HookArguments + ): void { + if (visit?.done) return; + + const detail: HookEventDetail = { hook, args, visit: visit || this.swup.visit }; + document.dispatchEvent( + new CustomEvent(`swup:any`, { detail, bubbles: true }) + ); + document.dispatchEvent( + new CustomEvent(`swup:${hook}`, { detail, bubbles: true }) + ); + } +} diff --git a/node_modules/swup/src/modules/Visit.ts b/node_modules/swup/src/modules/Visit.ts new file mode 100644 index 00000000..b0d5d271 --- /dev/null +++ b/node_modules/swup/src/modules/Visit.ts @@ -0,0 +1,170 @@ +import type Swup from '../Swup.js'; +import type { Options } from '../Swup.js'; +import type { HistoryAction, HistoryDirection } from './navigate.js'; + +/** See below for the class Visit {} definition */ +// export interface Visit {} + +export interface VisitFrom { + /** The URL of the previous page */ + url: string; + /** The hash of the previous page */ + hash?: string; +} + +export interface VisitTo { + /** The URL of the next page */ + url: string; + /** The hash of the next page */ + hash?: string; + /** The HTML content of the next page */ + html?: string; + /** The parsed document of the next page, available during visit */ + document?: Document; +} + +export interface VisitAnimation { + /** Whether this visit is animated. Default: `true` */ + animate: boolean; + /** Whether to wait for the next page to load before starting the animation. Default: `false` */ + wait: boolean; + /** Name of a custom animation to run. */ + name?: string; + /** Whether this animation uses the native browser ViewTransition API. Default: `false` */ + native: boolean; + /** Elements on which to add animation classes. Default: `html` element */ + scope: 'html' | 'containers' | string[]; + /** Selector for detecting animation timing. Default: `[class*="transition-"]` */ + selector: Options['animationSelector']; +} + +export interface VisitScroll { + /** Whether to reset the scroll position after the visit. Default: `true` */ + reset: boolean; + /** Anchor element to scroll to on the next page. */ + target?: string | false; +} + +export interface VisitTrigger { + /** DOM element that triggered this visit. */ + el?: Element; + /** DOM event that triggered this visit. */ + event?: Event; +} + +export interface VisitCache { + /** Whether this visit will try to load the requested page from cache. */ + read: boolean; + /** Whether this visit will save the loaded page in cache. */ + write: boolean; +} + +export interface VisitHistory { + /** History action to perform: `push` for creating a new history entry, `replace` for replacing the current entry. Default: `push` */ + action: HistoryAction; + /** Whether this visit was triggered by a browser history navigation. */ + popstate: boolean; + /** The direction of travel in case of a browser history navigation: backward or forward. */ + direction: HistoryDirection | undefined; +} + +export interface VisitInitOptions { + to: string; + from?: string; + hash?: string; + el?: Element; + event?: Event; +} + +/** @internal */ +export const VisitState = { + CREATED: 1, + QUEUED: 2, + STARTED: 3, + LEAVING: 4, + LOADED: 5, + ENTERING: 6, + COMPLETED: 7, + ABORTED: 8, + FAILED: 9 +} as const; + +/** @internal */ +export type VisitState = (typeof VisitState)[keyof typeof VisitState]; + +/** An object holding details about the current visit. */ +export class Visit { + /** A unique ID to identify this visit */ + id: number; + /** The current state of this visit @internal */ + state: VisitState; + /** The previous page, about to leave */ + from: VisitFrom; + /** The next page, about to enter */ + to: VisitTo; + /** The content containers, about to be replaced */ + containers: Options['containers']; + /** Information about animated page transitions */ + animation: VisitAnimation; + /** What triggered this visit */ + trigger: VisitTrigger; + /** Cache behavior for this visit */ + cache: VisitCache; + /** Browser history behavior on this visit */ + history: VisitHistory; + /** Scroll behavior on this visit */ + scroll: VisitScroll; + + constructor(swup: Swup, options: VisitInitOptions) { + const { to, from, hash, el, event } = options; + + this.id = Math.random(); + this.state = VisitState.CREATED; + this.from = { url: from ?? swup.location.url, hash: swup.location.hash }; + this.to = { url: to, hash }; + this.containers = swup.options.containers; + this.animation = { + animate: true, + wait: false, + name: undefined, + native: swup.options.native, + scope: swup.options.animationScope, + selector: swup.options.animationSelector + }; + this.trigger = { el, event }; + this.cache = { + read: swup.options.cache, + write: swup.options.cache + }; + this.history = { + action: 'push', + popstate: false, + direction: undefined + }; + this.scroll = { + reset: true, + target: undefined + }; + } + /** @internal */ + advance(state: VisitState) { + if (this.state < state) { + this.state = state; + } + } + + /** @internal */ + abort() { + this.state = VisitState.ABORTED; + } + + /** Is this visit done, i.e. completed, failed, or aborted? */ + get done(): boolean { + return this.state >= VisitState.COMPLETED; + } +} + +/** Create a new visit object. */ +export function createVisit(this: Swup, options: VisitInitOptions): Visit { + return new Visit(this, options); +} diff --git a/node_modules/swup/src/modules/animatePageIn.ts b/node_modules/swup/src/modules/animatePageIn.ts new file mode 100755 index 00000000..fc384b73 --- /dev/null +++ b/node_modules/swup/src/modules/animatePageIn.ts @@ -0,0 +1,32 @@ +import type Swup from '../Swup.js'; +import { nextTick } from '../utils.js'; +import type { Visit } from './Visit.js'; + +/** + * Perform the in/enter animation of the next page. + * @returns Promise + */ +export const animatePageIn = async function (this: Swup, visit: Visit) { + // Check if failed/aborted in the meantime + if (visit.done) return; + + const animation = this.hooks.call( + 'animation:in:await', + visit, + { skip: false }, + (visit, { skip }) => { + if (skip) return; + return this.awaitAnimations({ selector: visit.animation.selector }); + } + ); + + await nextTick(); + + await this.hooks.call('animation:in:start', visit, undefined, () => { + this.classes.remove('is-animating'); + }); + + await animation; + + await this.hooks.call('animation:in:end', visit, undefined); +}; diff --git a/node_modules/swup/src/modules/animatePageOut.ts b/node_modules/swup/src/modules/animatePageOut.ts new file mode 100755 index 00000000..d42bf57f --- /dev/null +++ b/node_modules/swup/src/modules/animatePageOut.ts @@ -0,0 +1,19 @@ +import type Swup from '../Swup.js'; +import type { Visit } from './Visit.js'; + +/** + * Perform the out/leave animation of the current page. + * @returns Promise + */ +export const animatePageOut = async function (this: Swup, visit: Visit) { + await this.hooks.call('animation:out:start', visit, undefined, () => { + this.classes.add('is-changing', 'is-animating', 'is-leaving'); + }); + + await this.hooks.call('animation:out:await', visit, { skip: false }, (visit, { skip }) => { + if (skip) return; + return this.awaitAnimations({ selector: visit.animation.selector }); + }); + + await this.hooks.call('animation:out:end', visit, undefined); +}; diff --git a/node_modules/swup/src/modules/awaitAnimations.ts b/node_modules/swup/src/modules/awaitAnimations.ts new file mode 100644 index 00000000..e4b77e74 --- /dev/null +++ b/node_modules/swup/src/modules/awaitAnimations.ts @@ -0,0 +1,148 @@ +import { queryAll } from '../utils.js'; +import type Swup from '../Swup.js'; +import type { Options } from '../Swup.js'; + +const TRANSITION = 'transition'; +const ANIMATION = 'animation'; + +type AnimationType = typeof TRANSITION | typeof ANIMATION; +type AnimationEndEvent = `${AnimationType}end`; +type AnimationProperty = 'Delay' | 'Duration'; +type AnimationStyleKey = `${AnimationType}${AnimationProperty}` | 'transitionProperty'; + +export type AnimationDirection = 'in' | 'out'; + +/** + * Return a Promise that resolves when all CSS animations and transitions + * are done on the page. Filters by selector or takes elements directly. + */ +export async function awaitAnimations( + this: Swup, + { + selector, + elements + }: { + selector: Options['animationSelector']; + elements?: NodeListOf | HTMLElement[]; + } +): Promise { + // Allow usage of swup without animations: { animationSelector: false } + if (selector === false && !elements) { + return; + } + + // Allow passing in elements + let animatedElements: HTMLElement[] = []; + if (elements) { + animatedElements = Array.from(elements); + } else if (selector) { + animatedElements = queryAll(selector, document.body); + // Warn if no elements match the selector, but keep things going + if (!animatedElements.length) { + console.warn(`[swup] No elements found matching animationSelector \`${selector}\``); + return; + } + } + + const awaitedAnimations = animatedElements.map((el) => awaitAnimationsOnElement(el)); + const hasAnimations = awaitedAnimations.filter(Boolean).length > 0; + if (!hasAnimations) { + if (selector) { + console.warn( + `[swup] No CSS animation duration defined on elements matching \`${selector}\`` + ); + } + return; + } + + await Promise.all(awaitedAnimations); +} + +function awaitAnimationsOnElement(element: HTMLElement): Promise | false { + const { type, timeout, propCount } = getTransitionInfo(element); + + // Resolve immediately if no transition defined + if (!type || !timeout) { + return false; + } + + return new Promise((resolve) => { + const endEvent: AnimationEndEvent = `${type}end`; + const startTime = performance.now(); + let propsTransitioned = 0; + + const end = () => { + element.removeEventListener(endEvent, onEnd); + resolve(); + }; + + const onEnd = (event: TransitionEvent | AnimationEvent) => { + // Skip transitions on child elements + if (event.target !== element) { + return; + } + + // Skip transitions that happened before we started listening + const elapsedTime = (performance.now() - startTime) / 1000; + if (elapsedTime < event.elapsedTime) { + return; + } + + // End if all properties have transitioned + if (++propsTransitioned >= propCount) { + end(); + } + }; + + setTimeout(() => { + if (propsTransitioned < propCount) { + end(); + } + }, timeout + 1); + + element.addEventListener(endEvent, onEnd); + }); +} + +function getTransitionInfo(element: Element) { + const styles = window.getComputedStyle(element); + + const transitionDelays = getStyleProperties(styles, `${TRANSITION}Delay`); + const transitionDurations = getStyleProperties(styles, `${TRANSITION}Duration`); + const transitionTimeout = calculateTimeout(transitionDelays, transitionDurations); + + const animationDelays = getStyleProperties(styles, `${ANIMATION}Delay`); + const animationDurations = getStyleProperties(styles, `${ANIMATION}Duration`); + const animationTimeout = calculateTimeout(animationDelays, animationDurations); + + const timeout = Math.max(transitionTimeout, animationTimeout); + const type: AnimationType | null = + timeout > 0 ? (transitionTimeout > animationTimeout ? TRANSITION : ANIMATION) : null; + const propCount = type + ? type === TRANSITION + ? transitionDurations.length + : animationDurations.length + : 0; + + return { + type, + timeout, + propCount + }; +} + +export function getStyleProperties(styles: CSSStyleDeclaration, key: AnimationStyleKey): string[] { + return (styles[key] || '').split(', '); +} + +export function calculateTimeout(delays: string[], durations: string[]): number { + while (delays.length < durations.length) { + delays = delays.concat(delays); + } + + return Math.max(...durations.map((duration, i) => toMs(duration) + toMs(delays[i]))); +} + +export function toMs(time: string): number { + return parseFloat(time) * 1000; +} diff --git a/node_modules/swup/src/modules/fetchPage.ts b/node_modules/swup/src/modules/fetchPage.ts new file mode 100644 index 00000000..96275448 --- /dev/null +++ b/node_modules/swup/src/modules/fetchPage.ts @@ -0,0 +1,114 @@ +import type Swup from '../Swup.js'; +import { Location } from '../helpers.js'; +import type { Visit } from './Visit.js'; + +/** A page object as used by swup and its cache. */ +export interface PageData { + /** The URL of the page */ + url: string; + /** The complete HTML response received from the server */ + html: string; +} + +/** Define how a page is fetched. */ +export interface FetchOptions extends Omit { + /** The request method. */ + method?: 'GET' | 'POST'; + /** The body of the request: raw string, form data object or URL params. */ + body?: string | FormData | URLSearchParams; + /** The request timeout in milliseconds. */ + timeout?: number; + /** Optional visit object with additional context. @internal */ + visit?: Visit; +} + +export class FetchError extends Error { + url: string; + status?: number; + aborted: boolean; + timedOut: boolean; + constructor( + message: string, + details: { url: string; status?: number; aborted?: boolean; timedOut?: boolean } + ) { + super(message); + this.name = 'FetchError'; + this.url = details.url; + this.status = details.status; + this.aborted = details.aborted || false; + this.timedOut = details.timedOut || false; + } +} + +/** + * Fetch a page from the server, return it and cache it. + */ +export async function fetchPage( + this: Swup, + url: URL | string, + options: FetchOptions = {} +): Promise { + url = Location.fromUrl(url).url; + + const { visit = this.visit } = options; + const headers = { ...this.options.requestHeaders, ...options.headers }; + const timeout = options.timeout ?? this.options.timeout; + const controller = new AbortController(); + const { signal } = controller; + options = { ...options, headers, signal }; + + let timedOut = false; + let timeoutId: ReturnType | null = null; + if (timeout && timeout > 0) { + timeoutId = setTimeout(() => { + timedOut = true; + controller.abort('timeout'); + }, timeout); + } + + // Allow hooking before this and returning a custom response-like object (e.g. custom fetch implementation) + let response: Response; + try { + response = await this.hooks.call( + 'fetch:request', + visit, + { url, options }, + (visit, { url, options }) => fetch(url, options) + ); + if (timeoutId) { + clearTimeout(timeoutId); + } + } catch (error) { + if (timedOut) { + this.hooks.call('fetch:timeout', visit, { url }); + throw new FetchError(`Request timed out: ${url}`, { url, timedOut }); + } + if ((error as Error)?.name === 'AbortError' || signal.aborted) { + throw new FetchError(`Request aborted: ${url}`, { url, aborted: true }); + } + throw error; + } + + const { status, url: responseUrl } = response; + const html = await response.text(); + + if (status === 500) { + this.hooks.call('fetch:error', visit, { status, response, url: responseUrl }); + throw new FetchError(`Server error: ${responseUrl}`, { status, url: responseUrl }); + } + + if (!html) { + throw new FetchError(`Empty response: ${responseUrl}`, { status, url: responseUrl }); + } + + // Resolve real url after potential redirect + const { url: finalUrl } = Location.fromUrl(responseUrl); + const page = { url: finalUrl, html }; + + // Write to cache for safe methods and non-redirects + if (visit.cache.write && (!options.method || options.method === 'GET') && url === finalUrl) { + this.cache.set(page.url, page); + } + + return page; +} diff --git a/node_modules/swup/src/modules/getAnchorElement.ts b/node_modules/swup/src/modules/getAnchorElement.ts new file mode 100755 index 00000000..b25de2bc --- /dev/null +++ b/node_modules/swup/src/modules/getAnchorElement.ts @@ -0,0 +1,32 @@ +import { query } from '../utils.js'; + +/** + * Find the anchor element for a given hash. + * + * @param hash Hash with or without leading '#' + * @returns The element, if found, or null. + * + * @see https://html.spec.whatwg.org/#find-a-potential-indicated-element + */ +export const getAnchorElement = (hash?: string): Element | null => { + if (hash && hash.charAt(0) === '#') { + hash = hash.substring(1); + } + + if (!hash) { + return null; + } + + const decoded = decodeURIComponent(hash); + let element = + document.getElementById(hash) || + document.getElementById(decoded) || + query(`a[name='${CSS.escape(hash)}']`) || + query(`a[name='${CSS.escape(decoded)}']`); + + if (!element && hash === 'top') { + element = document.body; + } + + return element; +}; diff --git a/node_modules/swup/src/modules/navigate.ts b/node_modules/swup/src/modules/navigate.ts new file mode 100755 index 00000000..7793f891 --- /dev/null +++ b/node_modules/swup/src/modules/navigate.ts @@ -0,0 +1,234 @@ +import type Swup from '../Swup.js'; +import { FetchError, type FetchOptions, type PageData } from './fetchPage.js'; +import { type VisitInitOptions, type Visit, VisitState } from './Visit.js'; +import { createHistoryRecord, updateHistoryRecord, Location, classify } from '../helpers.js'; +import { getContextualAttr } from '../utils.js'; + +export type HistoryAction = 'push' | 'replace'; +export type HistoryDirection = 'forwards' | 'backwards'; +export type NavigationToSelfAction = 'scroll' | 'navigate'; +export type CacheControl = Partial<{ read: boolean; write: boolean }>; + +/** Define how to navigate to a page. */ +type NavigationOptions = { + /** Whether this visit is animated. Default: `true` */ + animate?: boolean; + /** Name of a custom animation to run. */ + animation?: string; + /** History action to perform: `push` for creating a new history entry, `replace` for replacing the current entry. Default: `push` */ + history?: HistoryAction; + /** Whether this visit should read from or write to the cache. */ + cache?: CacheControl; +}; + +/** + * Navigate to a new URL. + * @param url The URL to navigate to. + * @param options Options for how to perform this visit. + * @returns Promise + */ +export function navigate( + this: Swup, + url: string, + options: NavigationOptions & FetchOptions = {}, + init: Omit = {} +) { + if (typeof url !== 'string') { + throw new Error(`swup.navigate() requires a URL parameter`); + } + + // Check if the visit should be ignored + if (this.shouldIgnoreVisit(url, { el: init.el, event: init.event })) { + window.location.assign(url); + return; + } + + const { url: to, hash } = Location.fromUrl(url); + + const visit = this.createVisit({ ...init, to, hash }); + this.performNavigation(visit, options); +} + +/** + * Start a visit to a new URL. + * + * Internal method that assumes the visit context has already been created. + * + * As a user, you should call `swup.navigate(url)` instead. + * + * @param url The URL to navigate to. + * @param options Options for how to perform this visit. + * @returns Promise + */ +export async function performNavigation( + this: Swup, + visit: Visit, + options: NavigationOptions & FetchOptions = {} +): Promise { + if (this.navigating) { + if (this.visit.state >= VisitState.ENTERING) { + // Currently navigating and content already loaded? Finish and queue + visit.state = VisitState.QUEUED; + this.onVisitEnd = () => this.performNavigation(visit, options); + return; + } else { + // Currently navigating and content not loaded? Abort running visit + await this.hooks.call('visit:abort', this.visit, undefined); + delete this.visit.to.document; + this.visit.state = VisitState.ABORTED; + } + } + + this.navigating = true; + this.visit = visit; + + const { el } = visit.trigger; + options.referrer = options.referrer || this.location.url; + + if (options.animate === false) { + visit.animation.animate = false; + } + + // Clean up old animation classes + if (!visit.animation.animate) { + this.classes.clear(); + } + + // Get history action from option or attribute on trigger element + const history = options.history || getContextualAttr(el, 'data-swup-history'); + if (typeof history === 'string' && ['push', 'replace'].includes(history)) { + visit.history.action = history as HistoryAction; + } + + // Get custom animation name from option or attribute on trigger element + const animation = options.animation || getContextualAttr(el, 'data-swup-animation'); + if (typeof animation === 'string') { + visit.animation.name = animation; + } + + // Sanitize cache option + if (typeof options.cache === 'object') { + visit.cache.read = options.cache.read ?? visit.cache.read; + visit.cache.write = options.cache.write ?? visit.cache.write; + } else if (options.cache !== undefined) { + visit.cache = { read: !!options.cache, write: !!options.cache }; + } + // Delete this so that window.fetch doesn't mis-interpret it + delete options.cache; + + try { + await this.hooks.call('visit:start', visit, undefined); + + visit.state = VisitState.STARTED; + + // Begin loading page + const page = this.hooks.call('page:load', visit, { options }, async (visit, args) => { + // Read from cache + let cachedPage: PageData | undefined; + if (visit.cache.read) { + cachedPage = this.cache.get(visit.to.url); + } + + args.page = cachedPage || (await this.fetchPage(visit.to.url, args.options)); + args.cache = !!cachedPage; + + return args.page; + }); + + /** + * When the page is loaded: mark the visit as loaded and save + * the raw html and a parsed document of the received page in the visit object + */ + page.then(({ html }) => { + visit.advance(VisitState.LOADED); + visit.to.html = html; + visit.to.document = new DOMParser().parseFromString(html, 'text/html'); + }); + + // Create/update history record if this is not a popstate call or leads to the same URL + const newUrl = visit.to.url + visit.to.hash; + if (!visit.history.popstate) { + if (visit.history.action === 'replace' || visit.to.url === this.location.url) { + updateHistoryRecord(newUrl); + } else { + this.currentHistoryIndex++; + createHistoryRecord(newUrl, { index: this.currentHistoryIndex }); + } + } + this.location = Location.fromUrl(newUrl); + + // Mark visit type with classes + if (visit.history.popstate) { + this.classes.add('is-popstate'); + } + if (visit.animation.name) { + this.classes.add(`to-${classify(visit.animation.name)}`); + } + + // Wait for page before starting to animate out? + if (visit.animation.wait) { + await page; + } + + // Check if failed/aborted in the meantime + if (visit.done) return; + + // Perform the actual transition: animate and replace content + await this.hooks.call('visit:transition', visit, undefined, async () => { + // No animation? Just await page and render + if (!visit.animation.animate) { + await this.hooks.call('animation:skip', undefined); + await this.renderPage(visit, await page); + return; + } + + // Animate page out, render page, animate page in + visit.advance(VisitState.LEAVING); + await this.animatePageOut(visit); + if (visit.animation.native && document.startViewTransition) { + await document.startViewTransition( + async () => await this.renderPage(visit, await page) + ).finished; + } else { + await this.renderPage(visit, await page); + } + await this.animatePageIn(visit); + }); + + // Check if failed/aborted in the meantime + if (visit.done) return; + + // Finalize visit + await this.hooks.call('visit:end', visit, undefined, () => this.classes.clear()); + visit.state = VisitState.COMPLETED; + this.navigating = false; + + /** Run eventually queued function */ + if (this.onVisitEnd) { + this.onVisitEnd(); + this.onVisitEnd = undefined; + } + } catch (error) { + // Return early if error is undefined or signals an aborted request + if (!error || (error as FetchError)?.aborted) { + visit.state = VisitState.ABORTED; + return; + } + + visit.state = VisitState.FAILED; + + // Log to console + console.error(error); + + // Remove current history entry, then load requested url in browser + this.options.skipPopStateHandling = () => { + window.location.assign(visit.to.url + visit.to.hash); + return true; + }; + + // Go back to the actual page we're still at + window.history.back(); + } finally { + delete visit.to.document; + } +} diff --git a/node_modules/swup/src/modules/plugins.ts b/node_modules/swup/src/modules/plugins.ts new file mode 100644 index 00000000..299d7cb3 --- /dev/null +++ b/node_modules/swup/src/modules/plugins.ts @@ -0,0 +1,77 @@ +import type Swup from '../Swup.js'; + +export type Plugin = { + /** Identify as a swup plugin */ + isSwupPlugin: true; + /** Name of this plugin */ + name: string; + /** Version of this plugin. Currently not in use, defined here for backward compatiblity. */ + version?: string; + /** The swup instance that mounted this plugin */ + swup?: Swup; + /** Version requirements of this plugin. Example: `{ swup: '>=4' }` */ + requires?: Record; + /** Run on mount */ + mount: () => void; + /** Run on unmount */ + unmount: () => void; + _beforeMount?: () => void; + _afterUnmount?: () => void; + _checkRequirements?: () => boolean; +}; + +const isSwupPlugin = (maybeInvalidPlugin: unknown): maybeInvalidPlugin is Plugin => { + // @ts-ignore: this might be anything, object or no + return Boolean(maybeInvalidPlugin?.isSwupPlugin); +}; + +/** Install a plugin. */ +export const use = function (this: Swup, plugin: unknown) { + if (!isSwupPlugin(plugin)) { + console.error('Not a swup plugin instance', plugin); + return; + } + + plugin.swup = this; + if (plugin._checkRequirements) { + if (!plugin._checkRequirements()) { + return; + } + } + if (plugin._beforeMount) { + plugin._beforeMount(); + } + plugin.mount(); + + this.plugins.push(plugin); + + return this.plugins; +}; + +/** Uninstall a plugin. */ +export function unuse(this: Swup, pluginOrName: Plugin | string) { + const plugin = this.findPlugin(pluginOrName); + if (!plugin) { + console.error('No such plugin', plugin); + return; + } + + plugin.unmount(); + if (plugin._afterUnmount) { + plugin._afterUnmount(); + } + + this.plugins = this.plugins.filter((p) => p !== plugin); + + return this.plugins; +} + +/** Find a plugin by name or reference. */ +export function findPlugin(this: Swup, pluginOrName: Plugin | string) { + return this.plugins.find( + (plugin) => + plugin === pluginOrName || + plugin.name === pluginOrName || + plugin.name === `Swup${String(pluginOrName)}` + ); +} diff --git a/node_modules/swup/src/modules/renderPage.ts b/node_modules/swup/src/modules/renderPage.ts new file mode 100755 index 00000000..9dc1f9f5 --- /dev/null +++ b/node_modules/swup/src/modules/renderPage.ts @@ -0,0 +1,51 @@ +import { updateHistoryRecord, getCurrentUrl, classify, Location } from '../helpers.js'; +import type Swup from '../Swup.js'; +import type { PageData } from './fetchPage.js'; +import { VisitState, type Visit } from './Visit.js'; + +/** + * Render the next page: replace the content and update scroll position. + */ +export const renderPage = async function (this: Swup, visit: Visit, page: PageData): Promise { + // Check if failed/aborted in the meantime + if (visit.done) return; + + visit.advance(VisitState.ENTERING); + + const { url } = page; + + // update state if the url was redirected + if (!this.isSameResolvedUrl(getCurrentUrl(), url)) { + updateHistoryRecord(url); + this.location = Location.fromUrl(url); + visit.to.url = this.location.url; + visit.to.hash = this.location.hash; + } + + // replace content: allow handlers and plugins to overwrite paga data and containers + await this.hooks.call('content:replace', visit, { page }, (visit, { page }) => { + this.classes.remove('is-leaving'); + // only add for animated page loads + if (visit.animation.animate) { + this.classes.add('is-rendering'); + } + const success = this.replaceContent(visit); + if (!success) { + throw new Error('[swup] Container mismatch, aborting'); + } + if (visit.animation.animate) { + // Make sure to add these classes to new containers as well + this.classes.add('is-changing', 'is-animating', 'is-rendering'); + if (visit.animation.name) { + this.classes.add(`to-${classify(visit.animation.name)}`); + } + } + }); + + // scroll into view: either anchor or top of page + await this.hooks.call('content:scroll', visit, undefined, () => { + return this.scrollToContent(visit); + }); + + await this.hooks.call('page:view', visit, { url: this.location.url, title: document.title }); +}; diff --git a/node_modules/swup/src/modules/replaceContent.ts b/node_modules/swup/src/modules/replaceContent.ts new file mode 100755 index 00000000..62859acd --- /dev/null +++ b/node_modules/swup/src/modules/replaceContent.ts @@ -0,0 +1,51 @@ +import type Swup from '../Swup.js'; +import { query, queryAll } from '../utils.js'; +import type { Visit } from './Visit.js'; + +/** + * Perform the replacement of content after loading a page. + * + * @returns Whether all containers were replaced. + */ +export const replaceContent = function (this: Swup, visit: Visit): boolean { + const incomingDocument = visit.to.document; + if (!incomingDocument) return false; + + // Update browser title + const title = incomingDocument.querySelector('title')?.innerText || ''; + document.title = title; + + // Save persisted elements + const persistedElements = queryAll('[data-swup-persist]:not([data-swup-persist=""])'); + + // Update content containers + const replaced = visit.containers + .map((selector) => { + const currentEl = document.querySelector(selector); + const incomingEl = incomingDocument.querySelector(selector); + if (currentEl && incomingEl) { + currentEl.replaceWith(incomingEl.cloneNode(true)); + return true; + } + if (!currentEl) { + console.warn(`[swup] Container missing in current document: ${selector}`); + } + if (!incomingEl) { + console.warn(`[swup] Container missing in incoming document: ${selector}`); + } + return false; + }) + .filter(Boolean); + + // Restore persisted elements + persistedElements.forEach((existing) => { + const key = existing.getAttribute('data-swup-persist'); + const replacement = query(`[data-swup-persist="${key}"]`); + if (replacement && replacement !== existing) { + replacement.replaceWith(existing); + } + }); + + // Return true if all containers were replaced + return replaced.length === visit.containers.length; +}; diff --git a/node_modules/swup/src/modules/resolveUrl.ts b/node_modules/swup/src/modules/resolveUrl.ts new file mode 100755 index 00000000..a687d6fe --- /dev/null +++ b/node_modules/swup/src/modules/resolveUrl.ts @@ -0,0 +1,33 @@ +import type Swup from '../Swup.js'; + +/** + * Utility function to validate and run the global option 'resolveUrl' + * @param {string} url + * @returns {string} the resolved url + */ +export function resolveUrl(this: Swup, url: string): string { + if (typeof this.options.resolveUrl !== 'function') { + console.warn(`[swup] options.resolveUrl expects a callback function.`); + return url; + } + const result = this.options.resolveUrl(url); + if (!result || typeof result !== 'string') { + console.warn(`[swup] options.resolveUrl needs to return a url`); + return url; + } + if (result.startsWith('//') || result.startsWith('http')) { + console.warn(`[swup] options.resolveUrl needs to return a relative url`); + return url; + } + return result; +} + +/** + * Compares the resolved version of two paths and returns true if they are the same + * @param {string} url1 + * @param {string} url2 + * @returns {boolean} + */ +export function isSameResolvedUrl(this: Swup, url1: string, url2: string): boolean { + return this.resolveUrl(url1) === this.resolveUrl(url2); +} diff --git a/node_modules/swup/src/modules/scrollToContent.ts b/node_modules/swup/src/modules/scrollToContent.ts new file mode 100755 index 00000000..0c081a28 --- /dev/null +++ b/node_modules/swup/src/modules/scrollToContent.ts @@ -0,0 +1,38 @@ +import type Swup from '../Swup.js'; +import type { Visit } from './Visit.js'; + +/** + * Update the scroll position after page render. + * @returns Promise + */ +export const scrollToContent = function (this: Swup, visit: Visit): boolean { + const options: ScrollIntoViewOptions = { behavior: 'auto' }; + const { target, reset } = visit.scroll; + const scrollTarget = target ?? visit.to.hash; + + let scrolled = false; + + if (scrollTarget) { + scrolled = this.hooks.callSync( + 'scroll:anchor', + visit, + { hash: scrollTarget, options }, + (visit, { hash, options }) => { + const anchor = this.getAnchorElement(hash); + if (anchor) { + anchor.scrollIntoView(options); + } + return !!anchor; + } + ); + } + + if (reset && !scrolled) { + scrolled = this.hooks.callSync('scroll:top', visit, { options }, (visit, { options }) => { + window.scrollTo({ top: 0, left: 0, ...options }); + return true; + }); + } + + return scrolled; +}; diff --git a/node_modules/swup/src/utils.ts b/node_modules/swup/src/utils.ts new file mode 100644 index 00000000..7a87b145 --- /dev/null +++ b/node_modules/swup/src/utils.ts @@ -0,0 +1,4 @@ +// Re-export all utils to allow custom package export path +// e.g. import { queryAll } from 'swup/utils' + +export * from './utils/index.js'; diff --git a/node_modules/swup/src/utils/index.ts b/node_modules/swup/src/utils/index.ts new file mode 100644 index 00000000..2d0e77a2 --- /dev/null +++ b/node_modules/swup/src/utils/index.ts @@ -0,0 +1,68 @@ +/** Find an element by selector. */ +export const query = (selector: string, context: Document | Element = document) => { + return context.querySelector(selector); +}; + +/** Find a set of elements by selector. */ +export const queryAll = ( + selector: string, + context: Document | Element = document +): HTMLElement[] => { + return Array.from(context.querySelectorAll(selector)); +}; + +/** Return a Promise that resolves after the next event loop. */ +export const nextTick = (): Promise => { + return new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + resolve(); + }); + }); + }); +}; + +/** Check if an object is a Promise or a Thenable */ +export function isPromise(obj: unknown): obj is PromiseLike { + return ( + !!obj && + (typeof obj === 'object' || typeof obj === 'function') && + typeof (obj as Record).then === 'function' + ); +} + +/** Call a function as a Promise. Resolves with the returned Promsise or immediately. */ +// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any +export function runAsPromise(func: Function, args: unknown[] = []): Promise { + return new Promise((resolve, reject) => { + const result: unknown = func(...args); + if (isPromise(result)) { + result.then(resolve, reject); + } else { + resolve(result); + } + }); +} + +/** + * Force a layout reflow, e.g. after adding classnames + * @see https://stackoverflow.com/a/21665117/3759615 + */ +export function forceReflow(element?: HTMLElement): void { + element = element || document.body; + element?.getBoundingClientRect(); +} + +/** + * Read data attribute from closest element with that attribute. + * + * Returns `undefined` if no element is found or attribute is missing. + * Returns `true` if attribute is present without a value. + */ +export function getContextualAttr( + el: Element | undefined, + attr: string +): string | boolean | undefined { + const target = el?.closest(`[${attr}]`); + return target?.hasAttribute(attr) ? target?.getAttribute(attr) || true : undefined; +} -- cgit v1.2.3