-
-
Notifications
You must be signed in to change notification settings - Fork 3.4k
/
index.ts
343 lines (312 loc) · 12.5 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
/*
* Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
EMPTY,
Observable,
Subject,
bufferCount,
catchError,
concat,
debounceTime,
distinctUntilKeyChanged,
endWith,
fromEvent,
ignoreElements,
map,
of,
share,
skip,
startWith,
switchMap,
take,
withLatestFrom
} from "rxjs"
import { configuration, feature } from "~/_"
import {
Viewport,
getElements,
getLocation,
getOptionalElement,
request,
setLocation,
setLocationHash
} from "~/browser"
import { getComponentElement } from "~/components"
import { fetchSitemap } from "../sitemap"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Setup options
*/
interface SetupOptions {
location$: Subject<URL> /* Location subject */
viewport$: Observable<Viewport> /* Viewport observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Set up instant navigation
*
* This is a heavily orchestrated operation - see inline comments to learn how
* this works with Material for MkDocs, and how you can hook into it.
*
* @param options - Options
*
* @returns Document observable
*/
export function setupInstantLoading(
{ location$, viewport$ }: SetupOptions
): Observable<Document> {
const config = configuration()
if (location.protocol === "file:")
return EMPTY
// Load sitemap immediately, so we have it available when the user initiates
// the first instant navigation request, and canonicalize URLs to the current
// base URL. The base URL will remain stable in between loads, as it's only
// read at the first initialization of the application.
const sitemap$ = fetchSitemap()
.pipe(
map(paths => paths.map(path => `${new URL(path, config.base)}`))
)
// Intercept inter-site navigation - to keep the number of event listeners
// low we use the fact that uncaptured events bubble up to the body. This also
// has the nice property that we don't need to detach and then again attach
// event listeners when instant navigation occurs.
const instant$ = fromEvent<MouseEvent>(document.body, "click")
.pipe(
withLatestFrom(sitemap$),
switchMap(([ev, sitemap]) => {
if (!(ev.target instanceof Element))
return EMPTY
// Skip, as target is not within a link - clicks on non-link elements
// are also captured, which we need to exclude from processing.
const el = ev.target.closest("a")
if (el === null)
return EMPTY
// Skip, as link opens in new window - we now know we have captured a
// click on a link, but the link either has a `target` property defined,
// or the user pressed the `meta` or `ctrl` key to open it in a new
// window. Thus, we need to filter those events, too.
if (el.target || ev.metaKey || ev.ctrlKey)
return EMPTY
// Next, we must check if the URL is relevant for us, i.e., if it's an
// internal link to a page that is managed by MkDocs. Only then we can
// be sure that the structure of the page to be loaded adheres to the
// current document structure and can subsequently be injected into it
// without doing a full reload. For this reason, we must canonicalize
// the URL by removing all search parameters and hash fragments.
const url = new URL(el.href)
url.search = url.hash = ""
// Skip, if URL is not included in the sitemap - this could be the case
// when linking between versions or languages, or to another page that
// the author included as part of the build, but that is not managed by
// MkDocs. In that case we must not continue with instant navigation.
if (!sitemap.includes(`${url}`))
return EMPTY
// We now know that we have a link to an internal page, so we prevent
// the browser from navigation and emit the URL for instant navigation.
// Note that this also includes anchor links, which means we need to
// implement anchor positioning ourselves.
ev.preventDefault()
return of(new URL(el.href))
}),
share()
)
// Before fetching for the first time, resolve the absolute favicon position,
// as the browser will try to fetch the icon immediately.
instant$.pipe(take(1))
.subscribe(() => {
const favicon = getOptionalElement<HTMLLinkElement>("link[rel=icon]")
if (typeof favicon !== "undefined")
favicon.href = favicon.href
})
// Enable scroll restoration before window unloads - this is essential to
// ensure that full reloads (F5) restore the viewport offset correctly. If
// only popstate events wouldn't reset the scroll position prior to their
// emission, we could just reset this in popstate. Meh.
fromEvent(window, "beforeunload")
.subscribe(() => {
history.scrollRestoration = "auto"
})
// When an instant navigation event occurs, disable scroll restoration, since
// we must normalize and synchronize the behavior across all browsers. For
// instance, when the user clicks the back or forward button, the browser
// would immediately jump to the position of the previous document.
instant$.pipe(withLatestFrom(viewport$))
.subscribe(([url, { offset }]) => {
history.scrollRestoration = "manual"
// While it would be better UX to defer the history state change until the
// document was fully fetched and parsed, we must schedule it here, since
// popstate events are emitted when history state changes happen. Moreover
// we need to back up the current viewport offset, so we can restore it
// when popstate events occur, e.g., when the browser's back and forward
// buttons are used for navigation.
history.replaceState(offset, "")
history.pushState(null, "", url)
})
// Emit URL that should be fetched via instant loading on location subject,
// which was passed into this function. The idea is that instant loading can
// be intercepted by other parts of the application, which can synchronously
// back up or restore state before instant loading happens.
instant$.subscribe(location$)
// Fetch document - when fetching, we could use `responseType: document`, but
// since all MkDocs links are relative, we need to make sure that the current
// location matches the document we just loaded. Otherwise any relative links
// in the document might use the old location. If the request fails for some
// reason, we fall back to regular navigation and set the location explicitly,
// which will force-load the page. Furthermore, we must pre-warm the buffer
// for the duplicate check, or the first click on an anchor link will also
// trigger an instant loading event, which doesn't make sense.
const response$ = location$
.pipe(
startWith(getLocation()),
distinctUntilKeyChanged("pathname"),
skip(1),
switchMap(url => request(url)
.pipe(
catchError(() => {
setLocation(url)
return EMPTY
})
)
)
)
// Initialize the DOM parser, parse the returned HTML, and replace selected
// meta tags and components before handing control down to the application.
const dom = new DOMParser()
const document$ = response$
.pipe(
switchMap(res => res.text()),
switchMap(res => {
const document = dom.parseFromString(res, "text/html")
for (const selector of [
// Meta tags
"title",
"link[rel=canonical]",
"meta[name=author]",
"meta[name=description]",
// Components
"[data-md-component=announce]",
"[data-md-component=container]",
"[data-md-component=header-topic]",
"[data-md-component=outdated]",
"[data-md-component=logo]",
"[data-md-component=skip]",
...feature("navigation.tabs.sticky")
? ["[data-md-component=tabs]"]
: []
]) {
const source = getOptionalElement(selector)
const target = getOptionalElement(selector, document)
if (
typeof source !== "undefined" &&
typeof target !== "undefined"
) {
source.replaceWith(target)
}
}
// After meta tags and components were replaced, re-evaluate scripts
// that were provided by the author as part of Markdown files.
const container = getComponentElement("container")
return concat(getElements("script", container))
.pipe(
switchMap(el => {
const script = document.createElement("script")
if (el.src) {
for (const name of el.getAttributeNames())
script.setAttribute(name, el.getAttribute(name)!)
el.replaceWith(script)
// Complete when script is loaded
return new Observable(observer => {
script.onload = () => observer.complete()
})
// Complete immediately
} else {
script.textContent = el.textContent
el.replaceWith(script)
return EMPTY
}
}),
ignoreElements(),
endWith(document)
)
}),
share()
)
// Intercept popstate events, e.g. when using the browser's back and forward
// buttons, and emit new location for fetching and parsing.
const popstate$ = fromEvent<PopStateEvent>(window, "popstate")
popstate$.pipe(map(() => getLocation()))
.subscribe(location$)
// Intercept clicks on anchor links, and scroll document into position. As
// we disabled scroll restoration, we need to do this manually here.
location$
.pipe(
startWith(getLocation()),
bufferCount(2, 1),
switchMap(([prev, next]) => (
prev.pathname === next.pathname &&
prev.hash !== next.hash
)
? of(next)
: EMPTY
)
)
.subscribe(url => {
if (history.state !== null || !url.hash) {
window.scrollTo(0, history.state?.y ?? 0)
} else {
history.scrollRestoration = "auto"
setLocationHash(url.hash)
history.scrollRestoration = "manual"
}
})
// After parsing the document, check if the current history entry has a state.
// This may happen when users press the back or forward button to visit a page
// that was already seen. If there's no state, it means a new page was visited
// and we should scroll to the top, unless an anchor is given.
document$.pipe(withLatestFrom(location$))
.subscribe(([, url]) => {
if (history.state !== null || !url.hash) {
window.scrollTo(0, history.state?.y ?? 0)
} else {
setLocationHash(url.hash)
}
})
// If the current history is not empty, register an event listener updating
// the current history state whenever the scroll position changes. This must
// be debounced and cannot be done in popstate, as popstate has already
// removed the entry from the history.
document$
.pipe(
switchMap(() => viewport$),
distinctUntilKeyChanged("offset"),
debounceTime(100)
)
.subscribe(({ offset }) => {
history.replaceState(offset, "")
})
// Return document
return document$
}