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__ __pycache__
venv venv
# Coverage reports
.nyc_output
coverage
# Build files # Build files
build build
MANIFEST 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": "assets/javascripts/bundle.15008d17.min.js",
"assets/javascripts/bundle.js.map": "assets/javascripts/bundle.1ed0f3ef.min.js.map", "assets/javascripts/bundle.js.map": "assets/javascripts/bundle.15008d17.min.js.map",
"assets/javascripts/vendor.js": "assets/javascripts/vendor.250c9a34.min.js", "assets/javascripts/vendor.js": "assets/javascripts/vendor.e4dae721.min.js",
"assets/javascripts/vendor.js.map": "assets/javascripts/vendor.250c9a34.min.js.map", "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": "assets/javascripts/worker/search.b9424174.min.js",
"assets/javascripts/worker/search.js.map": "assets/javascripts/worker/search.b9424174.min.js.map", "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": "assets/stylesheets/main.77762a86.min.css",
"assets/stylesheets/main.css.map": "assets/stylesheets/main.1e9203c3.min.css.map", "assets/stylesheets/main.css.map": "assets/stylesheets/main.77762a86.min.css.map",
"assets/stylesheets/overrides.css": "assets/stylesheets/overrides.470c2618.min.css", "assets/stylesheets/overrides.css": "assets/stylesheets/overrides.177d4d59.min.css",
"assets/stylesheets/overrides.css.map": "assets/stylesheets/overrides.470c2618.min.css.map", "assets/stylesheets/overrides.css.map": "assets/stylesheets/overrides.177d4d59.min.css.map",
"assets/stylesheets/palette.css": "assets/stylesheets/palette.a4a42c2a.min.css", "assets/stylesheets/palette.css": "assets/stylesheets/palette.07fb9723.min.css",
"assets/stylesheets/palette.css.map": "assets/stylesheets/palette.a4a42c2a.min.css.map" "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)} @-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 %} {% endif %}
{% endblock %} {% endblock %}
{% block styles %} {% 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 %} {% if config.theme.palette %}
{% set palette = 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 %} {% if palette.primary %}
{% import "partials/palette.html" as map %} {% import "partials/palette.html" as map %}
{% set primary = map.primary( {% set primary = map.primary(
@ -182,10 +182,10 @@
{% block footer %} {% block footer %}
{% include "partials/footer.html" %} {% include "partials/footer.html" %}
{% endblock %} {% endblock %}
</div>
<div class="md-dialog" data-md-component="dialog"> <div class="md-dialog" data-md-component="dialog">
<div class="md-dialog__inner md-typeset"></div> <div class="md-dialog__inner md-typeset"></div>
</div> </div>
</div>
{% block config %} {% block config %}
{%- set app = { {%- set app = {
"base": base_url, "base": base_url,
@ -216,8 +216,8 @@
</script> </script>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ 'assets/javascripts/vendor.250c9a34.min.js' | url }}"></script> <script src="{{ 'assets/javascripts/vendor.e4dae721.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.1ed0f3ef.min.js' | url }}"></script> <script src="{{ 'assets/javascripts/bundle.15008d17.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

@ -22,7 +22,7 @@
<meta name="twitter:title" content="{{ title }}"> <meta name="twitter:title" content="{{ title }}">
<meta name="twitter:description" content="{{ config.site_description }}"> <meta name="twitter:description" content="{{ config.site_description }}">
<meta name="twitter:image" content="{{ image }}"> <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 %} {% endblock %}
{% block announce %} {% block announce %}
<a href="https://twitter.com/squidfunk"> <a href="https://twitter.com/squidfunk">

View File

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

View File

@ -22,10 +22,10 @@
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"
export * from "./request"
export * from "./toggle" export * from "./toggle"
export * from "./viewport" export * from "./viewport"
export * from "./worker" export * from "./worker"

View File

@ -20,8 +20,14 @@
* IN THE SOFTWARE. * IN THE SOFTWARE.
*/ */
import { Observable, fromEvent, merge } from "rxjs" import { NEVER, Observable, fromEvent, merge } from "rxjs"
import { filter, map, mapTo, startWith } from "rxjs/operators" import {
filter,
map,
mapTo,
startWith,
switchMap
} from "rxjs/operators"
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
* Functions * Functions
@ -57,3 +63,24 @@ export function watchPrint(): Observable<void> {
mapTo(undefined) 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 switchMap
} from "rxjs/operators" } from "rxjs/operators"
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* XML parser
*/
const dom = new DOMParser()
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
* Functions * 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 * @template T - Data type
* *
@ -51,32 +59,31 @@ const dom = new DOMParser()
* *
* @returns Data observable * @returns Data observable
*/ */
export function fetchJSON<T>( export function requestJSON<T>(
url: string, options: RequestInit = { credentials: "same-origin" } url: string, options?: RequestInit
): Observable<T> { ): Observable<T> {
return from(fetch(url, options)) return request(url, options)
.pipe( .pipe(
filter(res => res.status === 200),
switchMap(res => res.json()), switchMap(res => res.json()),
shareReplay(1) shareReplay(1)
) )
} }
/** /**
* Fetch given URL as XML * Fetch XML from the given URL
* *
* @param url - Request URL * @param url - Request URL
* @param options - Request options * @param options - Request options
* *
* @returns Data observable * @returns Data observable
*/ */
export function fetchXML( export function requestXML(
url: string, options: RequestInit = { credentials: "same-origin" } url: string, options?: RequestInit
): Observable<Document> { ): Observable<Document> {
return from(fetch(url, options)) const dom = new DOMParser()
return request(url, options)
.pipe( .pipe(
filter(res => res.status === 200), switchMap(res => res.text()),
switchMap(res => res.json()),
map(res => dom.parseFromString(res, "text/xml")), map(res => dom.parseFromString(res, "text/xml")),
shareReplay(1) shareReplay(1)
) )

View File

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

View File

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

View File

@ -28,7 +28,6 @@ import {
distinctUntilChanged, distinctUntilChanged,
distinctUntilKeyChanged, distinctUntilKeyChanged,
map, map,
shareReplay,
switchMap switchMap
} from "rxjs/operators" } from "rxjs/operators"
@ -120,7 +119,6 @@ export function watchMain(
a.offset === b.offset && a.offset === b.offset &&
a.height === b.height && a.height === b.height &&
a.active === b.active 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 { filter, sample, take } from "rxjs/operators"
import { configuration } from "~/_" import { configuration } from "~/_"
import { fetchJSON, getElementOrThrow } from "~/browser" import { requestJSON, getElementOrThrow } from "~/browser"
import { import {
SearchIndex, SearchIndex,
isSearchQueryMessage, isSearchQueryMessage,
@ -59,7 +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 || 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 { Observable } from "rxjs"
import { defaultIfEmpty, map } from "rxjs/operators" import { defaultIfEmpty, map } from "rxjs/operators"
import { fetchJSON } from "~/browser" import { requestJSON } from "~/browser"
import { round } from "~/utilities" import { round } from "~/utilities"
import { SourceFacts } from "../_" import { SourceFacts } from "../_"
@ -47,7 +47,7 @@ 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 fetchJSON<Repo & User>(url) return requestJSON<Repo & User>(url)
.pipe( .pipe(
map(data => { map(data => {

View File

@ -24,7 +24,7 @@ import { ProjectSchema } from "gitlab"
import { Observable } from "rxjs" import { Observable } from "rxjs"
import { defaultIfEmpty, map } from "rxjs/operators" import { defaultIfEmpty, map } from "rxjs/operators"
import { fetchJSON } from "~/browser" import { requestJSON } from "~/browser"
import { round } from "~/utilities" import { round } from "~/utilities"
import { SourceFacts } from "../_" import { SourceFacts } from "../_"
@ -45,7 +45,7 @@ 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 fetchJSON<ProjectSchema>(url) return requestJSON<ProjectSchema>(url)
.pipe( .pipe(
map(({ star_count, forks_count }) => ([ map(({ star_count, forks_count }) => ([
`${round(star_count)} Stars`, `${round(star_count)} Stars`,

View File

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

View File

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

View File

@ -20,9 +20,42 @@
* IN THE SOFTWARE. * 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 * Types
@ -49,6 +82,42 @@ interface SetupOptions {
viewport$: Observable<Viewport> /* Viewport observable */ 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 * Functions
* ------------------------------------------------------------------------- */ * ------------------------------------------------------------------------- */
@ -72,12 +141,12 @@ interface SetupOptions {
* history will be irreversibly overwritten. In case the request fails, the * history will be irreversibly overwritten. In case the request fails, the
* location change is dispatched regularly. * location change is dispatched regularly.
* *
* @param urls - URLs to load with XHR
* @param options - Options * @param options - Options
*/ */
export function setupInstantLoading( export function setupInstantLoading(
urls: string[], { document$, location$, viewport$ }: SetupOptions { document$, location$, viewport$ }: SetupOptions
): void { ): void {
const config = configuration()
if (location.protocol === "file:") if (location.protocol === "file:")
return return
@ -97,6 +166,149 @@ export function setupInstantLoading(
if (typeof favicon !== "undefined") if (typeof favicon !== "undefined")
favicon.href = favicon.href favicon.href = favicon.href
// eslint-disable-next-line /* Intercept internal navigation */
console.log(urls, document$, location$, viewport$) 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 %} {% block footer %}
{% include "partials/footer.html" %} {% include "partials/footer.html" %}
{% endblock %} {% endblock %}
</div>
<!-- Dialog --> <!-- Dialog -->
<div class="md-dialog" data-md-component="dialog"> <div class="md-dialog" data-md-component="dialog">
<div class="md-dialog__inner md-typeset"></div> <div class="md-dialog__inner md-typeset"></div>
</div> </div>
</div>
<!-- Theme-related configuration --> <!-- Theme-related configuration -->
{% block config %} {% block config %}