Implemented instant loading

This commit is contained in:
squidfunk 2020-02-20 17:42:46 +01:00
parent 3aa251fb03
commit ae1ed3d924
31 changed files with 197 additions and 138 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.4cda9c77.min.js",
"assets/javascripts/bundle.js.map": "assets/javascripts/bundle.4cda9c77.min.js.map",
"assets/javascripts/bundle.js": "assets/javascripts/bundle.1defb77e.min.js",
"assets/javascripts/bundle.js.map": "assets/javascripts/bundle.1defb77e.min.js.map",
"assets/javascripts/worker/search.js": "assets/javascripts/worker/search.926ffd9e.min.js",
"assets/javascripts/worker/search.js.map": "assets/javascripts/worker/search.926ffd9e.min.js.map",
"assets/stylesheets/app-palette.scss": "assets/stylesheets/app-palette.3f90c815.min.css",

View File

@ -190,7 +190,7 @@
{% endblock %}
</div>
{% block scripts %}
<script src="{{ 'assets/javascripts/bundle.4cda9c77.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.1defb77e.min.js' | url }}"></script>
<script id="__lang" type="application/json">
{%- set translations = {} -%}
{%- for key in [
@ -209,7 +209,17 @@
{%- endfor -%}
{{ translations | tojson }}
</script>
<script>app=initialize({base:"{{ base_url }}",worker:{search:"{{ 'assets/javascripts/worker/search.926ffd9e.min.js' | url }}"}})</script>
<script>
app = initialize({
base: "{{ base_url }}",
worker: {
search: "{{ 'assets/javascripts/worker/search.926ffd9e.min.js' | url }}"
},
feature: {
instant: {{ feature.instant | tojson }}
}
});
</script>
{% for path in config["extra_javascript"] %}
<script src="{{ path | url }}"></script>
{% endfor %}

View File

@ -32,6 +32,10 @@ feature:
# of tabs, especially useful for larger documentation projects
tabs: false
# Instant loading will instruct the application to intercept all internal
# links, load and inject the HTML into the page and rebind all handlers
instant: false
# Sets the primary and accent color palettes as defined in the Material Design
# documentation - possible values can be looked up in the getting started guide
palette:

View File

@ -22,4 +22,4 @@
mkdocs>=1.0
Pygments>=2.4
markdown>=3.2
pymdown-extensions>=7.0b1
pymdown-extensions>=7.0b2

View File

@ -22,7 +22,14 @@
import { keys } from "ramda"
import { NEVER, Observable, of } from "rxjs"
import { map, scan, shareReplay, switchMap } from "rxjs/operators"
import {
distinctUntilChanged,
map,
scan,
shareReplay,
switchMap,
tap
} from "rxjs/operators"
import { getElement } from "observables"
@ -92,7 +99,7 @@ let components$: Observable<ComponentMap>
export function watchComponentMap(
names: Component[], { document$ }: WatchOptions
): Observable<ComponentMap> {
components$ = document$
return components$ = document$
.pipe(
/* Build component map */
@ -124,12 +131,9 @@ export function watchComponentMap(
}
}
return prev
})
)
}),
/* Return component map as hot observable */
return components$
.pipe(
/* Convert to hot observable */
shareReplay(1)
)
}
@ -152,6 +156,7 @@ export function useComponent<T extends HTMLElement>(
typeof components[name] !== "undefined"
? of(components[name] as T)
: NEVER
))
)),
distinctUntilChanged()
)
}

View File

@ -21,7 +21,7 @@
*/
import { Observable, OperatorFunction, pipe } from "rxjs"
import { shareReplay, switchMap } from "rxjs/operators"
import { switchMap } from "rxjs/operators"
import { Header, Viewport, watchHeader } from "observables"
@ -51,7 +51,6 @@ export function mountHeader(
{ viewport$ }: MountOptions
): OperatorFunction<HTMLElement, Header> {
return pipe(
switchMap(el => watchHeader(el, { viewport$ })),
shareReplay(1)
switchMap(el => watchHeader(el, { viewport$ }))
)
}

