Refactored instant loading and some other components

This commit is contained in:
squidfunk
2021-02-12 12:12:49 +01:00
parent 884330da3b
commit 9c9ea8a64d
33 changed files with 463 additions and 150 deletions

View File

@@ -22,10 +22,10 @@
export * from "./document"
export * from "./element"
export * from "./fetch"
export * from "./keyboard"
export * from "./location"
export * from "./media"
export * from "./request"
export * from "./toggle"
export * from "./viewport"
export * from "./worker"

View File

@@ -20,8 +20,14 @@
* IN THE SOFTWARE.
*/
import { Observable, fromEvent, merge } from "rxjs"
import { filter, map, mapTo, startWith } from "rxjs/operators"
import { NEVER, Observable, fromEvent, merge } from "rxjs"
import {
filter,
map,
mapTo,
startWith,
switchMap
} from "rxjs/operators"
/* ----------------------------------------------------------------------------
* Functions
@@ -57,3 +63,24 @@ export function watchPrint(): Observable<void> {
mapTo(undefined)
)
}
/* ------------------------------------------------------------------------- */
/**
* Toggle an observable with another one
*
* @template T - Data type
*
* @param toggle$ - Toggle observable
* @param factory - Observable factory
*
* @returns Toggled observable
*/
export function at<T>(
toggle$: Observable<boolean>, factory: () => Observable<T>
): Observable<T> {
return toggle$
.pipe(
switchMap(active => active ? factory() : NEVER)
)
}

View File

