aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src/templates/assets/javascripts/integrations/instant
diff options
context:
space:
mode:
Diffstat (limited to 'src/templates/assets/javascripts/integrations/instant')
-rw-r--r--src/templates/assets/javascripts/integrations/instant/.eslintrc6
-rw-r--r--src/templates/assets/javascripts/integrations/instant/index.ts446
2 files changed, 452 insertions, 0 deletions
diff --git a/src/templates/assets/javascripts/integrations/instant/.eslintrc b/src/templates/assets/javascripts/integrations/instant/.eslintrc
new file mode 100644
index 00000000..5adf108a
--- /dev/null
+++ b/src/templates/assets/javascripts/integrations/instant/.eslintrc
@@ -0,0 +1,6 @@
+{
+ "rules": {
+ "no-self-assign": "off",
+ "no-null/no-null": "off"
+ }
+}
diff --git a/src/templates/assets/javascripts/integrations/instant/index.ts b/src/templates/assets/javascripts/integrations/instant/index.ts
new file mode 100644
index 00000000..d321b751
--- /dev/null
+++ b/src/templates/assets/javascripts/integrations/instant/index.ts
@@ -0,0 +1,446 @@
+/*
+ * 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,
+ filter,
+ fromEvent,
+ ignoreElements,
+ map,
+ of,
+ sample,
+ share,
+ skip,
+ startWith,
+ switchMap,
+ take,
+ withLatestFrom
+} from "rxjs"
+
+import { configuration, feature } from "~/_"
+import {
+ Viewport,
+ getElement,
+ 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
+ progress$: Subject<number> // Progress suject
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Create a map of head elements for lookup and replacement
+ *
+ * @param head - Document head
+ *
+ * @returns Element map
+ */
+function lookup(head: HTMLHeadElement): 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 = getElement<HTMLLinkElement>("[rel=canonical]", head)
+ canonical.href = canonical.href.replace("//localhost:", "//127.0.0.1")
+
+ // Create tag map and index elements in head
+ const tags = new Map<string, HTMLElement>()
+ for (const el of getElements(":scope > *", head)) {
+ let html = el.outerHTML
+
+ // 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 tags
+}
+
+/* ----------------------------------------------------------------------------
+ * 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 setupInstantNavigation(
+ { location$, viewport$, progress$ }: 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. 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$))
+ .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 navigation on location subject,
+ // which was passed into this function. Instant navigation can be intercepted
+ // by other parts of the application, which can synchronously back up or
+ // restore state before instant navigation 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 navigation event, which doesn't make sense.
+ const response$ = location$
+ .pipe(
+ startWith(getLocation()),
+ distinctUntilKeyChanged("pathname"),
+ skip(1),
+ switchMap(url => request(url, { progress$ })
+ .pipe(
+ catchError(() => {
+ setLocation(url, true)
+ return EMPTY
+ })
+ )
+ )
+ )
+
+ // Initialize the DOM parser, parse the returned HTML, and replace selected
+ // components before handing control down to the application
+ const dom = new DOMParser()
+ const document$ = response$
+ .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()
+ )
+
+ // 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),
+ filter(([prev, next]) => (
+ prev.pathname === next.pathname &&
+ prev.hash !== next.hash
+ )),
+ map(([, next]) => next)
+ )
+ .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"
+ }
+ })
+
+ // Intercept clicks on the same anchor link - we must use a distinct pipeline
+ // for this, or we'd end up in a loop, setting the hash again and again
+ location$
+ .pipe(
+ 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) {
+ 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.
+ viewport$
+ .pipe(
+ distinctUntilKeyChanged("offset"),
+ debounceTime(100)
+ )
+ .subscribe(({ offset }) => {
+ history.replaceState(offset, "")
+ })
+
+ // Return document
+ return document$
+}