mirror of
https://github.com/squidfunk/mkdocs-material.git
synced 2024-11-30 18:24:35 +01:00
Merge pull request #6662 from squidfunk/fix/instant-loading-bugs
Fixed instant navigation bugs
This commit is contained in:
commit
943e97801e
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -23,5 +23,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<script src="{{ 'assets/javascripts/custom.526c59dc.min.js' | url }}"></script>
|
<script src="{{ 'assets/javascripts/custom.129bd6ad.min.js' | url }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
File diff suppressed because one or more lines are too long
29
material/templates/assets/javascripts/bundle.c18c5fb9.min.js
vendored
Normal file
29
material/templates/assets/javascripts/bundle.c18c5fb9.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -249,7 +249,7 @@
|
|||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ 'assets/javascripts/bundle.a963951d.min.js' | url }}"></script>
|
<script src="{{ 'assets/javascripts/bundle.c18c5fb9.min.js' | url }}"></script>
|
||||||
{% for script in config.extra_javascript %}
|
{% for script in config.extra_javascript %}
|
||||||
{{ script | script_tag }}
|
{{ script | script_tag }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -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
|
||||||
*
|
*
|
||||||
|
@ -24,23 +24,21 @@ import {
|
|||||||
EMPTY,
|
EMPTY,
|
||||||
Observable,
|
Observable,
|
||||||
Subject,
|
Subject,
|
||||||
bufferCount,
|
|
||||||
catchError,
|
catchError,
|
||||||
|
combineLatestWith,
|
||||||
concat,
|
concat,
|
||||||
debounceTime,
|
debounceTime,
|
||||||
|
distinctUntilChanged,
|
||||||
distinctUntilKeyChanged,
|
distinctUntilKeyChanged,
|
||||||
endWith,
|
endWith,
|
||||||
filter,
|
|
||||||
fromEvent,
|
fromEvent,
|
||||||
ignoreElements,
|
ignoreElements,
|
||||||
map,
|
map,
|
||||||
|
merge,
|
||||||
of,
|
of,
|
||||||
sample,
|
|
||||||
share,
|
share,
|
||||||
skip,
|
|
||||||
startWith,
|
|
||||||
switchMap,
|
switchMap,
|
||||||
take,
|
tap,
|
||||||
withLatestFrom
|
withLatestFrom
|
||||||
} from "rxjs"
|
} from "rxjs"
|
||||||
|
|
||||||
@ -50,13 +48,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
|
||||||
@ -68,62 +66,171 @@ import { fetchSitemap } from "../sitemap"
|
|||||||
interface SetupOptions {
|
interface SetupOptions {
|
||||||
location$: Subject<URL> // Location subject
|
location$: Subject<URL> // Location subject
|
||||||
viewport$: Observable<Viewport> // Viewport observable
|
viewport$: Observable<Viewport> // Viewport observable
|
||||||
progress$: Subject<number> // Progress suject
|
progress$: Subject<number> // Progress subject
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----------------------------------------------------------------------------
|
/* ----------------------------------------------------------------------------
|
||||||
* 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 +253,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"),
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"rules": {
|
||||||
|
"no-null/no-null": "off"
|
||||||
|
}
|
||||||
|
}
|
@ -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))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
})
|
})
|
||||||
|
Loading…
Reference in New Issue
Block a user