View File

@ -21,12 +21,7 @@
*/
import { Observable, OperatorFunction, pipe } from "rxjs"
import {
filter,
map,
shareReplay,
switchMap
} from "rxjs/operators"
import { filter, map, switchMap } from "rxjs/operators"
import {
Header,
@ -74,8 +69,7 @@ export function mountHeaderTitle(
map(({ offset: { y } }) => y >= hx.offsetHeight),
paintHeaderTitle(el)
)
),
shareReplay(1)
)
)
)
)

View File

@ -21,7 +21,7 @@
*/
import { Observable, OperatorFunction, pipe } from "rxjs"
import { map, shareReplay, switchMap } from "rxjs/operators"
import { map, switchMap } from "rxjs/operators"
import {
Header,
@ -73,7 +73,6 @@ export function mountHero(
paintHideable(el, 20),
map(hidden => ({ hidden }))
)
),
shareReplay(1)
)
)
}

View File

@ -21,11 +21,7 @@
*/
import { Observable, OperatorFunction, pipe } from "rxjs"
import {
distinctUntilKeyChanged,
shareReplay,
switchMap
} from "rxjs/operators"
import { distinctUntilKeyChanged, switchMap } from "rxjs/operators"
import {
Header,
@ -81,7 +77,6 @@ export function mountMain(
return main$
})
)
),
shareReplay(1)
)
)
}

View File

@ -21,7 +21,7 @@
*/
import { Observable, OperatorFunction, pipe } from "rxjs"
import { map, shareReplay, switchMap } from "rxjs/operators"
import { map, switchMap } from "rxjs/operators"
import {
Header,
@ -115,7 +115,6 @@ export function mountNavigation(
}
})
)
),
shareReplay(1)
)
)
}

View File

@ -21,7 +21,7 @@
*/
import { Observable, OperatorFunction, combineLatest, pipe } from "rxjs"
import { map, shareReplay, switchMap } from "rxjs/operators"
import { map, switchMap } from "rxjs/operators"
import { SearchResult } from "integrations/search"
import { SearchQuery } from "observables"
@ -68,8 +68,7 @@ export function mountSearch(
return pipe(
switchMap(() => combineLatest([query$, result$, reset$])
.pipe(
map(([query, result]) => ({ query, result })),
shareReplay(1)
map(([query, result]) => ({ query, result }))
))
)
}

View File

@ -27,7 +27,6 @@ import {
filter,
map,
pluck,
shareReplay,
switchMap
} from "rxjs/operators"
@ -90,7 +89,6 @@ export function mountSearchResult(
pluck("data"),
paintSearchResult(el, { query$, fetch$ })
)
}),
shareReplay(1)
})
)
}

View File

@ -21,7 +21,7 @@
*/
import { Observable, OperatorFunction, of, pipe } from "rxjs"
import { map, shareReplay, switchMap } from "rxjs/operators"
import { map, switchMap } from "rxjs/operators"
import {
Header,
@ -87,7 +87,6 @@ export function mountTabs(
}
})
)
),
shareReplay(1)
)
)
}

View File

@ -27,7 +27,7 @@ import {
of,
pipe
} from "rxjs"
import { map, shareReplay, switchMap } from "rxjs/operators"
import { map, switchMap } from "rxjs/operators"
import {
AnchorList,
@ -129,7 +129,6 @@ export function mountTableOfContents(
}
})
)
),
shareReplay(1)
)
)
}

View File

