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

54
.eslintignore Normal file
View File

@ -0,0 +1,54 @@
# 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.
# -----------------------------------------------------------------------------
# Node, TypeScript, Python
# -----------------------------------------------------------------------------
# Dependencies
node_modules
__pycache__
venv
# Build files
build
MANIFEST
manifest.json
site
# Configuration
webpack.config.ts
# Distribution files
dist
mkdocs_material.egg-info
# Caches and logs
*.cpuprofile
*.log
*.tsbuildinfo
.eslintcache
# -----------------------------------------------------------------------------
# General
# -----------------------------------------------------------------------------
# Temporary files
tmp

4
.gitignore vendored
View File

@ -27,10 +27,6 @@ node_modules
__pycache__
venv
# Coverage reports
.nyc_output
coverage
# Build files
build
MANIFEST

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

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,14 +1,14 @@
{
"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/bundle.js": "assets/javascripts/bundle.15008d17.min.js",
"assets/javascripts/bundle.js.map": "assets/javascripts/bundle.15008d17.min.js.map",
"assets/javascripts/vendor.js": "assets/javascripts/vendor.e4dae721.min.js",
"assets/javascripts/vendor.js.map": "assets/javascripts/vendor.e4dae721.min.js.map",
"assets/javascripts/worker/search.js": "assets/javascripts/worker/search.b9424174.min.js",
"assets/javascripts/worker/search.js.map": "assets/javascripts/worker/search.b9424174.min.js.map",
"assets/stylesheets/main.css": "assets/stylesheets/main.1e9203c3.min.css",
"assets/stylesheets/main.css.map": "assets/stylesheets/main.1e9203c3.min.css.map",
"assets/stylesheets/overrides.css": "assets/stylesheets/overrides.470c2618.min.css",
"assets/stylesheets/overrides.css.map": "assets/stylesheets/overrides.470c2618.min.css.map",
"assets/stylesheets/palette.css": "assets/stylesheets/palette.a4a42c2a.min.css",
"assets/stylesheets/palette.css.map": "assets/stylesheets/palette.a4a42c2a.min.css.map"
"assets/stylesheets/main.css": "assets/stylesheets/main.77762a86.min.css",
"assets/stylesheets/main.css.map": "assets/stylesheets/main.77762a86.min.css.map",
"assets/stylesheets/overrides.css": "assets/stylesheets/overrides.177d4d59.min.css",
"assets/stylesheets/overrides.css.map": "assets/stylesheets/overrides.177d4d59.min.css.map",
"assets/stylesheets/palette.css": "assets/stylesheets/palette.07fb9723.min.css",
"assets/stylesheets/palette.css.map": "assets/stylesheets/palette.07fb9723.min.css.map"
}

View File

