diff options
| author | 2023-12-15 10:08:56 +0800 | |
|---|---|---|
| committer | 2023-12-15 10:08:56 +0800 | |
| commit | d697a1774ad8571e9314e3bd85aa141170587d19 (patch) | |
| tree | cd3c3d84e3288db1f8e574a0b4dfb85e2e40f764 /src/templates/assets/javascripts/integrations/instant/index.ts | |
| parent | 4943d5eff52a75caaccda6a1d84183032f06be26 (diff) | |
| parent | 4dafb0f0a81255193f2a44df5d203239325e2236 (diff) | |
| download | infini-d697a1774ad8571e9314e3bd85aa141170587d19.tar.gz infini-d697a1774ad8571e9314e3bd85aa141170587d19.zip | |
Merge branch 'master' into master
Diffstat (limited to 'src/templates/assets/javascripts/integrations/instant/index.ts')
| -rw-r--r-- | src/templates/assets/javascripts/integrations/instant/index.ts | 446 |
1 files changed, 0 insertions, 446 deletions
diff --git a/src/templates/assets/javascripts/integrations/instant/index.ts b/src/templates/assets/javascripts/integrations/instant/index.ts deleted file mode 100644 index d321b751..00000000 --- a/src/templates/assets/javascripts/integrations/instant/index.ts +++ /dev/null @@ -1,446 +0,0 @@ -/* - * 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$ -} |