@ -30,7 +30,10 @@ import { values } from "ramda"
import {
merge,
combineLatest,
animationFrameScheduler
animationFrameScheduler,
fromEvent,
of,
NEVER
} from "rxjs"
import {
delay,
@ -41,7 +44,8 @@ import {
observeOn,
take,
mapTo,
shareReplay
shareReplay,
switchMapTo
} from "rxjs/operators"
import {
@ -55,7 +59,8 @@ import {
watchViewport,
watchToggleMap,
useToggle,
getElement
getElement,
watchDocumentSwitch
} from "./observables"
import { setupSearchWorker } from "./workers"
@ -108,9 +113,15 @@ export function initialize(config: unknown) {
if (!isConfig(config))
throw new SyntaxError(`Invalid configuration: ${JSON.stringify(config)}`)
// pass config here!?
const document$ = watchDocument()
const location$ = watchLocation()
// instant loading
const switch$ = config.feature.instant
? watchDocumentSwitch({ location$ })
: NEVER
const load$ = watchDocument()
const document$ = merge(load$, switch$)
const hash$ = watchLocationHash()
const viewport$ = watchViewport()
const tablet$ = watchMedia("(min-width: 960px)")
@ -147,12 +158,14 @@ export function initialize(config: unknown) {
/* Create header observable */
const header$ = useComponent("header")
.pipe(
mountHeader({ viewport$ })
mountHeader({ viewport$ }),
shareReplay(1)
)
const main$ = useComponent("main")
.pipe(
mountMain({ header$, viewport$ })
mountMain({ header$, viewport$ }),
shareReplay(1)
)
/* ----------------------------------------------------------------------- */
@ -167,47 +180,55 @@ export function initialize(config: unknown) {
/* Mount search reset */
const reset$ = useComponent("search-reset")
.pipe(
mountSearchReset()
mountSearchReset(),
shareReplay(1)
)
/* Mount search result */
const result$ = useComponent("search-result")
.pipe(
mountSearchResult(worker, { query$ })
mountSearchResult(worker, { query$ }),
shareReplay(1)
)
/* ----------------------------------------------------------------------- */
const search$ = useComponent("search")
.pipe(
mountSearch({ query$, reset$, result$ })
mountSearch({ query$, reset$, result$ }),
shareReplay(1)
)
/* ----------------------------------------------------------------------- */
const navigation$ = useComponent("navigation")
.pipe(
mountNavigation({ header$, main$, viewport$, screen$ })
mountNavigation({ header$, main$, viewport$, screen$ }),
shareReplay(1)
)
const toc$ = useComponent("toc")
.pipe(
mountTableOfContents({ header$, main$, viewport$, tablet$ })
mountTableOfContents({ header$, main$, viewport$, tablet$ }),
shareReplay(1)
)
const tabs$ = useComponent("tabs")
.pipe(
mountTabs({ header$, viewport$, screen$ })
mountTabs({ header$, viewport$, screen$ }),
shareReplay(1)
)
const hero$ = useComponent("hero")
.pipe(
mountHero({ header$, viewport$ })
mountHero({ header$, viewport$ }),
shareReplay(1)
)
const title$ = useComponent("header-title")
.pipe(
mountHeaderTitle({ header$, viewport$ })
mountHeaderTitle({ header$, viewport$ }),
shareReplay(1)
)
/* ----------------------------------------------------------------------- */
@ -278,6 +299,50 @@ export function initialize(config: unknown) {
/* ----------------------------------------------------------------------- */
// instant loading
const instant$ = config.feature.instant ? load$ // TODO: just use document$ and take(1)
.pipe(
switchMap(({ body }) => fromEvent(body, "click")),
switchMap(ev => {
// two cases: search results which should always load from same domain
// and/or
if (ev.target && ev.target instanceof HTMLElement) {
const anchor = ev.target.closest("a")
if (anchor) {
if (/(:\/\/|^#)/.test(anchor.getAttribute("href")!) === false) {
ev.preventDefault()
// we must copy the value, or weird stuff will happen
const href = anchor.href
history.pushState(true, "", href)
return of(href)
}
}
}
return NEVER
}),
shareReplay(1)
)
: NEVER
// deploy new location
instant$.subscribe(url => {
console.log(`Load ${url}`)
location$.next(url)
})
// scroll to top when document is loaded
instant$
.pipe(
switchMapTo(switch$), // TODO: just use document$ and skip(1)
)
.subscribe(() => {
window.scrollTo(0, 0) // TODO: or scroll element into view
})
/* ----------------------------------------------------------------------- */
// if we use a single tab outside of search, unhide all permalinks.
// TODO: experimental. necessary!?
keyboard$

View File

@ -26,7 +26,7 @@ import {
distinctUntilChanged,
map,
pluck,
shareReplay,
share,
skip,
startWith,
switchMap
@ -67,7 +67,7 @@ export function watchDocumentSwitch(
return location$
.pipe(
startWith(getLocation()),
map(url => url.replace(/#[^#]+$/, "")),
map(url => url.replace(/#[^#]*$/, "")),
distinctUntilChanged(),
skip(1),
@ -81,6 +81,6 @@ export function watchDocumentSwitch(
pluck("response")
)
),
shareReplay(1)
share()
)
}

View File

@ -35,7 +35,6 @@ import {
map,
observeOn,
scan,
shareReplay,
switchMap,
tap
} from "rxjs/operators"
@ -184,8 +183,8 @@ export function watchAnchorList(
)
)
/* Compute anchor list migrations */
const migration$ = partition$
/* Compute and return anchor list migrations */
return partition$
.pipe(
map(([prev, next]) => ({
prev: prev.map(([path]) => path),
@ -202,12 +201,6 @@ export function watchAnchorList(
}
}, { prev: [], next: [] })
)
/* Return anchor list migrations as hot observable */
return migration$
.pipe(
shareReplay(1)
)
}
/* ------------------------------------------------------------------------- */

View File

@ -21,12 +21,7 @@
*/
import { Observable, combineLatest } from "rxjs"
import {
distinctUntilChanged,
map,
pluck,
shareReplay
} from "rxjs/operators"
import { distinctUntilChanged, map, pluck } from "rxjs/operators"
import { Viewport } from "../../agent"
import { Header } from "../../header"
@ -103,14 +98,13 @@ export function watchMain(
distinctUntilChanged()
)
/* Combine into a single hot observable */
/* Combine into a single observable */
return combineLatest([adjust$, height$, active$])
.pipe(
map(([adjust, height, active]) => ({
offset: el.offsetTop - adjust,
height,
active
})),
shareReplay(1)
}))
)
}

View File

@ -33,7 +33,6 @@ import {
finalize,
map,
observeOn,
shareReplay,
tap,
withLatestFrom
} from "rxjs/operators"
@ -136,11 +135,10 @@ export function watchSidebar(
distinctUntilChanged()
)
/* Combine into single hot observable */
/* Combine into single observable */
return combineLatest([height$, lock$])
.pipe(
map(([height, lock]) => ({ height, lock })),
shareReplay(1)
map(([height, lock]) => ({ height, lock }))
)
}

View File

@ -35,7 +35,6 @@ import {
map,
observeOn,
scan,
shareReplay,
tap
} from "rxjs/operators"
@ -99,12 +98,11 @@ export function watchNavigationLayer(
)))
)
/* Return previous and next layer as hot observable */
/* Return previous and next layer */
return layer$
.pipe(
map(next => ({ next })),
scan(({ next: prev }, { next }) => ({ prev, next })),
shareReplay(1)
scan(({ next: prev }, { next }) => ({ prev, next }))
)
}

View File

@ -25,7 +25,6 @@ import {
delay,
distinctUntilChanged,
map,
shareReplay,
startWith
} from "rxjs/operators"
@ -109,10 +108,9 @@ export function watchSearchQuery(
/* Intercept focus events */
const focus$ = watchElementFocus(el)
/* Combine into a single hot observable */
/* Combine into a single observable */
return combineLatest([value$, focus$])
.pipe(
map(([value, focus]) => ({ value, focus })),
shareReplay(1)
map(([value, focus]) => ({ value, focus }))
)
}

View File

@ -22,6 +22,7 @@
import { NEVER, Observable, fromEvent, of } from "rxjs"
import {
distinctUntilChanged,
map,
shareReplay,
startWith,
@ -84,7 +85,7 @@ let toggles$: Observable<ToggleMap>
export function watchToggleMap(
names: Toggle[], { document$ }: WatchOptions
): Observable<ToggleMap> {
toggles$ = document$
return toggles$ = document$
.pipe(
/* Ignore document switches */
@ -97,12 +98,9 @@ export function watchToggleMap(
...toggles,
...typeof el !== "undefined" ? { [name]: el } : {}
}
}, {}))
)
}, {})),
/* Return toggle map as hot observable */
return toggles$
.pipe(
/* Convert to hot observable */
shareReplay(1)
)
}
@ -125,7 +123,8 @@ export function useToggle(
typeof toggles[name] !== "undefined"
? of(toggles[name]!)
: NEVER
))
)),
distinctUntilChanged()
)
}

