Fixed instant loading bugs

This commit is contained in:
squidfunk 2024-01-18 17:29:20 +07:00
parent 44982451f7
commit a6282931ef
No known key found for this signature in database
GPG Key ID: 5ED40BC4F9C436DF
12 changed files with 442 additions and 391 deletions

View File

@ -23,5 +23,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
{{ super() }} {{ super() }}
<script src="{{ 'assets/javascripts/custom.054acff4.min.js' | url }}"></script> <script src="{{ 'assets/javascripts/custom.30be3006.min.js' | url }}"></script>
{% endblock %} {% endblock %}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -249,7 +249,7 @@
</script> </script>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ 'assets/javascripts/bundle.7389ff0e.min.js' | url }}"></script> <script src="{{ 'assets/javascripts/bundle.8bb211bc.min.js' | url }}"></script>
{% for script in config.extra_javascript %} {% for script in config.extra_javascript %}
{{ script | script_tag }} {{ script | script_tag }}
{% endfor %} {% endfor %}

View File

@ -46,20 +46,27 @@ interface Options {
/** /**
* Fetch the given URL * Fetch the given URL
* *
* If the request fails (e.g. when dispatched from `file://` locations), the * This function returns an observable that emits the response as a blob and
* observable will complete without emitting a value. * completes, or emits an error if the request failed. The caller can cancel
* the request by unsubscribing at any time, which will automatically abort
* the inflight request and complete the observable.
*
* Note that we use `XMLHTTPRequest` not because we're nostalgic, but because
* it's the only way to get progress events for downloads and also allow for
* cancellation of requests, as the official Fetch API does not support this
* yet, even though we're already in 2024.
* *
* @param url - Request URL * @param url - Request URL
* @param options - Options * @param options - Options
* *
* @returns Response observable * @returns Data observable
*/ */
export function request( export function request(
url: URL | string, options?: Options url: URL | string, options?: Options
): Observable<Blob> { ): Observable<Blob> {
return new Observable<Blob>(observer => { return new Observable<Blob>(observer => {
const req = new XMLHttpRequest() const req = new XMLHttpRequest()
req.open("GET", `${url}`) req.open("GET", `${url}`)
req.responseType = "blob" req.responseType = "blob"
// Handle response // Handle response
@ -67,6 +74,8 @@ export function request(
if (req.status >= 200 && req.status < 300) { if (req.status >= 200 && req.status < 300) {
observer.next(req.response) observer.next(req.response)
observer.complete() observer.complete()
// Every response that is not in the 2xx range is considered an error
} else { } else {
observer.error(new Error(req.statusText)) observer.error(new Error(req.statusText))
} }
@ -74,12 +83,12 @@ export function request(
// Handle network errors // Handle network errors
req.addEventListener("error", () => { req.addEventListener("error", () => {
observer.error(new Error("Network Error")) observer.error(new Error("Network error"))
}) })
// Handle aborted requests // Handle aborted requests
req.addEventListener("abort", () => { req.addEventListener("abort", () => {
observer.error(new Error("Request aborted")) observer.complete()
}) })
// Handle download progress // Handle download progress
@ -87,9 +96,12 @@ export function request(
req.addEventListener("progress", event => { req.addEventListener("progress", event => {
if (event.lengthComputable) { if (event.lengthComputable) {
options.progress$!.next((event.loaded / event.total) * 100) options.progress$!.next((event.loaded / event.total) * 100)
} else { // https://bugs.chromium.org/p/chromium/issues/detail?id=463622
const totalFromHeader = Number(req.getResponseHeader("Content-Length")) || 0 // Hack: Chromium doesn't report the total number of bytes if content
options.progress$!.next((event.loaded / totalFromHeader) * 100) // is compressed, so we need this fallback - see https://t.ly/ZXofI
} else {
const length = req.getResponseHeader("Content-Length") ?? 0
options.progress$!.next((event.loaded / +length) * 100)
} }
}) })
@ -97,8 +109,9 @@ export function request(
options.progress$.next(5) options.progress$.next(5)
} }
// Send request // Send request and automatically abort request upon unsubscription
req.send() req.send()
return () => req.abort()
}) })
} }
@ -125,6 +138,26 @@ export function requestJSON<T>(
) )
} }
/**
* Fetch HTML from the given URL
*
* @param url - Request URL
* @param options - Options
*
* @returns Data observable
*/
export function requestHTML(
url: URL | string, options?: Options
): Observable<Document> {
const dom = new DOMParser()
return request(url, options)
.pipe(
switchMap(res => res.text()),
map(res => dom.parseFromString(res, "text/html")),
shareReplay(1)
)
}
/** /**
* Fetch XML from the given URL * Fetch XML from the given URL
* *

View File

@ -24,23 +24,24 @@ import {
EMPTY, EMPTY,
Observable, Observable,
Subject, Subject,
bufferCount,
catchError, catchError,
combineLatestWith,
concat, concat,
debounceTime, debounceTime,
distinct,
distinctUntilChanged,
distinctUntilKeyChanged, distinctUntilKeyChanged,
endWith, endWith,
filter, exhaustMap,
fromEvent, fromEvent,
ignoreElements, ignoreElements,
map, map,
merge,
of, of,
sample,
share, share,
skip,
startWith,
switchMap, switchMap,
take, take,
tap,
withLatestFrom withLatestFrom
} from "rxjs" } from "rxjs"
@ -50,13 +51,13 @@ import {
getElements, getElements,
getLocation, getLocation,
getOptionalElement, getOptionalElement,
request, requestHTML,
setLocation, setLocation,
setLocationHash setLocationHash
} from "~/browser" } from "~/browser"
import { getComponentElement } from "~/components" import { getComponentElement } from "~/components"
import { fetchSitemap } from "../sitemap" import { Sitemap, fetchSitemap } from "../sitemap"
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
* Helper types * Helper types
@ -75,55 +76,164 @@ interface SetupOptions {
* Helper functions * Helper functions
* ------------------------------------------------------------------------- */ * ------------------------------------------------------------------------- */
/**
* Handle clicks on internal URLs while skipping external URLs
*
* @param ev - Mouse event
* @param sitemap - Sitemap
*
* @returns URL observable
*/
function handle(
ev: MouseEvent, sitemap: Sitemap
): Observable<URL> {
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 this event as well.
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.has(`${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. The reason for this is that if we wouldn't manage
// anchor links as well, scroll restoration will not work correctly (e.g.
// following an anchor link and scrolling).
ev.preventDefault()
return of(new URL(el.href))
}
/** /**
* Create a map of head elements for lookup and replacement * Create a map of head elements for lookup and replacement
* *
* @param head - Document head * @param document - Document
* *
* @returns Element map * @returns Tag map
*/ */
function lookup(head: HTMLHeadElement): Map<string, HTMLElement> { function head(document: Document): Map<string, HTMLElement> {
// @todo When resolving URLs, we must make sure to use the correct base for
// resolution. The next time we refactor instant loading, we should use the
// location subject as a source, which is also used for anchor links tracking,
// but for now we just rely on canonical.
const canonical = getOptionalElement<HTMLLinkElement>("[rel=canonical]", head)
if (typeof canonical !== "undefined")
canonical.href = canonical.href.replace("//localhost:", "//127.0.0.1:")
// Create tag map and index elements in head
const tags = new Map<string, HTMLElement>() const tags = new Map<string, HTMLElement>()
for (const el of getElements(":scope > *", head)) { for (const el of getElements(":scope > *", document.head))
let html = el.outerHTML tags.set(el.outerHTML, el)
// If the current element is a style sheet or script, we must resolve the
// URL relative to the current location and make it absolute, so it's easy
// to deduplicate it later on by comparing the outer HTML of tags. We must
// keep identical style sheets and scripts without replacing them.
for (const key of ["href", "src"]) {
const value = el.getAttribute(key)!
if (value === null)
continue
// Resolve URL relative to current location
const url = new URL(value, canonical?.href)
const ref = el.cloneNode() as HTMLElement
// Set resolved URL and retrieve HTML for deduplication
ref.setAttribute(key, `${url}`)
html = ref.outerHTML
break
}
// Index element in tag map
tags.set(html, el)
}
// Return tag map // Return tag map
return tags return tags
} }
/**
* Resolve relative URLs in the given document
*
* @param document - Document
*
* @returns Document observable
*/
function resolve(document: Document): Observable<Document> {
for (const el of getElements<HTMLLinkElement>("[href], [src]", document))
for (const key in ["href", "src"]) {
const value = el.getAttribute(key)
if (!/^(?:[a-z]+:)?\/\//i.test(value!))
el.href = el.href
}
// Return document observable
return of(document)
}
/**
* Create a map of head elements for lookup and replacement
*
* @param next - Next document
*
* @returns Document observable
*/
function inject(next: Document): Observable<Document> {
for (const selector of [
"[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, next)
if (
typeof source !== "undefined" &&
typeof target !== "undefined"
) {
source.replaceWith(target)
}
}
// Update meta tags
const tags = head(document)
for (const [html, el] of head(next))
if (tags.has(html))
tags.delete(html)
else
document.head.appendChild(el)
// Remove meta tags that are not present in the new document
for (const el of tags.values())
el.remove()
// After components and meta tags 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 = next.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(next)
)
}
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
* Functions * Functions
* ------------------------------------------------------------------------- */ * ------------------------------------------------------------------------- */
@ -146,292 +256,168 @@ export function setupInstantNavigation(
return EMPTY return EMPTY
// Load sitemap immediately, so we have it available when the user initiates // Load sitemap immediately, so we have it available when the user initiates
// the first instant navigation request, and canonicalize URLs to the current // the first navigation request, so there's no perceived delay.
// base URL. The base URL will remain stable in between loads, as it's only const sitemap$ = fetchSitemap(config.base)
// 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 // Since we might be on a slow connection, the user might trigger multiple
// low we use the fact that uncaptured events bubble up to the body. This also // instant navigation events that overlap. MkDocs produces relative URLs for
// has the nice property that we don't need to detach and then again attach // all internal links, which becomes a problem in this case, because we need
// event listeners when instant navigation occurs. // to change the base URL the moment the user clicks a link that should be
const instant$ = fromEvent<MouseEvent>(document.body, "click") // intercepted in order to be consistent with popstate, which means that the
.pipe( // base URL would now be incorrect when resolving another relative link from
withLatestFrom(sitemap$), // the same site. For this reason we always resolve all relative links to
switchMap(([ev, sitemap]) => { // absolute links, so we can be sure this never happens.
if (!(ev.target instanceof Element)) of(document)
return EMPTY .subscribe(resolve)
// Skip, as target is not within a link - clicks on non-link elements // --------------------------------------------------------------------------
// are also captured, which we need to exclude from processing // Navigation interception
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 // Intercept navigation - to keep the number of event listeners down we use
// click on a link, but the link either has a `target` property defined, // the fact that uncaptured events bubble up to the body. This has the nice
// or the user pressed the `meta` or `ctrl` key to open it in a new // property that we don't need to detach and then re-attach event listeners
// window. Thus, we need to filter those events, too. // when the document is replaced after a navigation event.
if (el.target || ev.metaKey || ev.ctrlKey) const instant$ =
return EMPTY fromEvent<MouseEvent>(document.body, "click")
.pipe(
combineLatestWith(sitemap$),
switchMap(([ev, sitemap]) => handle(ev, sitemap)),
share()
)
// Next, we must check if the URL is relevant for us, i.e., if it's an // Intercept history change events, e.g. when the user uses the browser's
// internal link to a page that is managed by MkDocs. Only then we can // back or forward buttons, and emit new location for fetching and parsing
// be sure that the structure of the page to be loaded adheres to the const history$ =
// current document structure and can subsequently be injected into it fromEvent<PopStateEvent>(window, "popstate")
// without doing a full reload. For this reason, we must canonicalize .pipe(
// the URL by removing all search parameters and hash fragments. map(getLocation),
const url = new URL(el.href) share()
url.search = url.hash = "" )
// Skip, if URL is not included in the sitemap - this could be the case // While it would be better UX to defer navigation events until the document
// when linking between versions or languages, or to another page that // is fully fetched and parsed, we must schedule it here to synchronize with
// the author included as part of the build, but that is not managed by // popstate events, as they are emitted immediately. Moreover we need to
// MkDocs. In that case we must not continue with instant navigation. // store the current viewport offset for scroll restoration later on.
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. The reason for this is that
// if we wouldn't manage anchor links as well, scroll restoration will
// not work correctly (e.g. following an anchor link and scrolling).
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$)) instant$.pipe(withLatestFrom(viewport$))
.subscribe(([url, { offset }]) => { .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.replaceState(offset, "")
history.pushState(null, "", url) history.pushState(null, "", url)
}) })
// Emit URL that should be fetched via instant navigation on location subject, // Emit URLs that should be fetched via instant navigation on location subject
// which was passed into this function. Instant navigation can be intercepted // which was passed into this function. The state of instant navigation can be
// by other parts of the application, which can synchronously back up or // intercepted by other parts of the application, which can synchronously back
// restore state before instant navigation happens. // up or restore state before or after instant navigation happens.
instant$.subscribe(location$) merge(instant$, history$)
.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 // Fetching and parsing
// 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, // Fetch document - we deduplicate requests to the same location, so we don't
// which will force-load the page. Furthermore, we must pre-warm the buffer // end up with multiple requests for the same page. We use `switchMap`, since
// for the duplicate check, or the first click on an anchor link will also // we want to cancel the previous request when a new one is triggered, which
// trigger an instant navigation event, which doesn't make sense. // is automatically handled by the observable returned by `request`. This is
const response$ = location$ // essential to ensure a good user experience, as we don't want to load pages
// that are not needed anymore, e.g., when the user clicks multiple links in
// quick succession or on slow connections. If the request fails for some
// reason, we fall back and use regular navigation, forcing a reload.
const document$ = location$
.pipe( .pipe(
startWith(getLocation()),
distinctUntilKeyChanged("pathname"), distinctUntilKeyChanged("pathname"),
skip(1), switchMap(url => requestHTML(url, { progress$ })
switchMap(url => request(url, { progress$ })
.pipe( .pipe(
catchError(() => { catchError(() => {
setLocation(url, true) setLocation(url, true)
return EMPTY return EMPTY
}) })
) )
) ),
)
// Initialize the DOM parser, parse the returned HTML, and replace selected // The document was successfully fetched and parsed, so we can inject its
// components before handing control down to the application // contents into the currently active document
const dom = new DOMParser() switchMap(resolve),
const document$ = response$ switchMap(inject),
.pipe(
switchMap(res => res.text()),
switchMap(res => {
const next = dom.parseFromString(res, "text/html")
for (const selector of [
"[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, next)
if (
typeof source !== "undefined" &&
typeof target !== "undefined"
) {
source.replaceWith(target)
}
}
// Update meta tags
const source = lookup(document.head)
const target = lookup(next.head)
for (const [html, el] of target) {
// Hack: skip stylesheets and scripts until we manage to replace them
// entirely in order to omit flashes of white content @todo refactor
if (
el.getAttribute("rel") === "stylesheet" ||
el.hasAttribute("src")
)
continue
if (source.has(html)) {
source.delete(html)
} else {
document.head.appendChild(el)
}
}
// Remove meta tags that are not present in the new document
for (const el of source.values())
// Hack: skip stylesheets and scripts until we manage to replace them
// entirely in order to omit flashes of white content @todo refactor
if (
el.getAttribute("rel") === "stylesheet" ||
el.hasAttribute("src")
)
continue
else
el.remove()
// After components and meta tags 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 = next.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(next)
)
}),
share() share()
) )
// Intercept popstate events, e.g. when using the browser's back and forward // --------------------------------------------------------------------------
// buttons, and emit new location for fetching and parsing // Scroll restoration
const popstate$ = fromEvent<PopStateEvent>(window, "popstate") // --------------------------------------------------------------------------
popstate$.pipe(map(getLocation))
.subscribe(location$)
// Intercept clicks on anchor links, and scroll document into position - as // Handle scroll restoration - we must restore the viewport offset after the
// we disabled scroll restoration, we need to do this manually here // document has been fetched and injected, and every time the user clicks an
location$ // anchor that leads to an element on the same page, which might also happen
.pipe( // when the user uses the back or forward button.
startWith(getLocation()), merge(
bufferCount(2, 1), document$.pipe(withLatestFrom(location$, (_, url) => url)),
filter(([prev, next]) => (
prev.pathname === next.pathname && // Handle instant navigation events that are triggered by the user clicking
prev.hash !== next.hash // on an anchor link with a hash fragment different from the current one, as
// well as from popstate events, which are emitted when the user navigates
// back and forth between pages. We use a two-layered subscription to scope
// the scroll restoration to the current page, as we don't need to restore
// the viewport offset when the user navigates to a different page, as this
// is already handled by the previous observable.
location$.pipe(
distinctUntilKeyChanged("pathname"),
switchMap(() => location$),
distinctUntilKeyChanged("hash"),
),
// Handle instant navigation events that are triggered by the user clicking
// on an anchor link with the same hash fragment as the current one in the
// URL. Is is essential that we only intercept those from instant navigation
// events and not from history change events, or we'll end up in and endless
// loop. The top-level history entry must be removed, as it will be replaced
// with a new one, which would otherwise lead to a duplicate entry.
location$.pipe(
distinctUntilChanged((a, b) => (
a.pathname === b.pathname &&
a.hash === b.hash
)), )),
map(([, next]) => next) switchMap(() => instant$),
tap(() => history.back())
) )
.subscribe(url => { )
if (history.state !== null || !url.hash) { .subscribe(url => {
window.scrollTo(0, history.state?.y ?? 0)
} else {
history.scrollRestoration = "auto"
setLocationHash(url.hash)
history.scrollRestoration = "manual"
}
})
// Intercept clicks on the same anchor link - we must use a distinct pipeline // Check if the current history entry has a state, which happens when the
// for this, or we'd end up in a loop, setting the hash again and again // user presses the back or forward button to visit a page we've already
location$ // seen. If there's no state, it means a new page was visited and we must
.pipe( // scroll to the top, unless an anchor is given.
sample(instant$),
startWith(getLocation()),
bufferCount(2, 1),
filter(([prev, next]) => (
prev.pathname === next.pathname &&
prev.hash === next.hash
)),
map(([, next]) => next)
)
.subscribe(url => {
history.scrollRestoration = "auto"
setLocationHash(url.hash)
history.scrollRestoration = "manual"
// Hack: we need to make sure that we don't end up with multiple history
// entries for the same anchor link, so we just remove the last entry
history.back()
})
// 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) { if (history.state !== null || !url.hash) {
window.scrollTo(0, history.state?.y ?? 0) window.scrollTo(0, history.state?.y ?? 0)
} else { } else {
history.scrollRestoration = "auto"
setLocationHash(url.hash) setLocationHash(url.hash)
history.scrollRestoration = "manual"
} }
}) })
// If the current history is not empty, register an event listener updating // Disable scroll restoration when an instant navigation event occurs, so the
// the current history state whenever the scroll position changes. This must // browser does not immediately set the viewport offset to the prior history
// be debounced and cannot be done in popstate, as popstate has already // entry, scrolling to the position on the same page, which would look odd.
// removed the entry from the history. // Instead, we manually restore the position once the page has loaded.
location$.subscribe(() => {
history.scrollRestoration = "manual"
})
// 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 viewport offset prior to their
// emission, we could just reset this in popstate. Meh.
fromEvent(window, "beforeunload")
.subscribe(() => {
history.scrollRestoration = "auto"
})
// Track viewport offset, so we can restore it when the user navigates back
// and forth between pages. Note that this must be debounced and cannot be
// done in popstate, as popstate has already removed the entry from the
// history, which means it is too late.
viewport$ viewport$
.pipe( .pipe(
distinctUntilKeyChanged("offset"), distinctUntilKeyChanged("offset"),

View File

@ -0,0 +1,5 @@
{
"rules": {
"no-null/no-null": "off"
}
}

View File

@ -21,17 +21,17 @@
*/ */
import { import {
EMPTY,
Observable, Observable,
catchError, catchError,
defaultIfEmpty,
map, map,
of, of
tap
} from "rxjs" } from "rxjs"
import { configuration } from "~/_" import {
import { getElements, requestXML } from "~/browser" getElement,
getElements,
requestXML
} from "~/browser"
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
* Types * Types
@ -40,41 +40,75 @@ import { getElements, requestXML } from "~/browser"
/** /**
* Sitemap, i.e. a list of URLs * Sitemap, i.e. a list of URLs
*/ */
export type Sitemap = string[] export type Sitemap = Map<string, URL[]>
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
* Helper functions * Helper functions
* ------------------------------------------------------------------------- */ * ------------------------------------------------------------------------- */
/** /**
* Preprocess a list of URLs * Resolve URL to the given base URL
* *
* This function replaces the `site_url` in the sitemap with the actual base * When serving the site with instant navigation, MkDocs will set the hostname
* URL, to allow instant navigation to work in occasions like Netlify previews. * to the value as specified in `dev_addr`, but the browser allows for several
* hostnames to be used: `localhost`, `127.0.0.1` or even `0.0.0.0`, depending
* on configuration. This function resolves the URL to the given hostname.
* *
* @param urls - URLs * @param url - URL
* @param base - Base URL
* *
* @returns URL path parts * @returns Resolved URL
*/ */
function preprocess(urls: Sitemap): Sitemap { function resolve(url: URL, base: URL) {
if (urls.length < 2) url.protocol = base.protocol
return [""] url.hostname = base.hostname
return url
}
/* Take the first two URLs and remove everything after the last slash */ /**
const [root, next] = [...urls] * Extract sitemap from document
.sort((a, b) => a.length - b.length) *
.map(url => url.replace(/[^/]+$/, "")) * This function extracts the URLs and alternate links from the document, and
* associates alternate links to the original URL as found in `loc`, allowing
* the browser to navigate to the correct page when switching languages. The
* format of the sitemap is expected to adhere to:
*
* ``` xml
* <urlset>
* <url>
* <loc>...</loc>
* <xhtml:link rel="alternate" hreflang="en" href="..."/>
* <xhtml:link rel="alternate" hreflang="de" href="..."/>
* ...
* </url>
* ...
* </urlset>
* ```
*
* @param document - Document
* @param base - Base URL
*
* @returns Sitemap
*/
function extract(document: Document, base: URL): Sitemap {
const sitemap: Sitemap = new Map()
for (const el of getElements("url", document)) {
const url = getElement("loc", el)
/* Compute common prefix */ // Create entry for location and add it to the list of links
let index = 0 const links = [resolve(new URL(url.textContent!), base)]
if (root === next) sitemap.set(`${links[0]}`, links)
index = root.length
else
while (root.charCodeAt(index) === next.charCodeAt(index))
index++
/* Remove common prefix and return in original order */ // Attach alternate links to current entry
return urls.map(url => url.replace(root.slice(0, index), "")) for (const link of getElements("[rel=alternate]", el)) {
const href = link.getAttribute("href")
if (href != null)
links.push(resolve(new URL(href), base))
}
}
// Return sitemap
return sitemap
} }
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
@ -84,24 +118,17 @@ function preprocess(urls: Sitemap): Sitemap {
/** /**
* Fetch the sitemap for the given base URL * Fetch the sitemap for the given base URL
* *
* If a network or parsing error occurs, we just default to an empty sitemap,
* which means the caller should fall back to regular navigation.
*
* @param base - Base URL * @param base - Base URL
* *
* @returns Sitemap observable * @returns Sitemap observable
*/ */
export function fetchSitemap(base?: URL): Observable<Sitemap> { export function fetchSitemap(base: URL | string): Observable<Sitemap> {
const cached = __md_get<Sitemap>("__sitemap", sessionStorage, base) return requestXML(new URL("sitemap.xml", base))
if (cached) { .pipe(
return of(cached) map(document => extract(document, new URL(base))),
} else { catchError(() => of(new Map())),
const config = configuration() )
return requestXML(new URL("sitemap.xml", base || config.base))
.pipe(
map(sitemap => preprocess(getElements("loc", sitemap)
.map(node => node.textContent!)
)),
catchError(() => EMPTY), // @todo refactor instant loading
defaultIfEmpty([]),
tap(sitemap => __md_set("__sitemap", sitemap, sessionStorage, base))
)
}
} }

View File

@ -134,7 +134,7 @@ export function setupVersionSelector(
map(sitemap => { map(sitemap => {
const location = getLocation() const location = getLocation()
const path = location.href.replace(config.base, "") const path = location.href.replace(config.base, "")
return sitemap.includes(path.split("#")[0]) return sitemap.has(path.split("#")[0])
? new URL(`../${version}/${path}`, config.base) ? new URL(`../${version}/${path}`, config.base)
: new URL(url) : new URL(url)
}) })