@@ -28,21 +28,29 @@ import {
switchMap
} from "rxjs/operators"
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* XML parser
*/
const dom = new DOMParser()
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Fetch given URL as JSON
* Fetch the given URL
*
* @param url - Request URL
* @param options - Request options
*
* @returns Response observable
*/
export function request(
url: string, options: RequestInit = { credentials: "same-origin" }
): Observable<Response> {
return from(fetch(url, options))
.pipe(
filter(res => res.status === 200),
)
}
/**
* Fetch JSON from the given URL
*
* @template T - Data type
*
@@ -51,32 +59,31 @@ const dom = new DOMParser()
*
* @returns Data observable
*/
export function fetchJSON<T>(
url: string, options: RequestInit = { credentials: "same-origin" }
export function requestJSON<T>(
url: string, options?: RequestInit
): Observable<T> {
return from(fetch(url, options))
return request(url, options)
.pipe(
filter(res => res.status === 200),
switchMap(res => res.json()),
shareReplay(1)
)
}
/**
* Fetch given URL as XML
* Fetch XML from the given URL
*
* @param url - Request URL
* @param options - Request options
*
* @returns Data observable
*/
export function fetchXML(
url: string, options: RequestInit = { credentials: "same-origin" }
export function requestXML(
url: string, options?: RequestInit
): Observable<Document> {
return from(fetch(url, options))
const dom = new DOMParser()
return request(url, options)
.pipe(
filter(res => res.status === 200),
switchMap(res => res.json()),
switchMap(res => res.text()),
map(res => dom.parseFromString(res, "text/xml")),
shareReplay(1)
)

View File

@@ -64,14 +64,14 @@ export interface Dialog {
* Watch options
*/
interface WatchOptions {
message$: Subject<string> /* Message subject */
alert$: Subject<string> /* Alert subject */
}
/**
* Mount options
*/
interface MountOptions {
message$: Subject<string> /* Message subject */
alert$: Subject<string> /* Alert subject */
}
/* ----------------------------------------------------------------------------
@@ -87,9 +87,9 @@ interface MountOptions {
* @returns Dialog observable
*/
export function watchDialog(
_el: HTMLElement, { message$ }: WatchOptions
_el: HTMLElement, { alert$ }: WatchOptions
): Observable<Dialog> {
return message$
return alert$
.pipe(
switchMap(message => merge(
of(true),
@@ -111,7 +111,7 @@ export function watchDialog(
* @returns Dialog component observable
*/
export function mountDialog(
el: HTMLElement, { message$ }: MountOptions
el: HTMLElement, options: MountOptions
): Observable<Component<Dialog>> {
const internal$ = new Subject<Dialog>()
internal$
@@ -127,7 +127,7 @@ export function mountDialog(
})
/* Create and return component */
return watchDialog(el, { message$ })
return watchDialog(el, options)
.pipe(
tap(internal$),
finalize(() => internal$.complete()),

View File

@@ -97,7 +97,8 @@ export function watchHeader(
distinctUntilChanged((a, b) => (
a.sticky === b.sticky &&
a.height === b.height
))
)),
shareReplay(1)
)
}
@@ -134,7 +135,6 @@ export function mountHeader(
main$.subscribe(main => internal$.next(main))
return header$
.pipe(
map(state => ({ ref: el, ...state })),
shareReplay(1)
map(state => ({ ref: el, ...state }))
)
}

View File

@@ -28,7 +28,6 @@ import {
distinctUntilChanged,
distinctUntilKeyChanged,
map,
shareReplay,
switchMap
} from "rxjs/operators"
@@ -120,7 +119,6 @@ export function watchMain(
a.offset === b.offset &&
a.height === b.height &&
a.active === b.active
)),
shareReplay(1)
))
)
}

View File

@@ -24,7 +24,7 @@ import { Observable, merge } from "rxjs"
import { filter, sample, take } from "rxjs/operators"
import { configuration } from "~/_"
import { fetchJSON, getElementOrThrow } from "~/browser"
import { requestJSON, getElementOrThrow } from "~/browser"
import {
SearchIndex,
isSearchQueryMessage,
@@ -59,7 +59,7 @@ export type Search =
* @returns Promise resolving with search index
*/
function fetchSearchIndex(url: string) {
return __search?.index || fetchJSON<SearchIndex>(url)
return __search?.index || requestJSON<SearchIndex>(url)
}
/* ----------------------------------------------------------------------------

View File

@@ -24,7 +24,7 @@ import { Repo, User } from "github-types"
import { Observable } from "rxjs"
import { defaultIfEmpty, map } from "rxjs/operators"
import { fetchJSON } from "~/browser"
import { requestJSON } from "~/browser"
import { round } from "~/utilities"
import { SourceFacts } from "../_"
@@ -47,7 +47,7 @@ export function fetchSourceFactsFromGitHub(
const url = typeof repo !== "undefined"
? `https://api.github.com/repos/${user}/${repo}`
: `https://api.github.com/users/${user}`
return fetchJSON<Repo & User>(url)
return requestJSON<Repo & User>(url)
.pipe(
map(data => {

View File

@@ -24,7 +24,7 @@ import { ProjectSchema } from "gitlab"
import { Observable } from "rxjs"
import { defaultIfEmpty, map } from "rxjs/operators"
import { fetchJSON } from "~/browser"
import { requestJSON } from "~/browser"
import { round } from "~/utilities"
import { SourceFacts } from "../_"
@@ -45,7 +45,7 @@ export function fetchSourceFactsFromGitLab(
base: string, project: string
): Observable<SourceFacts> {
const url = `https://${base}/api/v4/projects/${encodeURIComponent(project)}`
return fetchJSON<ProjectSchema>(url)
return requestJSON<ProjectSchema>(url)
.pipe(
map(({ star_count, forks_count }) => ([
`${round(star_count)} Stars`,

View File

@@ -21,12 +21,21 @@
*/
import "focus-visible"
import { NEVER, Observable, Subject, merge } from "rxjs"
import { switchMap } from "rxjs/operators"
import { Subject, defer, merge } from "rxjs"
import {
map,
mergeWith,
shareReplay,
switchMap
} from "rxjs/operators"
import { feature } from "./_"
import {
at,
getElementOrThrow,
getElements,
watchDocument,
watchLocation,
watchLocationTarget,
watchMedia,
watchPrint,
@@ -46,7 +55,8 @@ import {
watchMain
} from "./components"
import {
setupClipboardJS
setupClipboardJS,
setupInstantLoading
} from "./integrations"
/* ----------------------------------------------------------------------------
@@ -57,61 +67,53 @@ import {
document.documentElement.classList.remove("no-js")
document.documentElement.classList.add("js")
/* Set up subjects */
/* Set up navigation observables */
const document$ = watchDocument()
const location$ = watchLocation()
const target$ = watchLocationTarget()
/* Set up user interface observables */
/* Set up media observables */
const viewport$ = watchViewport()
const tablet$ = watchMedia("(min-width: 960px)")
const screen$ = watchMedia("(min-width: 1220px)")
const print$ = watchPrint()
// these elements MUST be available
const header = getElementOrThrow("[data-md-component=header]")
const main = getElementOrThrow("[data-md-component=main]")
/* Set up Clipboard.js integration */
const alert$ = new Subject<string>()
setupClipboardJS({ alert$ })
const header$ = watchHeader(header)
const main$ = watchMain(main, { header$, viewport$ })
/* Set up instant loading, if enabled */
if (feature("navigation.instant"))
setupInstantLoading({ document$, location$, viewport$ })
/* Setup Clipboard.js integration */
const message$ = new Subject<string>()
setupClipboardJS({ message$ })
/* Set up header observable */
const header$ = watchHeader(
getElementOrThrow("[data-md-component=header]")
)
// TODO: watchElements + general mount function that takes a factory...
// + a toggle function (optionally)
/* Set up main area observable */
const main$ = document$
.pipe(
map(() => getElementOrThrow("[data-md-component=main]")),
switchMap(el => watchMain(el, { viewport$, header$ })),
shareReplay(1)
)
// TODO: catch errors on all components when mounting, so one error doesn't
// take down the whole site.
const app$ = merge(
/* Content */
...getElements("[data-md-component=content]")
.map(child => mountContent(child, { target$, viewport$, print$ })),
/* Set up control component observables */
const control$ = merge(
/* Dialog */
...getElements("[data-md-component=dialog]")
.map(child => mountDialog(child, { message$ })),
.map(child => mountDialog(child, { alert$ })),
/* Header */
/* Header */
...getElements("[data-md-component=header]")
.map(child => mountHeader(child, { viewport$, header$, main$ })),
/* Header title */
...getElements("[data-md-component=header-title]")
.map(child => mountHeaderTitle(child, { viewport$, header$ })),
/* Search */
...getElements("[data-md-component=search]")
.map(child => mountSearch(child)),
/* Sidebar */
...getElements("[data-md-component=sidebar]")
.map(child => child.getAttribute("data-md-type") === "navigation"
? at(screen$, () => mountSidebar(child, { viewport$, header$, main$ }))
: at(tablet$, () => mountSidebar(child, { viewport$, header$, main$ }))
),
/* Repository information */
...getElements("[data-md-component=source]")
.map(child => mountSource(child as HTMLAnchorElement)),
@@ -119,30 +121,46 @@ const app$ = merge(
/* Navigation tabs */
...getElements("[data-md-component=tabs]")
.map(child => mountTabs(child, { viewport$, header$ })),
)
/* Set up content component observables */
const content$ = defer(() => merge(
/* Content */
...getElements("[data-md-component=content]")
.map(child => mountContent(child, { target$, viewport$, print$ })),
/* Header title */
...getElements("[data-md-component=header-title]")
.map(child => mountHeaderTitle(child, { viewport$, header$ })),
/* Sidebar */
...getElements("[data-md-component=sidebar]")
.map(child => child.getAttribute("data-md-type") === "navigation"
? at(screen$, () => mountSidebar(child, { viewport$, header$, main$ }))
: at(tablet$, () => mountSidebar(child, { viewport$, header$, main$ }))
),
/* Table of contents */
...getElements("[data-md-component=toc]")
.map(child => mountTableOfContents(child, { viewport$, header$ })),
)
))
// eslint-disable-next-line
app$.subscribe(console.log)
/* Set up component observables */
const component$ = document$
.pipe(
switchMap(() => content$),
mergeWith(control$)
)
/* ------------------------------------------------------------------------- */
/**
* Test
*
* @param toggle$ - Toggle observable
* @param factory - Observable factory
*
* @returns New observable
*/
function at<T>(
toggle$: Observable<boolean>, factory: () => Observable<T>
) {
return toggle$
.pipe(
switchMap(active => active ? factory() : NEVER),
)
/* Export to window */
export {
document$,
component$,
viewport$,
location$,
target$,
screen$,
tablet$,
print$
}

View File

@@ -33,7 +33,7 @@ import { translation } from "~/_"
* Setup options
*/
interface SetupOptions {
message$: Subject<string> /* Message subject */
alert$: Subject<string> /* Alert subject */
}
/* ----------------------------------------------------------------------------
@@ -46,13 +46,13 @@ interface SetupOptions {
* @param options - Options
*/
export function setupClipboardJS(
{ message$ }: SetupOptions
{ alert$ }: SetupOptions
): void {
if (!ClipboardJS.isSupported()) {
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")))
.subscribe(() => alert$.next(translation("clipboard.copied")))
}
}

View File

@@ -1,5 +1,6 @@
{
"rules": {
"no-self-assign": "off"
"no-self-assign": "off",
"no-null/no-null": "off"
}
}

View File

@@ -20,9 +20,42 @@
* IN THE SOFTWARE.
*/
import { Observable, Subject, fromEvent } from "rxjs"
import {
NEVER,
Observable,
Subject,
fromEvent,
merge,
of
} from "rxjs"
import {
bufferCount,
catchError,
debounceTime,
distinctUntilChanged,
distinctUntilKeyChanged,
filter,
map,
sample,
share,
skip,
skipUntil,
switchMap
} from "rxjs/operators"
import { Viewport, ViewportOffset, getElement } from "~/browser"
import { configuration } from "~/_"
import {
Viewport,
ViewportOffset,
getElement,
getElements,
replaceElement,
request,
requestXML,
setLocation,
setLocationHash,
setViewportOffset
} from "~/browser"
/* ----------------------------------------------------------------------------
* Types
@@ -49,6 +82,42 @@ interface SetupOptions {
viewport$: Observable<Viewport> /* Viewport observable */
}
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Preprocess a list of URLs
*
* This function replaces the `site_url` in the sitemap with the actual base
* URL, to allow instant loading to work in occasions like Netlify previews.
*
* @param urls - URLs
*
* @returns Processed URLs
*/
function preprocess(urls: string[]): string[] {
if (urls.length < 2)
return urls
/* Compute references URLs */
const [root, next] = urls.sort((a, b) => a.length - b.length)
/* Compute common prefix */
let index = 0
if (root === next)
index = root.length
else
while (root.charCodeAt(index) === root.charCodeAt(index))
index++
/* Replace common prefix (i.e. base) with effective base */
const config = configuration()
return urls.map(url => (
url.replace(root.slice(0, index), `${config.base}/`)
))
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
@@ -72,12 +141,12 @@ interface SetupOptions {
* 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
{ document$, location$, viewport$ }: SetupOptions
): void {
const config = configuration()
if (location.protocol === "file:")
return
@@ -97,6 +166,149 @@ export function setupInstantLoading(
if (typeof favicon !== "undefined")
favicon.href = favicon.href
// eslint-disable-next-line
console.log(urls, document$, location$, viewport$)
/* Intercept internal navigation */
const push$ = requestXML(`${config.base}/sitemap.xml`)
.pipe(
map(sitemap => preprocess(getElements("loc", sitemap)
.map(node => node.textContent!)
)),
switchMap(urls => fromEvent<MouseEvent>(document.body, "click")
.pipe(
filter(ev => !ev.metaKey && !ev.ctrlKey),
switchMap(ev => {
if (ev.target instanceof HTMLElement) {
const el = ev.target.closest("a")
if (el && !el.target && urls.includes(el.href)) {
ev.preventDefault()
return of<HistoryState>({
url: new URL(el.href)
})
}
}
return NEVER
})
)
),
share()
)
/* Intercept history back and forward */
const pop$ = fromEvent<PopStateEvent>(window, "popstate")
.pipe(
filter(ev => ev.state !== null),
map(ev => ({
url: new URL(location.href),
offset: ev.state
} as HistoryState)),
share()
)
/* Emit location change */
merge(push$, pop$)
.pipe(
distinctUntilChanged((a, b) => a.url.href === b.url.href),
map(({ url }) => url)
)
.subscribe(location$)
/* Fetch document via `XMLHTTPRequest` */
const response$ = location$
.pipe(
distinctUntilKeyChanged("pathname"),
skip(1),
switchMap(url => request(url.href)
.pipe(
catchError(() => {
setLocation(url)
return NEVER
})
)
),
share()
)
/* Set new location via `history.pushState` */
push$
.pipe(
sample(response$)
)
.subscribe(({ url }) => {
history.pushState({}, "", url.toString())
})
/* Parse and emit fetched document */
const dom = new DOMParser()
response$
.pipe(
switchMap(res => res.text()),
map(res => dom.parseFromString(res, "text/html"))
)
.subscribe(document$)
/* Emit history state change */
merge(push$, pop$)
.pipe(
sample(document$)
)
.subscribe(({ url, offset }) => {
if (url.hash && !offset)
setLocationHash(url.hash)
else
setViewportOffset(offset || { y: 0 })
})
/* Replace components */
document$
.pipe(
skip(1)
)
.subscribe(replacement => {
/* Replace meta tags and components */
for (const selector of [
/* Meta tags */
"title",
"link[rel='canonical']",
"meta[name='author']",
"meta[name='description']",
/* Components */
"[data-md-component=announce]",
"[data-md-component=header-title]",
"[data-md-component=container]",
"[data-md-component=skip]"
]) {
const next = getElement(selector, replacement)
const prev = getElement(selector)
if (
typeof next !== "undefined" &&
typeof prev !== "undefined"
) {
replaceElement(prev, next)
}
}
})
/* Debounce update of viewport offset */
viewport$
.pipe(
skipUntil(push$),
debounceTime(250),
distinctUntilKeyChanged("offset")
)
.subscribe(({ offset }) => {
history.replaceState(offset, "")
})
/* Set viewport offset from history */
merge(push$, pop$)
.pipe(
bufferCount(2, 1),
filter(([a, b]) => a.url.pathname === b.url.pathname),
map(([, state]) => state)
)
.subscribe(({ offset }) => {
setViewportOffset(offset || { y: 0 })
})
}

View File

@@ -346,11 +346,11 @@
{% block footer %}
{% include "partials/footer.html" %}
{% endblock %}
</div>
<!-- Dialog -->
<div class="md-dialog" data-md-component="dialog">
<div class="md-dialog__inner md-typeset"></div>
</div>
<!-- Dialog -->
<div class="md-dialog" data-md-component="dialog">
<div class="md-dialog__inner md-typeset"></div>
</div>
<!-- Theme-related configuration -->