View File

@ -83,7 +83,7 @@ export function patchDetails(
/* Open parent details and fix anchor jump */
hash$
.pipe(
map(hash => getElement(hash)!),
map(id => getElement(`[id="${id}"]`)!),
filter(el => typeof el !== "undefined"),
tap(el => {
const details = el.closest("details")

View File

@ -32,6 +32,9 @@ export interface Config {
worker: {
search: string /* Search worker URL */
}
feature: {
instant: true /* Instant loading */
}
}
/* ----------------------------------------------------------------------------

View File

@ -56,14 +56,18 @@ interface SetupOptions {
/**
* Resolve URL
* * TODO: document what's going on here + cache results
*
* @param base - Base URL
* @param origin - Base URL
* @param paths - Further URL paths
*
* @return Absolute URL
* @return Relative URL
*/
function resolve(base: URL | string, ...paths: string[]) {
return [base, ...paths].join("")
function resolve(origin: URL, ...paths: string[]) {
const path = location.pathname
.replace(origin.pathname, "")
.replace(/[^\/]+/g, "..")
return [path, ...paths].join("")
}
/* ----------------------------------------------------------------------------
@ -87,7 +91,7 @@ export function setupSearchWorker(
url: string, { base, index }: SetupOptions
): WorkerHandler<SearchMessage> {
const worker = new Worker(url)
const prefix = new URL(base, getLocation())
const origin = new URL(base, getLocation())
/* Create communication channels and resolve relative links */
const tx$ = new Subject<SearchMessage>()
@ -96,9 +100,9 @@ export function setupSearchWorker(
map(message => {
if (isSearchResultMessage(message)) {
for (const { article, sections } of message.data) {
article.location = resolve(prefix, article.location)
article.location = resolve(origin, article.location)
for (const section of sections)
section.location = resolve(prefix, section.location)
section.location = resolve(origin, section.location)
}
}
return message
@ -109,7 +113,7 @@ export function setupSearchWorker(
const index$ = typeof index !== "undefined"
? from(index)
: ajax({
url: resolve(prefix, "search/search_index.json"),
url: resolve(origin, "search/search_index.json"),
responseType: "json",
withCredentials: true
})

View File

@ -400,6 +400,9 @@
base: "{{ base_url }}",
worker: {
search: "{{ 'assets/javascripts/worker/search.js' | url }}"
},
feature: {
instant: {{ feature.instant | tojson }}
}
});
</script>

View File

@ -32,6 +32,10 @@ feature:
# of tabs, especially useful for larger documentation projects
tabs: false
# Instant loading will instruct the application to intercept all internal
# links, load and inject the HTML into the page and rebind all handlers
instant: false
# Sets the primary and accent color palettes as defined in the Material Design
# documentation - possible values can be looked up in the getting started guide
palette: