UNPKG

swup

Version:

Versatile and extensible page transition library for server-rendered websites

592 lines (538 loc) 21.4 kB
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<MouseEvent> }; '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> | boolean; 'fetch:request': Promise<Response>; 'page:load': Promise<PageData>; 'scroll:top': boolean; 'scroll:anchor': boolean; } export type HookArguments<T extends HookName> = HookDefinitions[T]; export type HookName = keyof HookDefinitions; export type HookNameWithModifier = `${HookName}.${HookModifier}`; type HookModifier = 'once' | 'before' | 'replace'; /** A generic hook handler. */ export type HookHandler<T extends HookName> = ( /** Context about the current visit. */ visit: Visit, /** Local arguments passed into the handler. */ args: HookArguments<T> ) => Promise<unknown> | unknown; /** A default hook handler with an expected return type. */ export type HookDefaultHandler<T extends HookName> = ( /** Context about the current visit. */ visit: Visit, /** Local arguments passed into the handler. */ args: HookArguments<T>, /** Default handler to be executed. Available if replacing an internal hook handler. */ defaultHandler?: HookDefaultHandler<T> ) => T extends keyof HookReturnValues ? HookReturnValues[T] : Promise<unknown> | unknown; export type Handlers = { [K in HookName]: HookHandler<K>[]; }; export type HookInitOptions = { [K in HookName as K | `${K}.${HookModifier}`]: HookHandler<K>; } & { [K in HookName as K | `${K}.${HookModifier}.${HookModifier}`]: HookHandler<K>; }; /** 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<T> | HookDefaultHandler<T> = HookHandler<T> > = { id: number; hook: T; handler: H; defaultHandler?: HookDefaultHandler<T>; } & HookOptions; type HookEventDetail = { hook: HookName; args: unknown; visit: Visit; }; export type HookEvent = CustomEvent<HookEventDetail>; type HookLedger<T extends HookName> = Map<HookHandler<T>, HookRegistration<T>>; interface HookRegistry extends Map<HookName, HookLedger<HookName>> { get<K extends HookName>(key: K): HookLedger<K> | undefined; set<K extends HookName>(key: K, value: HookLedger<K>): 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<T extends HookName>(hook: T): HookLedger<T> | 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<T extends HookName, O extends HookOptions>(hook: T, handler: HookDefaultHandler<T>, options: O & { replace: true }): HookUnregister; // prettier-ignore // Overload: passed in handler options on<T extends HookName, O extends HookOptions>(hook: T, handler: HookHandler<T>, options: O): HookUnregister; // prettier-ignore // Overload: no handler options on<T extends HookName>(hook: T, handler: HookHandler<T>): HookUnregister; // prettier-ignore // Implementation on<T extends HookName, O extends HookOptions>( hook: T, handler: O['replace'] extends true ? HookDefaultHandler<T> : HookHandler<T>, options: Partial<O> = {} ): HookUnregister { const ledger = this.get(hook); if (!ledger) { console.warn(`Hook '${hook}' not found.`); return () => {}; } const id = ledger.size + 1; const registration: HookRegistration<T> = { ...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<T extends HookName>(hook: T, handler: HookHandler<T>, options: HookOptions): HookUnregister; // prettier-ignore // Overload: no handler options before<T extends HookName>(hook: T, handler: HookHandler<T>): HookUnregister; // Implementation before<T extends HookName>( hook: T, handler: HookHandler<T>, 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<T extends HookName>(hook: T, handler: HookDefaultHandler<T>, options: HookOptions): HookUnregister; // prettier-ignore // Overload: no handler options replace<T extends HookName>(hook: T, handler: HookDefaultHandler<T>): HookUnregister; // prettier-ignore // Implementation replace<T extends HookName>( hook: T, handler: HookDefaultHandler<T>, 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<T extends HookName>(hook: T, handler: HookHandler<T>, options: HookOptions): HookUnregister; // prettier-ignore // Overload: no handler options once<T extends HookName>(hook: T, handler: HookHandler<T>): HookUnregister; // Implementation once<T extends HookName>( hook: T, handler: HookHandler<T>, 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<T extends HookName>(hook: T, handler: HookHandler<T> | HookDefaultHandler<T>): void; // Overload: unregister all handlers off<T extends HookName>(hook: T): void; // Implementation off<T extends HookName>(hook: T, handler?: HookHandler<T> | HookDefaultHandler<T>): 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<T extends HookName>(hook: T, visit: Visit | undefined, args: HookArguments<T>, defaultHandler?: HookDefaultHandler<T>): Promise<Awaited<ReturnType<HookDefaultHandler<T>>>>; // prettier-ignore // Overload: legacy order of arguments, with visit missing async call<T extends HookName>(hook: T, args: HookArguments<T>, defaultHandler?: HookDefaultHandler<T>): Promise<Awaited<ReturnType<HookDefaultHandler<T>>>>; // prettier-ignore // Implementation async call<T extends HookName>( hook: T, arg1: Visit | HookArguments<T>, arg2: HookArguments<T> | HookDefaultHandler<T>, arg3?: HookDefaultHandler<T> ): Promise<Awaited<ReturnType<HookDefaultHandler<T>>>> { 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<T extends HookName>(hook: T, visit: Visit | undefined, args: HookArguments<T>, defaultHandler?: HookDefaultHandler<T>): ReturnType<HookDefaultHandler<T>>; // prettier-ignore // Overload: legacy order of arguments, with visit missing callSync<T extends HookName>(hook: T, args: HookArguments<T>, defaultHandler?: HookDefaultHandler<T>): ReturnType<HookDefaultHandler<T>>; // prettier-ignore // Implementation callSync<T extends HookName>( hook: T, arg1: Visit | HookArguments<T>, arg2: HookArguments<T> | HookDefaultHandler<T>, arg3?: HookDefaultHandler<T> ): ReturnType<HookDefaultHandler<T>> { 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<T extends HookName>( hook: T, arg1: Visit | HookArguments<T> | undefined, arg2: HookArguments<T> | HookDefaultHandler<T>, arg3?: HookDefaultHandler<T> ): [Visit | undefined, HookArguments<T>, HookDefaultHandler<T> | 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<T>, arg2 as HookDefaultHandler<T>]; } else { // Default positioning: visit passed in as first argument return [arg1, arg2 as HookArguments<T>, 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<T extends HookName>(registrations: HookRegistration<T, HookDefaultHandler<T>>[], visit: Visit | undefined, args: HookArguments<T>, rethrow: true): Promise<Awaited<ReturnType<HookDefaultHandler<T>>>[]>; // prettier-ignore // Overload: running user handler: expect no specific type protected async run<T extends HookName>(registrations: HookRegistration<T>[], visit: Visit | undefined, args: HookArguments<T>): Promise<unknown[]>; // prettier-ignore // Implementation protected async run<T extends HookName, R extends HookRegistration<T>[]>( registrations: R, visit: Visit | undefined = this.swup.visit, args: HookArguments<T>, rethrow: boolean = false ): Promise<Awaited<ReturnType<HookDefaultHandler<T>>> | 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<T extends HookName>(registrations: HookRegistration<T, HookDefaultHandler<T>>[], visit: Visit | undefined, args: HookArguments<T>, rethrow: true): ReturnType<HookDefaultHandler<T>>[]; // prettier-ignore // Overload: running user handler: expect no specific type protected runSync<T extends HookName>(registrations: HookRegistration<T>[], visit: Visit | undefined, args: HookArguments<T>): unknown[]; // prettier-ignore // Implementation protected runSync<T extends HookName, R extends HookRegistration<T>[]>( registrations: R, visit: Visit | undefined = this.swup.visit, args: HookArguments<T>, rethrow: boolean = false ): (ReturnType<HookDefaultHandler<T>> | 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<T>)(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<T extends HookName>(hook: T, defaultHandler?: HookDefaultHandler<T>) { 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>): T is HookRegistration<T, HookDefaultHandler<T>> => 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<T, HookDefaultHandler<T>>[] = []; if (defaultHandler) { handler = [{ id: 0, hook, handler: defaultHandler }]; if (replaced) { const index = replace.length - 1; const { handler: replacingHandler, once } = replace[index]; const createDefaultHandler = (index: number): HookDefaultHandler<T> | 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, once, handler: replacingHandler, defaultHandler: nestedDefaultHandler }]; // prettier-ignore } } 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<T extends HookName>( a: HookRegistration<T>, b: HookRegistration<T> ): 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<T extends HookName>( hook: T, visit: Visit | undefined, args?: HookArguments<T> ): void { if (visit?.done) return; const detail: HookEventDetail = { hook, args, visit: visit || this.swup.visit }; document.dispatchEvent( new CustomEvent<HookEventDetail>(`swup:any`, { detail, bubbles: true }) ); document.dispatchEvent( new CustomEvent<HookEventDetail>(`swup:${hook}`, { detail, bubbles: true }) ); } /** * Parse a hook name into the name and any modifiers. * @param hook Name of the hook. */ parseName(hook: HookName | HookNameWithModifier): [HookName, Partial<HookOptions>] { const [name, ...modifiers] = hook.split('.'); const options = modifiers.reduce((acc, mod) => ({ ...acc, [mod]: true }), {}); return [name as HookName, options]; } }