Centralized fetch methods

This commit is contained in:
squidfunk 2021-02-11 19:41:34 +01:00
parent 4808b46dfb
commit 884330da3b
21 changed files with 269 additions and 75 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{
"assets/javascripts/bundle.js": "assets/javascripts/bundle.48bdffa8.min.js",
"assets/javascripts/bundle.js.map": "assets/javascripts/bundle.48bdffa8.min.js.map",
"assets/javascripts/bundle.js": "assets/javascripts/bundle.1ed0f3ef.min.js",
"assets/javascripts/bundle.js.map": "assets/javascripts/bundle.1ed0f3ef.min.js.map",
"assets/javascripts/vendor.js": "assets/javascripts/vendor.250c9a34.min.js",
"assets/javascripts/vendor.js.map": "assets/javascripts/vendor.250c9a34.min.js.map",
"assets/javascripts/worker/search.js": "assets/javascripts/worker/search.b9424174.min.js",

View File

@ -217,7 +217,7 @@
{% endblock %}
{% block scripts %}
<script src="{{ 'assets/javascripts/vendor.250c9a34.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.48bdffa8.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.1ed0f3ef.min.js' | url }}"></script>
{% for path in config["extra_javascript"] %}
<script src="{{ path | url }}"></script>
{% endfor %}

View File

@ -29,7 +29,7 @@ import { getElementOrThrow, getLocation } from "~/browser"
/**
* Feature flag
*/
export type Feature =
export type Flag =
| "header.autohide" /* Hide header */
| "navigation.tabs" /* Tabs navigation */
| "navigation.instant" /* Instant loading */
@ -66,7 +66,7 @@ export type Translations = Record<Translation, string>
*/
export interface Config {
base: string /* Base URL */
features: Feature[] /* Feature flags */
features: Flag[] /* Feature flags */
translations: Translations /* Translations */
search: string /* Search worker URL */
}
@ -78,7 +78,8 @@ export interface Config {
/**
* Retrieve global configuration and make base URL absolute
*/
const config: Config = JSON.parse(getElementOrThrow("#__config").textContent!)
const script = getElementOrThrow("#__config")
const config: Config = JSON.parse(script.textContent!)
config.base = new URL(config.base, getLocation())
.toString()
.replace(/\/$/, "")
@ -97,14 +98,14 @@ export function configuration(): Config {
}
/**
* Check whether a feature is enabled
* Check whether a feature flag is enabled
*
* @param feature - Feature
* @param flag - Feature flag
*
* @returns Test result
*/
export function flag(feature: Feature): boolean {
return config.features.includes(feature)
export function feature(flag: Flag): boolean {
return config.features.includes(flag)
}
/**
@ -118,9 +119,6 @@ export function flag(feature: Feature): boolean {
export function translation(
key: Translation, value?: string | number
): string {
if (typeof config.translations[key] === "undefined") {
throw new ReferenceError(`Invalid translation: ${key}`)
}
return typeof value !== "undefined"
? config.translations[key].replace("#", value.toString())
: config.translations[key]

View File

@ -40,7 +40,7 @@ export function getElement<T extends keyof HTMLElementTagNameMap>(
export function getElement<T extends HTMLElement>(
selector: string, node?: ParentNode
): T
): T | undefined
export function getElement<T extends HTMLElement>(
selector: string, node: ParentNode = document

View File

@ -0,0 +1,83 @@
/*
* Copyright (c) 2016-2020 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 { Observable, from } from "rxjs"
import {
filter,
map,
shareReplay,
switchMap
} from "rxjs/operators"
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* XML parser
*/
const dom = new DOMParser()
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Fetch given URL as JSON
*
* @template T - Data type
*
* @param url - Request URL
* @param options - Request options
*
* @returns Data observable
*/
export function fetchJSON<T>(
url: string, options: RequestInit = { credentials: "same-origin" }
): Observable<T> {
return from(fetch(url, options))
.pipe(
filter(res => res.status === 200),
switchMap(res => res.json()),
shareReplay(1)
)
}
/**
* Fetch given URL as XML
*
* @param url - Request URL
* @param options - Request options
*
* @returns Data observable
*/
export function fetchXML(
url: string, options: RequestInit = { credentials: "same-origin" }
): Observable<Document> {
return from(fetch(url, options))
.pipe(
filter(res => res.status === 200),
switchMap(res => res.json()),
map(res => dom.parseFromString(res, "text/xml")),
shareReplay(1)
)
}

View File

@ -22,6 +22,7 @@
export * from "./document"
export * from "./element"
export * from "./fetch"
export * from "./keyboard"
export * from "./location"
export * from "./media"

View File

@ -20,7 +20,7 @@
* IN THE SOFTWARE.
*/
import { Observable, Subject, animationFrameScheduler } from "rxjs"
import { NEVER, Observable, Subject, animationFrameScheduler } from "rxjs"
import {
distinctUntilKeyChanged,
finalize,
@ -35,7 +35,7 @@ import {
} from "~/actions"
import {
Viewport,
getElementOrThrow,
getElement,
getElementSize,
watchViewportAt
} from "~/browser"
@ -124,8 +124,12 @@ export function mountHeaderTitle(
resetHeaderTitleState(el)
})
/* Obtain headline, if any */
const headline = getElement<HTMLHeadingElement>("article h1")
if (typeof headline === "undefined")
return NEVER
/* Create and return component */
const headline = getElementOrThrow<HTMLHeadingElement>("article h1")
return watchHeaderTitle(headline, options)
.pipe(
tap(internal$),

View File

@ -24,8 +24,9 @@ import { Observable, merge } from "rxjs"
import { filter, sample, take } from "rxjs/operators"
import { configuration } from "~/_"
import { getElementOrThrow } from "~/browser"
import { fetchJSON, getElementOrThrow } from "~/browser"
import {
SearchIndex,
isSearchQueryMessage,
isSearchReadyMessage,
setupSearchWorker
@ -58,8 +59,7 @@ export type Search =
* @returns Promise resolving with search index
*/
function fetchSearchIndex(url: string) {
return __search?.index || fetch(url, { credentials: "same-origin" })
.then(res => res.json())
return __search?.index || fetchJSON<SearchIndex>(url)
}
/* ----------------------------------------------------------------------------
@ -91,14 +91,18 @@ export function mountSearch(
)
.subscribe(tx$.next.bind(tx$))
/* Obtain search query and result elements */
const query = getElementOrThrow("[data-md-component=search-query]", el)
const result = getElementOrThrow("[data-md-component=search-result]", el)
/* Mount search query component */
const query$ = mountSearchQuery(
getElementOrThrow("[data-md-component=search-query]", el),
worker
)
/* Create and return component */
const query$ = mountSearchQuery(query as HTMLInputElement, worker)
/* Mount search result and return component */
return merge(
query$,
mountSearchResult(result, worker, { query$ })
mountSearchResult(
getElementOrThrow("[data-md-component=search-result]", el),
worker, { query$ }
)
)
}

View File

@ -88,7 +88,7 @@ export function mountSearchResult(
const internal$ = new Subject<SearchResult>()
/* Update search result metadata */
const meta = getElementOrThrow(":first-child", el)
const meta = getElementOrThrow(":scope > :first-child", el)
internal$
.pipe(
withLatestFrom(query$)
@ -101,7 +101,7 @@ export function mountSearchResult(
})
/* Update search result list */
const list = getElementOrThrow(":last-child", el)
const list = getElementOrThrow(":scope > :last-child", el)
internal$
.subscribe(({ data }) => {
resetSearchResultList(list)

View File

@ -21,14 +21,10 @@
*/
import { Repo, User } from "github-types"
import { Observable, from } from "rxjs"
import {
defaultIfEmpty,
filter,
map,
switchMap
} from "rxjs/operators"
import { Observable } from "rxjs"
import { defaultIfEmpty, map } from "rxjs/operators"
import { fetchJSON } from "~/browser"
import { round } from "~/utilities"
import { SourceFacts } from "../_"
@ -51,10 +47,8 @@ export function fetchSourceFactsFromGitHub(
const url = typeof repo !== "undefined"
? `https://api.github.com/repos/${user}/${repo}`
: `https://api.github.com/users/${user}`
return from(fetch(url))
return fetchJSON<Repo & User>(url)
.pipe(
filter(res => res.status === 200),
switchMap(res => res.json()),
map(data => {
/* GitHub repository */

View File

@ -21,14 +21,10 @@
*/
import { ProjectSchema } from "gitlab"
import { Observable, from } from "rxjs"
import {
defaultIfEmpty,
filter,
map,
switchMap
} from "rxjs/operators"
import { Observable } from "rxjs"
import { defaultIfEmpty, map } from "rxjs/operators"
import { fetchJSON } from "~/browser"
import { round } from "~/utilities"
import { SourceFacts } from "../_"
@ -49,11 +45,9 @@ export function fetchSourceFactsFromGitLab(
base: string, project: string
): Observable<SourceFacts> {
const url = `https://${base}/api/v4/projects/${encodeURIComponent(project)}`
return from(fetch(url))
return fetchJSON<ProjectSchema>(url)
.pipe(
filter(res => res.status === 200),
switchMap(res => res.json()),
map(({ star_count, forks_count }: ProjectSchema) => ([
map(({ star_count, forks_count }) => ([
`${round(star_count)} Stars`,
`${round(forks_count)} Forks`
])),

View File

@ -24,7 +24,6 @@ import "focus-visible"
import { NEVER, Observable, Subject, merge } from "rxjs"
import { switchMap } from "rxjs/operators"
import { translation } from "./_"
import {
getElementOrThrow,
getElements,
@ -76,8 +75,7 @@ const main$ = watchMain(main, { header$, viewport$ })
/* Setup Clipboard.js integration */
const message$ = new Subject<string>()
setupClipboardJS()
.subscribe(() => message$.next(translation("clipboard.copied")))
setupClipboardJS({ message$ })
// TODO: watchElements + general mount function that takes a factory...
// + a toggle function (optionally)

View File

@ -21,8 +21,20 @@
*/
import ClipboardJS from "clipboard"
import { NEVER, Observable } from "rxjs"
import { share } from "rxjs/operators"
import { Observable, Subject } from "rxjs"
import { translation } from "~/_"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Setup options
*/
interface SetupOptions {
message$: Subject<string> /* Message subject */
}
/* ----------------------------------------------------------------------------
* Functions
@ -31,18 +43,16 @@ import { share } from "rxjs/operators"
/**
* Set up Clipboard.js integration
*
* @returns Clipboard.js event observable
* @param options - Options
*/
export function setupClipboardJS(): Observable<ClipboardJS.Event> {
if (!ClipboardJS.isSupported())
return NEVER
/* Initialize Clipboard.js */
return new Observable<ClipboardJS.Event>(subscriber => {
new ClipboardJS("[data-clipboard-target], [data-clipboard-text]")
.on("success", ev => subscriber.next(ev))
})
.pipe(
share()
)
export function setupClipboardJS(
{ message$ }: SetupOptions
): void {
if (!ClipboardJS.isSupported()) {
new Observable<ClipboardJS.Event>(subscriber => {
new ClipboardJS("[data-clipboard-target], [data-clipboard-text]")
.on("success", ev => subscriber.next(ev))
})
.subscribe(() => message$.next(translation("clipboard.copied")))
}
}

View File

@ -21,4 +21,5 @@
*/
export * from "./clipboard"
export * from "./instant"
export * from "./search"

View File

@ -0,0 +1,5 @@
{
"rules": {
"no-self-assign": "off"
}
}

View File

@ -0,0 +1,102 @@
/*
* Copyright (c) 2016-2020 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 { Observable, Subject, fromEvent } from "rxjs"
import { Viewport, ViewportOffset, getElement } from "~/browser"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* History state
*/
export interface HistoryState {
url: URL /* State URL */
offset?: ViewportOffset /* State viewport offset */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Setup options
*/
interface SetupOptions {
document$: Subject<Document> /* Document subject */
location$: Subject<URL> /* Location subject */
viewport$: Observable<Viewport> /* Viewport observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Set up instant loading
*
* When fetching, theoretically, 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 could use the old location.
*
* This is the reason why we need to synchronize history events and the process
* of fetching the document for navigation changes (except `popstate` events):
*
* 1. Fetch document via `XMLHTTPRequest`
* 2. Set new location via `history.pushState`
* 3. Parse and emit fetched document
*
* For `popstate` events, we must not use `history.pushState`, or the forward
* history will be irreversibly overwritten. In case the request fails, the
* location change is dispatched regularly.
*
* @param urls - URLs to load with XHR
* @param options - Options
*/
export function setupInstantLoading(
urls: string[], { document$, location$, viewport$ }: SetupOptions
): void {
if (location.protocol === "file:")
return
/* Disable automatic scroll restoration */
if ("scrollRestoration" in history) {
history.scrollRestoration = "manual"
/* Hack: ensure that reloads restore viewport offset */
fromEvent(window, "beforeunload")
.subscribe(() => {
history.scrollRestoration = "auto"
})
}
/* Hack: ensure absolute favicon link to omit 404s when switching */
const favicon = getElement<HTMLLinkElement>("link[rel='shortcut icon']")
if (typeof favicon !== "undefined")
favicon.href = favicon.href
// eslint-disable-next-line
console.log(urls, document$, location$, viewport$)
}

View File

@ -20,8 +20,8 @@
* IN THE SOFTWARE.
*/
import { Subject, asyncScheduler, from } from "rxjs"
import { delay, map, observeOn, share } from "rxjs/operators"
import { ObservableInput, Subject, from } from "rxjs"
import { map, share } from "rxjs/operators"
import { configuration, translation } from "~/_"
import { WorkerHandler, watchWorker } from "~/browser"
@ -82,19 +82,19 @@ function setupSearchIndex(
* ------------------------------------------------------------------------- */
/**
* Set up search web worker
* Set up search worker
*
* This function will create a web worker to set up and query the search index
* which is done using `lunr`. The index must be passed as an observable to
* enable hacks like _localsearch_ via search index embedding as JSON.
*
* @param url - Worker URL
* @param index - Promise resolving with search index
* @param index - Search index observable input
*
* @returns Search worker
*/
export function setupSearchWorker(
url: string, index: Promise<SearchIndex>
url: string, index: ObservableInput<SearchIndex>
): SearchWorker {
const config = configuration()
const worker = new Worker(url)