/
index.ts
executable file
路403 lines (347 loc) 路 11.6 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
import Plugin from '@swup/plugin';
import { getCurrentUrl, Location } from 'swup';
import type {
DelegateEvent,
DelegateEventHandler,
DelegateEventUnsubscribe,
PageData,
HookDefaultHandler
} from 'swup';
import { deviceSupportsHover, networkSupportsPreloading, whenIdle } from './util.js';
import createQueue, { Queue } from './queue.js';
import createObserver, { Observer } from './observer.js';
declare module 'swup' {
export interface Swup {
/**
* Preload links by passing in either:
* - a URL or an array of URLs
* - a link element or an array of link elements
*/
preload?: SwupPreloadPlugin['preload'];
/**
* Preload any links on the current page manually marked for preloading.
*/
preloadLinks?: () => void;
}
export interface HookDefinitions {
'link:hover': { el: HTMLAnchorElement; event: DelegateEvent };
'page:preload': { page?: PageData };
}
export interface HookReturnValues {
'page:preload': Promise<PageData>;
}
}
type VisibleLinkPreloadOptions = {
/** Enable preloading of links entering the viewport */
enabled: boolean;
/** How much area of a link must be visible to preload it: 0 to 1.0 */
threshold: number;
/** How long a link must be visible to preload it, in milliseconds */
delay: number;
/** Containers to look for links in */
containers: string[];
/** Callback for opting out selected elements from preloading */
ignore: (el: HTMLAnchorElement) => boolean;
};
export type PluginOptions = {
/** The *concurrency limit* for simultaneous requests when preloading. */
throttle: number;
/** Preload the initial page to allow instant back-button navigation. */
preloadInitialPage: boolean;
/** Preload links when they are hovered, touched or focused. */
preloadHoveredLinks: boolean;
/** Preload links when they enter the viewport. */
preloadVisibleLinks: VisibleLinkPreloadOptions;
};
export type PluginInitOptions = Omit<PluginOptions, 'preloadVisibleLinks'> & {
/** Preload links when they enter the viewport. */
preloadVisibleLinks: boolean | Partial<VisibleLinkPreloadOptions>;
};
type PreloadOptions = {
/** Priority of this preload: `true` for high, `false` for low. */
priority?: boolean;
};
export default class SwupPreloadPlugin extends Plugin {
name = 'SwupPreloadPlugin';
requires = { swup: '>=4.5' };
defaults: PluginOptions = {
throttle: 5,
preloadInitialPage: true,
preloadHoveredLinks: true,
preloadVisibleLinks: {
enabled: false,
threshold: 0.2,
delay: 500,
containers: ['body'],
ignore: () => false
}
};
options: PluginOptions;
protected queue: Queue;
protected preloadObserver?: Observer;
protected preloadPromises = new Map<string, Promise<PageData | void>>();
protected mouseEnterDelegate?: DelegateEventUnsubscribe;
protected touchStartDelegate?: DelegateEventUnsubscribe;
protected focusDelegate?: DelegateEventUnsubscribe;
constructor(options: Partial<PluginInitOptions> = {}) {
super();
// Set all options except `preloadVisibleLinks` which is sanitized below
const { preloadVisibleLinks, ...otherOptions } = options;
this.options = { ...this.defaults, ...otherOptions };
// Sanitize/merge `preloadVisibleLinks`` option
if (typeof preloadVisibleLinks === 'object') {
this.options.preloadVisibleLinks = {
...this.options.preloadVisibleLinks,
enabled: true,
...preloadVisibleLinks
};
} else {
this.options.preloadVisibleLinks.enabled = Boolean(preloadVisibleLinks);
}
// Bind public methods
this.preload = this.preload.bind(this);
// Create global priority queue
this.queue = createQueue(this.options.throttle);
}
mount() {
const swup = this.swup;
if (!swup.options.cache) {
console.warn('SwupPreloadPlugin: swup cache needs to be enabled for preloading');
return;
}
swup.hooks.create('page:preload');
swup.hooks.create('link:hover');
// @ts-ignore: non-matching signatures (TODO: fix properly)
swup.preload = this.preload;
swup.preloadLinks = this.preloadLinks;
// Inject custom promise whenever a page is loaded
this.replace('page:load', this.onPageLoad);
// Preload links with [data-swup-preload] attr
this.preloadLinks();
this.on('page:view', () => this.preloadLinks());
// Preload visible links in viewport
if (this.options.preloadVisibleLinks.enabled) {
this.preloadVisibleLinks();
this.on('page:view', () => this.preloadVisibleLinks());
}
// Preload links on attention
if (this.options.preloadHoveredLinks) {
this.preloadLinksOnAttention();
}
// Cache unmodified DOM of initial/current page
if (this.options.preloadInitialPage) {
this.preload(getCurrentUrl());
}
}
unmount() {
this.swup.preload = undefined;
this.swup.preloadLinks = undefined;
this.preloadPromises.clear();
this.mouseEnterDelegate?.destroy();
this.touchStartDelegate?.destroy();
this.focusDelegate?.destroy();
this.stopPreloadingVisibleLinks();
}
/**
* Before core page load: return existing preload promise if available.
*/
protected onPageLoad: HookDefaultHandler<'page:load'> = (visit, args, defaultHandler) => {
const { url } = visit.to;
if (url && this.preloadPromises.has(url)) {
return this.preloadPromises.get(url) as Promise<PageData>;
}
return defaultHandler!(visit, args);
};
/**
* When hovering over a link: preload the linked page with high priority.
*/
protected onMouseEnter: DelegateEventHandler = async (event) => {
// Make sure mouseenter is only fired once even on links with nested html
if (event.target !== event.delegateTarget) return;
// Return early on devices that don't support hover
if (!deviceSupportsHover()) return;
const el = event.delegateTarget;
if (!(el instanceof HTMLAnchorElement)) return;
this.swup.hooks.callSync('link:hover', undefined, { el, event });
this.preload(el, { priority: true });
};
/**
* When touching a link: preload the linked page with high priority.
*/
protected onTouchStart: DelegateEventHandler = (event) => {
// Return early on devices that support hover
if (deviceSupportsHover()) return;
const el = event.delegateTarget;
if (!(el instanceof HTMLAnchorElement)) return;
this.preload(el, { priority: true });
};
/**
* When focussing a link: preload the linked page with high priority.
*/
protected onFocus: DelegateEventHandler = (event) => {
const el = event.delegateTarget;
if (!(el instanceof HTMLAnchorElement)) return;
this.preload(el, { priority: true });
};
/**
* Preload links.
*
* The method accepts either:
* - a URL or an array of URLs
* - a link element or an array of link elements
*
* It returns either:
* - a Promise resolving to the page data, if requesting a single page
* - a Promise resolving to an array of page data, if requesting multiple pages
*/
async preload(url: string, options?: PreloadOptions): Promise<PageData | void>;
async preload(urls: string[], options?: PreloadOptions): Promise<(PageData | void)[]>;
async preload(el: HTMLAnchorElement, options?: PreloadOptions): Promise<PageData | void>;
async preload(els: HTMLAnchorElement[], options?: PreloadOptions): Promise<(PageData | void)[]>;
async preload(
input: string | HTMLAnchorElement,
options?: PreloadOptions
): Promise<PageData | void>;
async preload(
input: string | string[] | HTMLAnchorElement | HTMLAnchorElement[],
options: PreloadOptions = {}
): Promise<PageData | (PageData | void)[] | void> {
let url: string;
let el: HTMLAnchorElement | undefined;
const priority = options.priority ?? false;
// Allow passing in array of urls or elements
if (Array.isArray(input)) {
return Promise.all(input.map((link) => this.preload(link)));
}
// Allow passing in an anchor element
else if (input instanceof HTMLAnchorElement) {
el = input;
({ url } = Location.fromElement(input));
}
// Allow passing in a url
else if (typeof input === 'string') {
url = input;
}
// Disallow other types
else {
return;
}
// Already preloading? Return existing promise
if (this.preloadPromises.has(url)) {
return this.preloadPromises.get(url);
}
// Should we preload?
if (!this.shouldPreload(url, { el })) {
return;
}
// Queue the preload with either low or high priority
// The actual preload will happen when a spot in the queue is available
const queuedPromise = new Promise<PageData | void>((resolve) => {
this.queue.add(() => {
this.performPreload(url)
.catch(() => {})
.then((page) => resolve(page))
.finally(() => {
this.queue.next();
this.preloadPromises.delete(url);
});
}, priority);
});
this.preloadPromises.set(url, queuedPromise);
return queuedPromise;
}
/**
* Preload any links on the current page manually marked for preloading.
*
* Links are marked for preloading by:
* - adding a `data-swup-preload` attribute to the link itself
* - adding a `data-swup-preload-all` attribute to a container of multiple links
*/
preloadLinks(): void {
whenIdle(() => {
const selector = 'a[data-swup-preload], [data-swup-preload-all] a';
const links = Array.from(document.querySelectorAll<HTMLAnchorElement>(selector));
links.forEach((el) => this.preload(el));
});
}
/**
* Register handlers for preloading on attention:
* - mouseenter
* - touchstart
* - focus
*/
protected preloadLinksOnAttention() {
const { swup } = this;
const { linkSelector: selector } = swup.options;
const opts = { passive: true, capture: true };
this.mouseEnterDelegate = swup.delegateEvent(
selector,
'mouseenter',
this.onMouseEnter,
opts
);
this.touchStartDelegate = swup.delegateEvent(
selector,
'touchstart',
this.onTouchStart,
opts
);
this.focusDelegate = swup.delegateEvent(selector, 'focus', this.onFocus, opts);
}
/**
* Start observing links in the viewport for preloading.
* Calling this repeatedly re-checks for links after DOM updates.
*/
protected preloadVisibleLinks(): void {
// Scan DOM for new links on repeated calls
if (this.preloadObserver) {
this.preloadObserver.update();
return;
}
const { threshold, delay, containers } = this.options.preloadVisibleLinks;
const callback = (el: HTMLAnchorElement) => this.preload(el);
const filter = (el: HTMLAnchorElement) => {
/** First, run the custom callback */
if (this.options.preloadVisibleLinks.ignore(el)) return false;
/** Second, run all default checks */
return this.shouldPreload(el.href, { el });
};
this.preloadObserver = createObserver({ threshold, delay, containers, callback, filter });
this.preloadObserver.start();
}
/**
* Stop observing links in the viewport for preloading.
*/
protected stopPreloadingVisibleLinks(): void {
if (this.preloadObserver) {
this.preloadObserver.stop();
}
}
/**
* Check whether a URL and/or element should trigger a preload.
*/
protected shouldPreload(location: string, { el }: { el?: HTMLAnchorElement } = {}): boolean {
const { url, href } = Location.fromUrl(location);
// Network too slow?
if (!networkSupportsPreloading()) return false;
// Already in cache?
if (this.swup.cache.has(url)) return false;
// Already preloading?
if (this.preloadPromises.has(url)) return false;
// Should be ignored anyway?
if (this.swup.shouldIgnoreVisit(href, { el })) return false;
// Special condition for links: points to current page?
if (el && this.swup.resolveUrl(url) === this.swup.resolveUrl(getCurrentUrl())) return false;
return true;
}
/**
* Perform the actual preload fetch and trigger the preload hook.
*/
protected async performPreload(url: string): Promise<PageData> {
const page = await this.swup.hooks.call('page:preload', undefined, {}, async (visit, args) => {
args.page = await this.swup.fetchPage(url);
return args.page;
});
return page;
}
}