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": "assets/javascripts/bundle.1ed0f3ef.min.js",
"assets/javascripts/bundle.js.map": "assets/javascripts/bundle.48bdffa8.min.js.map", "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": "assets/javascripts/vendor.250c9a34.min.js",
"assets/javascripts/vendor.js.map": "assets/javascripts/vendor.250c9a34.min.js.map", "assets/javascripts/vendor.js.map": "assets/javascripts/vendor.250c9a34.min.js.map",
"assets/javascripts/worker/search.js": "assets/javascripts/worker/search.b9424174.min.js", "assets/javascripts/worker/search.js": "assets/javascripts/worker/search.b9424174.min.js",

View File

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

View File

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

View File

@ -40,7 +40,7 @@ export function getElement<T extends keyof HTMLElementTagNameMap>(
export function getElement<T extends HTMLElement>( export function getElement<T extends HTMLElement>(
selector: string, node?: ParentNode selector: string, node?: ParentNode
): T ): T | undefined
export function getElement<T extends HTMLElement>( export function getElement<T extends HTMLElement>(
selector: string, node: ParentNode = document 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 "./document"
export * from "./element" export * from "./element"
export * from "./fetch"
export * from "./keyboard" export * from "./keyboard"
export * from "./location" export * from "./location"
export * from "./media" export * from "./media"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -21,4 +21,5 @@
*/ */
export * from "./clipboard" export * from "./clipboard"
export * from "./instant"
export * from "./search" 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. * IN THE SOFTWARE.
*/ */
import { Subject, asyncScheduler, from } from "rxjs" import { ObservableInput, Subject, from } from "rxjs"
import { delay, map, observeOn, share } from "rxjs/operators" import { map, share } from "rxjs/operators"
import { configuration, translation } from "~/_" import { configuration, translation } from "~/_"
import { WorkerHandler, watchWorker } from "~/browser" 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 * 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 * which is done using `lunr`. The index must be passed as an observable to
* enable hacks like _localsearch_ via search index embedding as JSON. * enable hacks like _localsearch_ via search index embedding as JSON.
* *
* @param url - Worker URL * @param url - Worker URL
* @param index - Promise resolving with search index * @param index - Search index observable input
* *
* @returns Search worker * @returns Search worker
*/ */
export function setupSearchWorker( export function setupSearchWorker(
url: string, index: Promise<SearchIndex> url: string, index: ObservableInput<SearchIndex>
): SearchWorker { ): SearchWorker {
const config = configuration() const config = configuration()
const worker = new Worker(url) const worker = new Worker(url)