@ -1,3 +1,3 @@
@-webkit-keyframes tx-heart{0%,40%,80%,100%{transform:scale(1)}20%,60%{transform:scale(1.15)}}@keyframes tx-heart{0%,40%,80%,100%{transform:scale(1)}20%,60%{transform:scale(1.15)}}.md-typeset figure>p+figcaption{margin-top:-1.2rem}.md-typeset .twitter{color:#00acee}.md-typeset .tx-video{width:auto}.md-typeset .tx-video__inner{position:relative;width:100%;height:0;padding-bottom:56.138%}.md-typeset .tx-video iframe{position:absolute;top:0;left:0;width:100%;height:100%;overflow:hidden;border:none}.md-typeset .tx-heart{-webkit-animation:tx-heart 1000ms infinite;animation:tx-heart 1000ms infinite}.md-typeset .tx-insiders{color:#e91e63}.md-typeset .tx-insiders-button{font-weight:400}.md-typeset .tx-insiders-count{font-weight:700}.md-typeset .tx-insiders-list{margin:2em 0;overflow:auto}.md-typeset .tx-insiders-list__item{display:block;float:left;width:3rem;height:3rem;margin:.2rem;overflow:hidden;border-radius:100%;transform:scale(1);transition:color 125ms,transform 125ms}.md-typeset .tx-insiders-list__item img{display:block;width:100%;height:auto;-webkit-filter:grayscale(100%);filter:grayscale(100%);transition:-webkit-filter 125ms;transition:filter 125ms;transition:filter 125ms, -webkit-filter 125ms}.md-typeset .tx-insiders-list__item:focus,.md-typeset .tx-insiders-list__item:hover{transform:scale(1.1)}.md-typeset .tx-insiders-list__item:focus img,.md-typeset .tx-insiders-list__item:hover img{-webkit-filter:grayscale(0%);filter:grayscale(0%)}.md-typeset .tx-insiders-list__item--private{color:var(--md-default-fg-color--lighter);font-weight:700;font-size:1.2rem;line-height:3rem;text-align:center;background:var(--md-default-fg-color--lightest)}.md-typeset .tx-switch button{cursor:pointer;transition:opacity 250ms}.md-typeset .tx-switch button:focus,.md-typeset .tx-switch button:hover{opacity:.75}.md-typeset .tx-switch button>code{display:block;color:var(--md-primary-bg-color);background-color:var(--md-primary-fg-color)}.md-typeset .tx-columns ol,.md-typeset .tx-columns ul{-moz-columns:2;columns:2}@media screen and (max-width: 29.9375em){.md-typeset .tx-columns ol,.md-typeset .tx-columns ul{-moz-columns:initial;columns:initial}}.md-typeset .tx-columns li{-moz-column-break-inside:avoid;break-inside:avoid}.md-announce a,.md-announce a:focus,.md-announce a:hover{color:currentColor}.md-announce strong{white-space:nowrap}.md-announce .twitter{margin-left:.2em}.tx-content__footer{margin-top:1rem;text-align:center}.tx-content__footer a{display:inline-block;color:#e91e63;transition:transform 250ms cubic-bezier(0.1, 0.7, 0.1, 1),color 125ms}.tx-content__footer a:focus,.tx-content__footer a:hover{transform:scale(1.2)}.tx-content__footer hr{display:inline-block;width:2rem;margin:1em;vertical-align:middle;background-color:currentColor;border:none}.tx-container{padding-top:1rem;background:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1123 258'><path d='M1124,2c0,0 0,256 0,256l-1125,0l0,-48c0,0 16,5 55,5c116,0 197,-92 325,-92c121,0 114,46 254,46c140,0 214,-167 572,-166Z' style='fill: hsla(0, 0%, 100%, 1)' /></svg>") no-repeat bottom,linear-gradient(to bottom, var(--md-primary-fg-color), #a63fd9 99%, var(--md-default-bg-color) 99%)}[data-md-color-scheme=slate] .tx-container{background:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1123 258'><path d='M1124,2c0,0 0,256 0,256l-1125,0l0,-48c0,0 16,5 55,5c116,0 197,-92 325,-92c121,0 114,46 254,46c140,0 214,-167 572,-166Z' style='fill: hsla(232, 15%, 21%, 1)' /></svg>") no-repeat bottom,linear-gradient(to bottom, var(--md-primary-fg-color), #a63fd9 99%, var(--md-default-bg-color) 99%)}.tx-hero{margin:0 .8rem;color:var(--md-primary-bg-color)}.tx-hero h1{margin-bottom:1rem;color:currentColor;font-weight:700}@media screen and (max-width: 29.9375em){.tx-hero h1{font-size:1.4rem}}.tx-hero__content{padding-bottom:6rem}@media screen and (min-width: 60em){.tx-hero{display:flex;align-items:stretch}.tx-hero__content{max-width:19rem;margin-top:3.5rem;padding-bottom:14vw}.tx-hero__image{order:1;width:38rem;transform:translateX(4rem)}}@media screen and (min-width: 76.25em){.tx-hero__image{transform:translateX(8rem)}}.tx-hero .md-button{margin-top:.5rem;margin-right:.5rem;color:var(--md-primary-bg-color)}.tx-hero .md-button:focus,.tx-hero .md-button:hover{color:var(--md-default-bg-color);background-color:var(--md-accent-fg-color);border-color:var(--md-accent-fg-color)}.tx-hero .md-button--primary{color:#894da8;background-color:var(--md-primary-bg-color);border-color:var(--md-primary-bg-color)}
/*# sourceMappingURL=overrides.470c2618.min.css.map*/
/*# sourceMappingURL=overrides.177d4d59.min.css.map*/

View File

@ -39,10 +39,10 @@
{% endif %}
{% endblock %}
{% block styles %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.1e9203c3.min.css' | url }}">
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.77762a86.min.css' | url }}">
{% if config.theme.palette %}
{% set palette = config.theme.palette %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.a4a42c2a.min.css' | url }}">
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.07fb9723.min.css' | url }}">
{% if palette.primary %}
{% import "partials/palette.html" as map %}
{% set primary = map.primary(
@ -182,10 +182,10 @@
{% block footer %}
{% include "partials/footer.html" %}
{% endblock %}
</div>
<div class="md-dialog" data-md-component="dialog">
<div class="md-dialog__inner md-typeset"></div>
</div>
</div>
{% block config %}
{%- set app = {
"base": base_url,
@ -216,8 +216,8 @@
</script>
{% endblock %}
{% block scripts %}
<script src="{{ 'assets/javascripts/vendor.250c9a34.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.1ed0f3ef.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/vendor.e4dae721.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.15008d17.min.js' | url }}"></script>
{% for path in config["extra_javascript"] %}
<script src="{{ path | url }}"></script>
{% endfor %}

View File

@ -22,7 +22,7 @@
<meta name="twitter:title" content="{{ title }}">
<meta name="twitter:description" content="{{ config.site_description }}">
<meta name="twitter:image" content="{{ image }}">
<link rel="stylesheet" href="{{ 'assets/stylesheets/overrides.470c2618.min.css' | url }}">
<link rel="stylesheet" href="{{ 'assets/stylesheets/overrides.177d4d59.min.css' | url }}">
{% endblock %}
{% block announce %}
<a href="https://twitter.com/squidfunk">

View File

@ -51,7 +51,7 @@ theme:
# Default values, taken from mkdocs_theme.yml
language: en
features:
# - navigation.instant
- navigation.instant
- navigation.sections
- navigation.tabs
palette:

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]")
const header$ = watchHeader(header)
const main$ = watchMain(main, { header$, viewport$ })
/* Set up Clipboard.js integration */
const message$ = new Subject<string>()
setupClipboardJS({ message$ })
const alert$ = new Subject<string>()
setupClipboardJS({ alert$ })
// TODO: watchElements + general mount function that takes a factory...
// + a toggle function (optionally)
/* Set up instant loading, if enabled */
if (feature("navigation.instant"))
setupInstantLoading({ document$, location$, viewport$ })
// TODO: catch errors on all components when mounting, so one error doesn't
// take down the whole site.
/* Set up header observable */
const header$ = watchHeader(
getElementOrThrow("[data-md-component=header]")
)
const app$ = merge(
/* Set up main area observable */
const main$ = document$
.pipe(
map(() => getElementOrThrow("[data-md-component=main]")),
switchMap(el => watchMain(el, { viewport$, header$ })),
shareReplay(1)
)
/* 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 */
...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)
/* ------------------------------------------------------------------------- */
/**
* Test
*
* @param toggle$ - Toggle observable
* @param factory - Observable factory
*
* @returns New observable
*/
function at<T>(
toggle$: Observable<boolean>, factory: () => Observable<T>
) {
return toggle$
/* Set up component observables */
const component$ = document$
.pipe(
switchMap(active => active ? factory() : NEVER),
switchMap(() => content$),
mergeWith(control$)
)
/* 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,12 +346,12 @@
{% 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>
</div>
<!-- Theme-related configuration -->
{% block config %}