aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src/templates/assets/javascripts/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/templates/assets/javascripts/components')
-rw-r--r--src/templates/assets/javascripts/components/_/index.ts138
-rw-r--r--src/templates/assets/javascripts/components/announce/index.ts110
-rw-r--r--src/templates/assets/javascripts/components/consent/index.ts116
-rw-r--r--src/templates/assets/javascripts/components/content/_/index.ts125
-rw-r--r--src/templates/assets/javascripts/components/content/annotation/_/index.ts272
-rw-r--r--src/templates/assets/javascripts/components/content/annotation/block/index.ts88
-rw-r--r--src/templates/assets/javascripts/components/content/annotation/index.ts25
-rw-r--r--src/templates/assets/javascripts/components/content/annotation/list/index.ts209
-rw-r--r--src/templates/assets/javascripts/components/content/code/_/index.ts238
-rw-r--r--src/templates/assets/javascripts/components/content/code/index.ts23
-rw-r--r--src/templates/assets/javascripts/components/content/details/index.ts138
-rw-r--r--src/templates/assets/javascripts/components/content/index.ts28
-rw-r--r--src/templates/assets/javascripts/components/content/mermaid/index.css430
-rw-r--r--src/templates/assets/javascripts/components/content/mermaid/index.ts133
-rw-r--r--src/templates/assets/javascripts/components/content/table/index.ts70
-rw-r--r--src/templates/assets/javascripts/components/content/tabs/index.ts265
-rw-r--r--src/templates/assets/javascripts/components/dialog/index.ts128
-rw-r--r--src/templates/assets/javascripts/components/header/_/index.ts200
-rw-r--r--src/templates/assets/javascripts/components/header/index.ts24
-rw-r--r--src/templates/assets/javascripts/components/header/title/index.ts144
-rw-r--r--src/templates/assets/javascripts/components/index.ts37
-rw-r--r--src/templates/assets/javascripts/components/main/index.ts125
-rw-r--r--src/templates/assets/javascripts/components/palette/index.ts180
-rw-r--r--src/templates/assets/javascripts/components/progress/index.ts87
-rw-r--r--src/templates/assets/javascripts/components/search/_/index.ts239
-rw-r--r--src/templates/assets/javascripts/components/search/highlight/.eslintrc5
-rw-r--r--src/templates/assets/javascripts/components/search/highlight/index.ts115
-rw-r--r--src/templates/assets/javascripts/components/search/index.ts28
-rw-r--r--src/templates/assets/javascripts/components/search/query/index.ts206
-rw-r--r--src/templates/assets/javascripts/components/search/result/index.ts197
-rw-r--r--src/templates/assets/javascripts/components/search/share/index.ts135
-rw-r--r--src/templates/assets/javascripts/components/search/suggest/index.ts154
-rw-r--r--src/templates/assets/javascripts/components/sidebar/index.ts227
-rw-r--r--src/templates/assets/javascripts/components/source/_/index.ts142
-rw-r--r--src/templates/assets/javascripts/components/source/facts/_/index.ts88
-rw-r--r--src/templates/assets/javascripts/components/source/facts/github/index.ts103
-rw-r--r--src/templates/assets/javascripts/components/source/facts/gitlab/index.ts61
-rw-r--r--src/templates/assets/javascripts/components/source/facts/index.ts25
-rw-r--r--src/templates/assets/javascripts/components/source/index.ts24
-rw-r--r--src/templates/assets/javascripts/components/tabs/index.ts144
-rw-r--r--src/templates/assets/javascripts/components/toc/index.ts379
-rw-r--r--src/templates/assets/javascripts/components/top/index.ts184
42 files changed, 5789 insertions, 0 deletions
diff --git a/src/templates/assets/javascripts/components/_/index.ts b/src/templates/assets/javascripts/components/_/index.ts
new file mode 100644
index 00000000..61c471d9
--- /dev/null
+++ b/src/templates/assets/javascripts/components/_/index.ts
@@ -0,0 +1,138 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import { getElement, getElements } from "~/browser"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Component type
+ */
+export type ComponentType =
+ | "announce" /* Announcement bar */
+ | "container" /* Container */
+ | "consent" /* Consent */
+ | "content" /* Content */
+ | "dialog" /* Dialog */
+ | "header" /* Header */
+ | "header-title" /* Header title */
+ | "header-topic" /* Header topic */
+ | "main" /* Main area */
+ | "outdated" /* Version warning */
+ | "palette" /* Color palette */
+ | "progress" /* Progress indicator */
+ | "search" /* Search */
+ | "search-query" /* Search input */
+ | "search-result" /* Search results */
+ | "search-share" /* Search sharing */
+ | "search-suggest" /* Search suggestions */
+ | "sidebar" /* Sidebar */
+ | "skip" /* Skip link */
+ | "source" /* Repository information */
+ | "tabs" /* Navigation tabs */
+ | "toc" /* Table of contents */
+ | "top" /* Back-to-top button */
+
+/**
+ * Component
+ *
+ * @template T - Component type
+ * @template U - Reference type
+ */
+export type Component<
+ T extends {} = {},
+ U extends HTMLElement = HTMLElement
+> =
+ T & {
+ ref: U /* Component reference */
+ }
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Component type map
+ */
+interface ComponentTypeMap {
+ "announce": HTMLElement /* Announcement bar */
+ "container": HTMLElement /* Container */
+ "consent": HTMLElement /* Consent */
+ "content": HTMLElement /* Content */
+ "dialog": HTMLElement /* Dialog */
+ "header": HTMLElement /* Header */
+ "header-title": HTMLElement /* Header title */
+ "header-topic": HTMLElement /* Header topic */
+ "main": HTMLElement /* Main area */
+ "outdated": HTMLElement /* Version warning */
+ "palette": HTMLElement /* Color palette */
+ "progress": HTMLElement /* Progress indicator */
+ "search": HTMLElement /* Search */
+ "search-query": HTMLInputElement /* Search input */
+ "search-result": HTMLElement /* Search results */
+ "search-share": HTMLAnchorElement /* Search sharing */
+ "search-suggest": HTMLElement /* Search suggestions */
+ "sidebar": HTMLElement /* Sidebar */
+ "skip": HTMLAnchorElement /* Skip link */
+ "source": HTMLAnchorElement /* Repository information */
+ "tabs": HTMLElement /* Navigation tabs */
+ "toc": HTMLElement /* Table of contents */
+ "top": HTMLAnchorElement /* Back-to-top button */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Retrieve the element for a given component or throw a reference error
+ *
+ * @template T - Component type
+ *
+ * @param type - Component type
+ * @param node - Node of reference
+ *
+ * @returns Element
+ */
+export function getComponentElement<T extends ComponentType>(
+ type: T, node: ParentNode = document
+): ComponentTypeMap[T] {
+ return getElement(`[data-md-component=${type}]`, node)
+}
+
+/**
+ * Retrieve all elements for a given component
+ *
+ * @template T - Component type
+ *
+ * @param type - Component type
+ * @param node - Node of reference
+ *
+ * @returns Elements
+ */
+export function getComponentElements<T extends ComponentType>(
+ type: T, node: ParentNode = document
+): ComponentTypeMap[T][] {
+ return getElements(`[data-md-component=${type}]`, node)
+}
diff --git a/src/templates/assets/javascripts/components/announce/index.ts b/src/templates/assets/javascripts/components/announce/index.ts
new file mode 100644
index 00000000..dd04b4ff
--- /dev/null
+++ b/src/templates/assets/javascripts/components/announce/index.ts
@@ -0,0 +1,110 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ EMPTY,
+ Observable,
+ Subject,
+ defer,
+ finalize,
+ fromEvent,
+ map,
+ tap
+} from "rxjs"
+
+import { feature } from "~/_"
+import { getElement } from "~/browser"
+
+import { Component } from "../_"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Announcement bar
+ */
+export interface Announce {
+ hash: number /* Content hash */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch announcement bar
+ *
+ * @param el - Announcement bar element
+ *
+ * @returns Announcement bar observable
+ */
+export function watchAnnounce(
+ el: HTMLElement
+): Observable<Announce> {
+ const button = getElement(".md-typeset > :first-child", el)
+ return fromEvent(button, "click", { once: true })
+ .pipe(
+ map(() => getElement(".md-typeset", el)),
+ map(content => ({ hash: __md_hash(content.innerHTML) }))
+ )
+}
+
+/**
+ * Mount announcement bar
+ *
+ * @param el - Announcement bar element
+ *
+ * @returns Announcement bar component observable
+ */
+export function mountAnnounce(
+ el: HTMLElement
+): Observable<Component<Announce>> {
+ if (!feature("announce.dismiss") || !el.childElementCount)
+ return EMPTY
+
+ /* Support instant navigation - see https://t.ly/3FTme */
+ if (!el.hidden) {
+ const content = getElement(".md-typeset", el)
+ if (__md_hash(content.innerHTML) === __md_get("__announce"))
+ el.hidden = true
+ }
+
+ /* Mount component on subscription */
+ return defer(() => {
+ const push$ = new Subject<Announce>()
+ push$.subscribe(({ hash }) => {
+ el.hidden = true
+
+ /* Persist preference in local storage */
+ __md_set<number>("__announce", hash)
+ })
+
+ /* Create and return component */
+ return watchAnnounce(el)
+ .pipe(
+ tap(state => push$.next(state)),
+ finalize(() => push$.complete()),
+ map(state => ({ ref: el, ...state }))
+ )
+ })
+}
diff --git a/src/templates/assets/javascripts/components/consent/index.ts b/src/templates/assets/javascripts/components/consent/index.ts
new file mode 100644
index 00000000..bc99db58
--- /dev/null
+++ b/src/templates/assets/javascripts/components/consent/index.ts
@@ -0,0 +1,116 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ Observable,
+ Subject,
+ finalize,
+ map,
+ tap
+} from "rxjs"
+
+import { Component } from "../_"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Consent
+ */
+export interface Consent {
+ hidden: boolean /* Consent is hidden */
+}
+
+/**
+ * Consent defaults
+ */
+export interface ConsentDefaults {
+ analytics?: boolean /* Consent for Analytics */
+ github?: boolean /* Consent for GitHub */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch options
+ */
+interface WatchOptions {
+ target$: Observable<HTMLElement> /* Target observable */
+}
+
+/**
+ * Mount options
+ */
+interface MountOptions {
+ target$: Observable<HTMLElement> /* Target observable */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch consent
+ *
+ * @param el - Consent element
+ * @param options - Options
+ *
+ * @returns Consent observable
+ */
+export function watchConsent(
+ el: HTMLElement, { target$ }: WatchOptions
+): Observable<Consent> {
+ return target$
+ .pipe(
+ map(target => ({ hidden: target !== el }))
+ )
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Mount consent
+ *
+ * @param el - Consent element
+ * @param options - Options
+ *
+ * @returns Consent component observable
+ */
+export function mountConsent(
+ el: HTMLElement, options: MountOptions
+): Observable<Component<Consent>> {
+ const internal$ = new Subject<Consent>()
+ internal$.subscribe(({ hidden }) => {
+ el.hidden = hidden
+ })
+
+ /* Create and return component */
+ return watchConsent(el, options)
+ .pipe(
+ tap(state => internal$.next(state)),
+ finalize(() => internal$.complete()),
+ map(state => ({ ref: el, ...state }))
+ )
+}
diff --git a/src/templates/assets/javascripts/components/content/_/index.ts b/src/templates/assets/javascripts/components/content/_/index.ts
new file mode 100644
index 00000000..899a695c
--- /dev/null
+++ b/src/templates/assets/javascripts/components/content/_/index.ts
@@ -0,0 +1,125 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import { Observable, merge } from "rxjs"
+
+import { Viewport, getElements } from "~/browser"
+
+import { Component } from "../../_"
+import {
+ Annotation,
+ mountAnnotationBlock
+} from "../annotation"
+import {
+ CodeBlock,
+ mountCodeBlock
+} from "../code"
+import {
+ Details,
+ mountDetails
+} from "../details"
+import {
+ Mermaid,
+ mountMermaid
+} from "../mermaid"
+import {
+ DataTable,
+ mountDataTable
+} from "../table"
+import {
+ ContentTabs,
+ mountContentTabs
+} from "../tabs"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Content
+ */
+export type Content =
+ | Annotation
+ | CodeBlock
+ | ContentTabs
+ | DataTable
+ | Details
+ | Mermaid
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mount options
+ */
+interface MountOptions {
+ viewport$: Observable<Viewport> /* Viewport observable */
+ target$: Observable<HTMLElement> /* Location target observable */
+ print$: Observable<boolean> /* Media print observable */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mount content
+ *
+ * This function mounts all components that are found in the content of the
+ * actual article, including code blocks, data tables and details.
+ *
+ * @param el - Content element
+ * @param options - Options
+ *
+ * @returns Content component observable
+ */
+export function mountContent(
+ el: HTMLElement, { viewport$, target$, print$ }: MountOptions
+): Observable<Component<Content>> {
+ return merge(
+
+ /* Annotations */
+ ...getElements(".annotate:not(.highlight)", el)
+ .map(child => mountAnnotationBlock(child, { target$, print$ })),
+
+ /* Code blocks */
+ ...getElements("pre:not(.mermaid) > code", el)
+ .map(child => mountCodeBlock(child, { target$, print$ })),
+
+ /* Mermaid diagrams */
+ ...getElements("pre.mermaid", el)
+ .map(child => mountMermaid(child)),
+
+ /* Data tables */
+ ...getElements("table:not([class])", el)
+ .map(child => mountDataTable(child)),
+
+ /* Details */
+ ...getElements("details", el)
+ .map(child => mountDetails(child, { target$, print$ })),
+
+ /* Content tabs */
+ ...getElements("[data-tabs]", el)
+ .map(child => mountContentTabs(child, { viewport$ }))
+ )
+}
diff --git a/src/templates/assets/javascripts/components/content/annotation/_/index.ts b/src/templates/assets/javascripts/components/content/annotation/_/index.ts
new file mode 100644
index 00000000..c5138fa4
--- /dev/null
+++ b/src/templates/assets/javascripts/components/content/annotation/_/index.ts
@@ -0,0 +1,272 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ Observable,
+ Subject,
+ animationFrameScheduler,
+ auditTime,
+ combineLatest,
+ debounceTime,
+ defer,
+ delay,
+ endWith,
+ filter,
+ finalize,
+ fromEvent,
+ ignoreElements,
+ map,
+ merge,
+ switchMap,
+ take,
+ takeUntil,
+ tap,
+ throttleTime,
+ withLatestFrom
+} from "rxjs"
+
+import {
+ ElementOffset,
+ getActiveElement,
+ getElementSize,
+ watchElementContentOffset,
+ watchElementFocus,
+ watchElementOffset,
+ watchElementVisibility
+} from "~/browser"
+
+import { Component } from "../../../_"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Annotation
+ */
+export interface Annotation {
+ active: boolean /* Annotation is active */
+ offset: ElementOffset /* Annotation offset */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mount options
+ */
+interface MountOptions {
+ target$: Observable<HTMLElement> /* Location target observable */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch annotation
+ *
+ * @param el - Annotation element
+ * @param container - Containing element
+ *
+ * @returns Annotation observable
+ */
+export function watchAnnotation(
+ el: HTMLElement, container: HTMLElement
+): Observable<Annotation> {
+ const offset$ = defer(() => combineLatest([
+ watchElementOffset(el),
+ watchElementContentOffset(container)
+ ]))
+ .pipe(
+ map(([{ x, y }, scroll]): ElementOffset => {
+ const { width, height } = getElementSize(el)
+ return ({
+ x: x - scroll.x + width / 2,
+ y: y - scroll.y + height / 2
+ })
+ })
+ )
+
+ /* Actively watch annotation on focus */
+ return watchElementFocus(el)
+ .pipe(
+ switchMap(active => offset$
+ .pipe(
+ map(offset => ({ active, offset })),
+ take(+!active || Infinity)
+ )
+ )
+ )
+}
+
+/**
+ * Mount annotation
+ *
+ * @param el - Annotation element
+ * @param container - Containing element
+ * @param options - Options
+ *
+ * @returns Annotation component observable
+ */
+export function mountAnnotation(
+ el: HTMLElement, container: HTMLElement, { target$ }: MountOptions
+): Observable<Component<Annotation>> {
+ const [tooltip, index] = Array.from(el.children)
+
+ /* Mount component on subscription */
+ return defer(() => {
+ const push$ = new Subject<Annotation>()
+ const done$ = push$.pipe(ignoreElements(), endWith(true))
+ push$.subscribe({
+
+ /* Handle emission */
+ next({ offset }) {
+ el.style.setProperty("--md-tooltip-x", `${offset.x}px`)
+ el.style.setProperty("--md-tooltip-y", `${offset.y}px`)
+ },
+
+ /* Handle complete */
+ complete() {
+ el.style.removeProperty("--md-tooltip-x")
+ el.style.removeProperty("--md-tooltip-y")
+ }
+ })
+
+ /* Start animation only when annotation is visible */
+ watchElementVisibility(el)
+ .pipe(
+ takeUntil(done$)
+ )
+ .subscribe(visible => {
+ el.toggleAttribute("data-md-visible", visible)
+ })
+
+ /* Toggle tooltip presence to mitigate empty lines when copying */
+ merge(
+ push$.pipe(filter(({ active }) => active)),
+ push$.pipe(debounceTime(250), filter(({ active }) => !active))
+ )
+ .subscribe({
+
+ /* Handle emission */
+ next({ active }) {
+ if (active)
+ el.prepend(tooltip)
+ else
+ tooltip.remove()
+ },
+
+ /* Handle complete */
+ complete() {
+ el.prepend(tooltip)
+ }
+ })
+
+ /* Toggle tooltip visibility */
+ push$
+ .pipe(
+ auditTime(16, animationFrameScheduler)
+ )
+ .subscribe(({ active }) => {
+ tooltip.classList.toggle("md-tooltip--active", active)
+ })
+
+ /* Track relative origin of tooltip */
+ push$
+ .pipe(
+ throttleTime(125, animationFrameScheduler),
+ filter(() => !!el.offsetParent),
+ map(() => el.offsetParent!.getBoundingClientRect()),
+ map(({ x }) => x)
+ )
+ .subscribe({
+
+ /* Handle emission */
+ next(origin) {
+ if (origin)
+ el.style.setProperty("--md-tooltip-0", `${-origin}px`)
+ else
+ el.style.removeProperty("--md-tooltip-0")
+ },
+
+ /* Handle complete */
+ complete() {
+ el.style.removeProperty("--md-tooltip-0")
+ }
+ })
+
+ /* Allow to copy link without scrolling to anchor */
+ fromEvent<MouseEvent>(index, "click")
+ .pipe(
+ takeUntil(done$),
+ filter(ev => !(ev.metaKey || ev.ctrlKey))
+ )
+ .subscribe(ev => {
+ ev.stopPropagation()
+ ev.preventDefault()
+ })
+
+ /* Allow to open link in new tab or blur on close */
+ fromEvent<MouseEvent>(index, "mousedown")
+ .pipe(
+ takeUntil(done$),
+ withLatestFrom(push$)
+ )
+ .subscribe(([ev, { active }]) => {
+
+ /* Open in new tab */
+ if (ev.button !== 0 || ev.metaKey || ev.ctrlKey) {
+ ev.preventDefault()
+
+ /* Close annotation */
+ } else if (active) {
+ ev.preventDefault()
+
+ /* Focus parent annotation, if any */
+ const parent = el.parentElement!.closest(".md-annotation")
+ if (parent instanceof HTMLElement)
+ parent.focus()
+ else
+ getActiveElement()?.blur()
+ }
+ })
+
+ /* Open and focus annotation on location target */
+ target$
+ .pipe(
+ takeUntil(done$),
+ filter(target => target === tooltip),
+ delay(125)
+ )
+ .subscribe(() => el.focus())
+
+ /* Create and return component */
+ return watchAnnotation(el, container)
+ .pipe(
+ tap(state => push$.next(state)),
+ finalize(() => push$.complete()),
+ map(state => ({ ref: el, ...state }))
+ )
+ })
+}
diff --git a/src/templates/assets/javascripts/components/content/annotation/block/index.ts b/src/templates/assets/javascripts/components/content/annotation/block/index.ts
new file mode 100644
index 00000000..c73b01fa
--- /dev/null
+++ b/src/templates/assets/javascripts/components/content/annotation/block/index.ts
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import { EMPTY, Observable, defer } from "rxjs"
+
+import { Component } from "../../../_"
+import { Annotation } from "../_"
+import { mountAnnotationList } from "../list"
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mount options
+ */
+interface MountOptions {
+ target$: Observable<HTMLElement> /* Location target observable */
+ print$: Observable<boolean> /* Media print observable */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Find list element directly following a block
+ *
+ * @param el - Annotation block element
+ *
+ * @returns List element or nothing
+ */
+function findList(el: HTMLElement): HTMLElement | undefined {
+ if (el.nextElementSibling) {
+ const sibling = el.nextElementSibling as HTMLElement
+ if (sibling.tagName === "OL")
+ return sibling
+
+ /* Skip empty paragraphs - see https://bit.ly/3r4ZJ2O */
+ else if (sibling.tagName === "P" && !sibling.children.length)
+ return findList(sibling)
+ }
+
+ /* Everything else */
+ return undefined
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mount annotation block
+ *
+ * @param el - Annotation block element
+ * @param options - Options
+ *
+ * @returns Annotation component observable
+ */
+export function mountAnnotationBlock(
+ el: HTMLElement, options: MountOptions
+): Observable<Component<Annotation>> {
+ return defer(() => {
+ const list = findList(el)
+ return typeof list !== "undefined"
+ ? mountAnnotationList(list, el, options)
+ : EMPTY
+ })
+}
diff --git a/src/templates/assets/javascripts/components/content/annotation/index.ts b/src/templates/assets/javascripts/components/content/annotation/index.ts
new file mode 100644
index 00000000..c593b723
--- /dev/null
+++ b/src/templates/assets/javascripts/components/content/annotation/index.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2016-2023 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.
+ */
+
+export * from "./_"
+export * from "./block"
+export * from "./list"
diff --git a/src/templates/assets/javascripts/components/content/annotation/list/index.ts b/src/templates/assets/javascripts/components/content/annotation/list/index.ts
new file mode 100644
index 00000000..725dd583
--- /dev/null
+++ b/src/templates/assets/javascripts/components/content/annotation/list/index.ts
@@ -0,0 +1,209 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ EMPTY,
+ Observable,
+ Subject,
+ defer,
+ endWith,
+ finalize,
+ ignoreElements,
+ merge,
+ share,
+ takeUntil
+} from "rxjs"
+
+import {
+ getElement,
+ getElements,
+ getOptionalElement
+} from "~/browser"
+import { renderAnnotation } from "~/templates"
+
+import { Component } from "../../../_"
+import {
+ Annotation,
+ mountAnnotation
+} from "../_"
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mount options
+ */
+interface MountOptions {
+ target$: Observable<HTMLElement> /* Location target observable */
+ print$: Observable<boolean> /* Media print observable */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Find all annotation hosts in the containing element
+ *
+ * @param container - Containing element
+ *
+ * @returns Annotation hosts
+ */
+function findHosts(container: HTMLElement): HTMLElement[] {
+ return container.tagName === "CODE"
+ ? getElements(".c, .c1, .cm", container)
+ : [container]
+}
+
+/**
+ * Find all annotation markers in the containing element
+ *
+ * @param container - Containing element
+ *
+ * @returns Annotation markers
+ */
+function findMarkers(container: HTMLElement): Text[] {
+ const markers: Text[] = []
+ for (const el of findHosts(container)) {
+ const nodes: Text[] = []
+
+ /* Find all text nodes in current element */
+ const it = document.createNodeIterator(el, NodeFilter.SHOW_TEXT)
+ for (let node = it.nextNode(); node; node = it.nextNode())
+ nodes.push(node as Text)
+
+ /* Find all markers in each text node */
+ for (let text of nodes) {
+ let match: RegExpExecArray | null
+
+ /* Split text at marker and add to list */
+ while ((match = /(\(\d+\))(!)?/.exec(text.textContent!))) {
+ const [, id, force] = match
+ if (typeof force === "undefined") {
+ const marker = text.splitText(match.index)
+ text = marker.splitText(id.length)
+ markers.push(marker)
+
+ /* Replace entire text with marker */
+ } else {
+ text.textContent = id
+ markers.push(text)
+ break
+ }
+ }
+ }
+ }
+ return markers
+}
+
+/**
+ * Swap the child nodes of two elements
+ *
+ * @param source - Source element
+ * @param target - Target element
+ */
+function swap(source: HTMLElement, target: HTMLElement): void {
+ target.append(...Array.from(source.childNodes))
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mount annotation list
+ *
+ * This function analyzes the containing code block and checks for markers
+ * referring to elements in the given annotation list. If no markers are found,
+ * the list is left untouched. Otherwise, list elements are rendered as
+ * annotations inside the code block.
+ *
+ * @param el - Annotation list element
+ * @param container - Containing element
+ * @param options - Options
+ *
+ * @returns Annotation component observable
+ */
+export function mountAnnotationList(
+ el: HTMLElement, container: HTMLElement, { target$, print$ }: MountOptions
+): Observable<Component<Annotation>> {
+
+ /* Compute prefix for tooltip anchors */
+ const parent = container.closest("[id]")
+ const prefix = parent?.id
+
+ /* Find and replace all markers with empty annotations */
+ const annotations = new Map<string, HTMLElement>()
+ for (const marker of findMarkers(container)) {
+ const [, id] = marker.textContent!.match(/\((\d+)\)/)!
+ if (getOptionalElement(`:scope > li:nth-child(${id})`, el)) {
+ annotations.set(id, renderAnnotation(id, prefix))
+ marker.replaceWith(annotations.get(id)!)
+ }
+ }
+
+ /* Keep list if there are no annotations to render */
+ if (annotations.size === 0)
+ return EMPTY
+
+ /* Mount component on subscription */
+ return defer(() => {
+ const push$ = new Subject()
+ const done$ = push$.pipe(ignoreElements(), endWith(true))
+
+ /* Retrieve container pairs for swapping */
+ const pairs: [HTMLElement, HTMLElement][] = []
+ for (const [id, annotation] of annotations)
+ pairs.push([
+ getElement(".md-typeset", annotation),
+ getElement(`:scope > li:nth-child(${id})`, el)
+ ])
+
+ /* Handle print mode - see https://bit.ly/3rgPdpt */
+ print$.pipe(takeUntil(done$))
+ .subscribe(active => {
+ el.hidden = !active
+
+ /* Add class to discern list element */
+ el.classList.toggle("md-annotation-list", active)
+
+ /* Show annotations in code block or list (print) */
+ for (const [inner, child] of pairs)
+ if (!active)
+ swap(child, inner)
+ else
+ swap(inner, child)
+ })
+
+ /* Create and return component */
+ return merge(...[...annotations]
+ .map(([, annotation]) => (
+ mountAnnotation(annotation, container, { target$ })
+ ))
+ )
+ .pipe(
+ finalize(() => push$.complete()),
+ share()
+ )
+ })
+}
diff --git a/src/templates/assets/javascripts/components/content/code/_/index.ts b/src/templates/assets/javascripts/components/content/code/_/index.ts
new file mode 100644
index 00000000..ccc09339
--- /dev/null
+++ b/src/templates/assets/javascripts/components/content/code/_/index.ts
@@ -0,0 +1,238 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import ClipboardJS from "clipboard"
+import {
+ EMPTY,
+ Observable,
+ Subject,
+ defer,
+ distinctUntilChanged,
+ distinctUntilKeyChanged,
+ filter,
+ finalize,
+ map,
+ mergeWith,
+ switchMap,
+ take,
+ tap
+} from "rxjs"
+
+import { feature } from "~/_"
+import {
+ getElementContentSize,
+ watchElementSize,
+ watchElementVisibility
+} from "~/browser"
+import { renderClipboardButton } from "~/templates"
+
+import { Component } from "../../../_"
+import {
+ Annotation,
+ mountAnnotationList
+} from "../../annotation"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Code block
+ */
+export interface CodeBlock {
+ scrollable: boolean /* Code block overflows */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mount options
+ */
+interface MountOptions {
+ target$: Observable<HTMLElement> /* Location target observable */
+ print$: Observable<boolean> /* Media print observable */
+}
+
+/* ----------------------------------------------------------------------------
+ * Data
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Global sequence number for code blocks
+ */
+let sequence = 0
+
+/* ----------------------------------------------------------------------------
+ * Helper functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Find candidate list element directly following a code block
+ *
+ * @param el - Code block element
+ *
+ * @returns List element or nothing
+ */
+function findCandidateList(el: HTMLElement): HTMLElement | undefined {
+ if (el.nextElementSibling) {
+ const sibling = el.nextElementSibling as HTMLElement
+ if (sibling.tagName === "OL")
+ return sibling
+
+ /* Skip empty paragraphs - see https://bit.ly/3r4ZJ2O */
+ else if (sibling.tagName === "P" && !sibling.children.length)
+ return findCandidateList(sibling)
+ }
+
+ /* Everything else */
+ return undefined
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch code block
+ *
+ * This function monitors size changes of the viewport, as well as switches of
+ * content tabs with embedded code blocks, as both may trigger overflow.
+ *
+ * @param el - Code block element
+ *
+ * @returns Code block observable
+ */
+export function watchCodeBlock(
+ el: HTMLElement
+): Observable<CodeBlock> {
+ return watchElementSize(el)
+ .pipe(
+ map(({ width }) => {
+ const content = getElementContentSize(el)
+ return {
+ scrollable: content.width > width
+ }
+ }),
+ distinctUntilKeyChanged("scrollable")
+ )
+}
+
+/**
+ * Mount code block
+ *
+ * This function ensures that an overflowing code block is focusable through
+ * keyboard, so it can be scrolled without a mouse to improve on accessibility.
+ * Furthermore, if code annotations are enabled, they are mounted if and only
+ * if the code block is currently visible, e.g., not in a hidden content tab.
+ *
+ * Note that code blocks may be mounted eagerly or lazily. If they're mounted
+ * lazily (on first visibility), code annotation anchor links will not work,
+ * as they are evaluated on initial page load, and code annotations in general
+ * might feel a little bumpier.
+ *
+ * @param el - Code block element
+ * @param options - Options
+ *
+ * @returns Code block and annotation component observable
+ */
+export function mountCodeBlock(
+ el: HTMLElement, options: MountOptions
+): Observable<Component<CodeBlock | Annotation>> {
+ const { matches: hover } = matchMedia("(hover)")
+
+ /* Defer mounting of code block - see https://bit.ly/3vHVoVD */
+ const factory$ = defer(() => {
+ const push$ = new Subject<CodeBlock>()
+ push$.subscribe(({ scrollable }) => {
+ if (scrollable && hover)
+ el.setAttribute("tabindex", "0")
+ else
+ el.removeAttribute("tabindex")
+ })
+
+ /* Render button for Clipboard.js integration */
+ if (ClipboardJS.isSupported()) {
+ if (el.closest(".copy") || (
+ feature("content.code.copy") && !el.closest(".no-copy")
+ )) {
+ const parent = el.closest("pre")!
+ parent.id = `__code_${sequence++}`
+ parent.insertBefore(
+ renderClipboardButton(parent.id),
+ el
+ )
+ }
+ }
+
+ /* Handle code annotations */
+ const container = el.closest(".highlight")
+ if (container instanceof HTMLElement) {
+ const list = findCandidateList(container)
+
+ /* Mount code annotations, if enabled */
+ if (typeof list !== "undefined" && (
+ container.classList.contains("annotate") ||
+ feature("content.code.annotate")
+ )) {
+ const annotations$ = mountAnnotationList(list, el, options)
+
+ /* Create and return component */
+ return watchCodeBlock(el)
+ .pipe(
+ tap(state => push$.next(state)),
+ finalize(() => push$.complete()),
+ map(state => ({ ref: el, ...state })),
+ mergeWith(
+ watchElementSize(container)
+ .pipe(
+ map(({ width, height }) => width && height),
+ distinctUntilChanged(),
+ switchMap(active => active ? annotations$ : EMPTY)
+ )
+ )
+ )
+ }
+ }
+
+ /* Create and return component */
+ return watchCodeBlock(el)
+ .pipe(
+ tap(state => push$.next(state)),
+ finalize(() => push$.complete()),
+ map(state => ({ ref: el, ...state }))
+ )
+ })
+
+ /* Mount code block lazily */
+ if (feature("content.lazy"))
+ return watchElementVisibility(el)
+ .pipe(
+ filter(visible => visible),
+ take(1),
+ switchMap(() => factory$)
+ )
+
+ /* Mount code block */
+ return factory$
+}
diff --git a/src/templates/assets/javascripts/components/content/code/index.ts b/src/templates/assets/javascripts/components/content/code/index.ts
new file mode 100644
index 00000000..3f86e2b4
--- /dev/null
+++ b/src/templates/assets/javascripts/components/content/code/index.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2016-2023 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.
+ */
+
+export * from "./_"
diff --git a/src/templates/assets/javascripts/components/content/details/index.ts b/src/templates/assets/javascripts/components/content/details/index.ts
new file mode 100644
index 00000000..17bfae45
--- /dev/null
+++ b/src/templates/assets/javascripts/components/content/details/index.ts
@@ -0,0 +1,138 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ Observable,
+ Subject,
+ defer,
+ filter,
+ finalize,
+ map,
+ merge,
+ tap
+} from "rxjs"
+
+import { Component } from "../../_"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Details
+ */
+export interface Details {
+ action: "open" | "close" /* Details state */
+ reveal?: boolean /* Details is revealed */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch options
+ */
+interface WatchOptions {
+ target$: Observable<HTMLElement> /* Location target observable */
+ print$: Observable<boolean> /* Media print observable */
+}
+
+/**
+ * Mount options
+ */
+interface MountOptions {
+ target$: Observable<HTMLElement> /* Location target observable */
+ print$: Observable<boolean> /* Media print observable */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch details
+ *
+ * @param el - Details element
+ * @param options - Options
+ *
+ * @returns Details observable
+ */
+export function watchDetails(
+ el: HTMLDetailsElement, { target$, print$ }: WatchOptions
+): Observable<Details> {
+ let open = true
+ return merge(
+
+ /* Open and focus details on location target */
+ target$
+ .pipe(
+ map(target => target.closest("details:not([open])")!),
+ filter(details => el === details),
+ map(() => ({
+ action: "open", reveal: true
+ }) as Details)
+ ),
+
+ /* Open details on print and close afterwards */
+ print$
+ .pipe(
+ filter(active => active || !open),
+ tap(() => open = el.open),
+ map(active => ({
+ action: active ? "open" : "close"
+ }) as Details)
+ )
+ )
+}
+
+/**
+ * Mount details
+ *
+ * This function ensures that `details` tags are opened on anchor jumps and
+ * prior to printing, so the whole content of the page is visible.
+ *
+ * @param el - Details element
+ * @param options - Options
+ *
+ * @returns Details component observable
+ */
+export function mountDetails(
+ el: HTMLDetailsElement, options: MountOptions
+): Observable<Component<Details>> {
+ return defer(() => {
+ const push$ = new Subject<Details>()
+ push$.subscribe(({ action, reveal }) => {
+ el.toggleAttribute("open", action === "open")
+ if (reveal)
+ el.scrollIntoView()
+ })
+
+ /* Create and return component */
+ return watchDetails(el, options)
+ .pipe(
+ tap(state => push$.next(state)),
+ finalize(() => push$.complete()),
+ map(state => ({ ref: el, ...state }))
+ )
+ })
+}
diff --git a/src/templates/assets/javascripts/components/content/index.ts b/src/templates/assets/javascripts/components/content/index.ts
new file mode 100644
index 00000000..a29d8b41
--- /dev/null
+++ b/src/templates/assets/javascripts/components/content/index.ts
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2016-2023 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.
+ */
+
+export * from "./_"
+export * from "./annotation"
+export * from "./code"
+export * from "./details"
+export * from "./table"
+export * from "./tabs"
diff --git a/src/templates/assets/javascripts/components/content/mermaid/index.css b/src/templates/assets/javascripts/components/content/mermaid/index.css
new file mode 100644
index 00000000..3092b8ec
--- /dev/null
+++ b/src/templates/assets/javascripts/components/content/mermaid/index.css
@@ -0,0 +1,430 @@
+/*
+ * Copyright (c) 2016-2023 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.
+ */
+
+/* ----------------------------------------------------------------------------
+ * Rules: general
+ * ------------------------------------------------------------------------- */
+
+/* General node */
+.node circle,
+.node ellipse,
+.node path,
+.node polygon,
+.node rect {
+ fill: var(--md-mermaid-node-bg-color);
+ stroke: var(--md-mermaid-node-fg-color);
+}
+
+/* General marker */
+marker {
+ fill: var(--md-mermaid-edge-color) !important;
+}
+
+/* General edge label */
+.edgeLabel .label rect {
+ fill: transparent;
+}
+
+/* ----------------------------------------------------------------------------
+ * Rules: flowcharts
+ * ------------------------------------------------------------------------- */
+
+/* Flowchart node label */
+.label {
+ color: var(--md-mermaid-label-fg-color);
+ font-family: var(--md-mermaid-font-family);
+}
+
+/* Flowchart node label container */
+.label foreignObject {
+ overflow: visible;
+ line-height: initial;
+}
+
+/* Flowchart edge label in node label */
+.label div .edgeLabel {
+ color: var(--md-mermaid-label-fg-color);
+ background-color: var(--md-mermaid-label-bg-color);
+}
+
+/* Flowchart edge label */
+.edgeLabel,
+.edgeLabel rect {
+ color: var(--md-mermaid-edge-color);
+ background-color: var(--md-mermaid-label-bg-color);
+ fill: var(--md-mermaid-label-bg-color);
+}
+
+/* Flowchart edge path */
+.edgePath .path,
+.flowchart-link {
+ stroke: var(--md-mermaid-edge-color);
+ stroke-width: .05rem;
+}
+
+/* Flowchart arrow head */
+.edgePath .arrowheadPath {
+ fill: var(--md-mermaid-edge-color);
+ stroke: none;
+}
+
+/* Flowchart subgraph */
+.cluster rect {
+ fill: var(--md-default-fg-color--lightest);
+ stroke: var(--md-default-fg-color--lighter);
+}
+
+/* Flowchart subgraph labels */
+.cluster span {
+ color: var(--md-mermaid-label-fg-color);
+ font-family: var(--md-mermaid-font-family);
+}
+
+/* Flowchart markers */
+g #flowchart-circleStart,
+g #flowchart-circleEnd,
+g #flowchart-crossStart,
+g #flowchart-crossEnd,
+g #flowchart-pointStart,
+g #flowchart-pointEnd {
+ stroke: none;
+}
+
+/* ----------------------------------------------------------------------------
+ * Rules: class diagrams
+ * ------------------------------------------------------------------------- */
+
+/* Class group node */
+g.classGroup line,
+g.classGroup rect {
+ fill: var(--md-mermaid-node-bg-color);
+ stroke: var(--md-mermaid-node-fg-color);
+}
+
+/* Class group node text */
+g.classGroup text {
+ font-family: var(--md-mermaid-font-family);
+ fill: var(--md-mermaid-label-fg-color);
+}
+
+/* Class label box */
+.classLabel .box {
+ background-color: var(--md-mermaid-label-bg-color);
+ opacity: 1;
+ fill: var(--md-mermaid-label-bg-color);
+}
+
+/* Class label text */
+.classLabel .label {
+ font-family: var(--md-mermaid-font-family);
+ fill: var(--md-mermaid-label-fg-color);
+}
+
+/* Class group divider */
+.node .divider {
+ stroke: var(--md-mermaid-node-fg-color);
+}
+
+/* Class relation */
+.relation {
+ stroke: var(--md-mermaid-edge-color);
+}
+
+/* Class relation cardinality */
+.cardinality {
+ font-family: var(--md-mermaid-font-family);
+ fill: var(--md-mermaid-label-fg-color);
+}
+
+/* Class relation cardinality text */
+.cardinality text {
+ fill: inherit !important;
+}
+
+/* Class extension, composition and dependency marker */
+defs #classDiagram-extensionStart,
+defs #classDiagram-extensionEnd,
+defs #classDiagram-compositionStart,
+defs #classDiagram-compositionEnd,
+defs #classDiagram-dependencyStart,
+defs #classDiagram-dependencyEnd {
+ fill: var(--md-mermaid-edge-color) !important;
+ stroke: var(--md-mermaid-edge-color) !important;
+}
+
+/* Class aggregation marker */
+defs #classDiagram-aggregationStart,
+defs #classDiagram-aggregationEnd {
+ fill: var(--md-mermaid-label-bg-color) !important;
+ stroke: var(--md-mermaid-edge-color) !important;
+}
+
+/* ----------------------------------------------------------------------------
+ * Rules: state diagrams
+ * ------------------------------------------------------------------------- */
+
+/* State group node */
+g.stateGroup rect {
+ fill: var(--md-mermaid-node-bg-color);
+ stroke: var(--md-mermaid-node-fg-color);
+}
+
+/* State group title */
+g.stateGroup .state-title {
+ font-family: var(--md-mermaid-font-family);
+ fill: var(--md-mermaid-label-fg-color) !important;
+}
+
+/* State group background */
+g.stateGroup .composit {
+ fill: var(--md-mermaid-label-bg-color);
+}
+
+/* State node label */
+.nodeLabel {
+ color: var(--md-mermaid-label-fg-color);
+ font-family: var(--md-mermaid-font-family);
+}
+
+/* State start and end marker */
+.start-state,
+.node circle.state-start,
+.node circle.state-end {
+ fill: var(--md-mermaid-edge-color);
+ stroke: none;
+}
+
+/* State end marker */
+.end-state-outer,
+.end-state-inner {
+ fill: var(--md-mermaid-edge-color);
+}
+
+/* State end marker */
+.end-state-inner,
+.node circle.state-end {
+ stroke: var(--md-mermaid-label-bg-color);
+}
+
+/* State transition */
+.transition {
+ stroke: var(--md-mermaid-edge-color);
+}
+
+/* State fork and join */
+[id^=state-fork] rect,
+[id^=state-join] rect {
+ fill: var(--md-mermaid-edge-color) !important;
+ stroke: none !important;
+}
+
+/* State cluster (yes, 2x... Mermaid WTF) */
+.statediagram-cluster.statediagram-cluster .inner {
+ fill: var(--md-default-bg-color);
+}
+
+/* State cluster node */
+.statediagram-cluster rect {
+ fill: var(--md-mermaid-node-bg-color);
+ stroke: var(--md-mermaid-node-fg-color);
+}
+
+/* State cluster divider */
+.statediagram-state rect.divider {
+ fill: var(--md-default-fg-color--lightest);
+ stroke: var(--md-default-fg-color--lighter);
+}
+
+/* State diagram markers */
+defs #statediagram-barbEnd {
+ stroke: var(--md-mermaid-edge-color);
+}
+
+/* ----------------------------------------------------------------------------
+ * Rules: entity-relationship diagrams
+ * ------------------------------------------------------------------------- */
+
+/* Attribute box */
+.attributeBoxEven,
+.attributeBoxOdd {
+ fill: var(--md-mermaid-node-bg-color);
+ stroke: var(--md-mermaid-node-fg-color);
+}
+
+/* Entity node */
+.entityBox {
+ fill: var(--md-mermaid-label-bg-color);
+ stroke: var(--md-mermaid-node-fg-color);
+}
+
+/* Entity node label */
+.entityLabel {
+ font-family: var(--md-mermaid-font-family);
+ fill: var(--md-mermaid-label-fg-color);
+}
+
+/* Entity relationship label container */
+.relationshipLabelBox {
+ background-color: var(--md-mermaid-label-bg-color);
+ opacity: 1;
+ fill: var(--md-mermaid-label-bg-color);
+ fill-opacity: 1;
+}
+
+/* Entity relationship label */
+.relationshipLabel {
+ fill: var(--md-mermaid-label-fg-color);
+}
+
+/* Entity relationship line { */
+.relationshipLine {
+ stroke: var(--md-mermaid-edge-color);
+}
+
+/* Entity relationship line markers */
+defs #ZERO_OR_ONE_START *,
+defs #ZERO_OR_ONE_END *,
+defs #ZERO_OR_MORE_START *,
+defs #ZERO_OR_MORE_END *,
+defs #ONLY_ONE_START *,
+defs #ONLY_ONE_END *,
+defs #ONE_OR_MORE_START *,
+defs #ONE_OR_MORE_END * {
+ stroke: var(--md-mermaid-edge-color) !important;
+}
+
+/* Entity relationship line markers */
+defs #ZERO_OR_MORE_START circle,
+defs #ZERO_OR_MORE_END circle {
+ fill: var(--md-mermaid-label-bg-color);
+}
+
+/* ----------------------------------------------------------------------------
+ * Rules: sequence diagrams
+ * ------------------------------------------------------------------------- */
+
+/* Sequence actor */
+.actor {
+ fill: var(--md-mermaid-sequence-actor-bg-color);
+ stroke: var(--md-mermaid-sequence-actor-border-color);
+}
+
+/* Sequence actor text */
+text.actor > tspan {
+ font-family: var(--md-mermaid-font-family);
+ fill: var(--md-mermaid-sequence-actor-fg-color);
+}
+
+/* Sequence actor line */
+line {
+ stroke: var(--md-mermaid-sequence-actor-line-color);
+}
+
+/* Sequence actor */
+.actor-man circle,
+.actor-man line {
+ fill: var(--md-mermaid-sequence-actorman-bg-color);
+ stroke: var(--md-mermaid-sequence-actorman-line-color);
+}
+
+/* Sequence message line */
+.messageLine0,
+.messageLine1 {
+ stroke: var(--md-mermaid-sequence-message-line-color);
+}
+
+/* Sequence note */
+.note {
+ fill: var(--md-mermaid-sequence-note-bg-color);
+ stroke: var(--md-mermaid-sequence-note-border-color);
+}
+
+/* Sequence message, loop and note text */
+.messageText,
+.loopText,
+.loopText > tspan,
+.noteText > tspan {
+ font-family: var(--md-mermaid-font-family) !important;
+ stroke: none;
+}
+
+/* Sequence message text */
+.messageText {
+ fill: var(--md-mermaid-sequence-message-fg-color);
+}
+
+/* Sequence loop text */
+.loopText,
+.loopText > tspan {
+ fill: var(--md-mermaid-sequence-loop-fg-color);
+}
+
+/* Sequence note text */
+.noteText > tspan {
+ fill: var(--md-mermaid-sequence-note-fg-color);
+}
+
+/* Sequence arrow head */
+#arrowhead path {
+ fill: var(--md-mermaid-sequence-message-line-color);
+ stroke: none;
+}
+
+/* Sequence loop line */
+.loopLine {
+ fill: var(--md-mermaid-sequence-loop-bg-color);
+ stroke: var(--md-mermaid-sequence-loop-border-color);
+}
+
+/* Sequence label box */
+.labelBox {
+ fill: var(--md-mermaid-sequence-label-bg-color);
+ stroke: none;
+}
+
+/* Sequence label text */
+.labelText,
+.labelText > span {
+ font-family: var(--md-mermaid-font-family);
+ fill: var(--md-mermaid-sequence-label-fg-color);
+}
+
+/* Sequence number */
+.sequenceNumber {
+ fill: var(--md-mermaid-sequence-number-fg-color);
+}
+
+/* Sequence rectangle */
+rect.rect {
+ fill: var(--md-mermaid-sequence-box-bg-color);
+ stroke: none;
+}
+
+/* Sequence rectangle text */
+rect.rect + text.text {
+ fill: var(--md-mermaid-sequence-box-fg-color);
+}
+
+/* Sequence diagram markers */
+defs #sequencenumber {
+ fill: var(--md-mermaid-sequence-number-bg-color) !important;
+}
diff --git a/src/templates/assets/javascripts/components/content/mermaid/index.ts b/src/templates/assets/javascripts/components/content/mermaid/index.ts
new file mode 100644
index 00000000..3f6480fd
--- /dev/null
+++ b/src/templates/assets/javascripts/components/content/mermaid/index.ts
@@ -0,0 +1,133 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ Observable,
+ map,
+ of,
+ shareReplay,
+ tap
+} from "rxjs"
+
+import { watchScript } from "~/browser"
+import { h } from "~/utilities"
+
+import { Component } from "../../_"
+
+import themeCSS from "./index.css"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mermaid diagram
+ */
+export interface Mermaid {}
+
+/* ----------------------------------------------------------------------------
+ * Data
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mermaid instance observable
+ */
+let mermaid$: Observable<void>
+
+/**
+ * Global sequence number for diagrams
+ */
+let sequence = 0
+
+/* ----------------------------------------------------------------------------
+ * Helper functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Fetch Mermaid script
+ *
+ * @returns Mermaid scripts observable
+ */
+function fetchScripts(): Observable<void> {
+ return typeof mermaid === "undefined" || mermaid instanceof Element
+ ? watchScript("https://unpkg.com/mermaid@9.4.3/dist/mermaid.min.js")
+ : of(undefined)
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mount Mermaid diagram
+ *
+ * @param el - Code block element
+ *
+ * @returns Mermaid diagram component observable
+ */
+export function mountMermaid(
+ el: HTMLElement
+): Observable<Component<Mermaid>> {
+ el.classList.remove("mermaid") // Hack: mitigate https://bit.ly/3CiN6Du
+ mermaid$ ||= fetchScripts()
+ .pipe(
+ tap(() => mermaid.initialize({
+ startOnLoad: false,
+ themeCSS,
+ sequence: {
+ actorFontSize: "16px", // Hack: mitigate https://bit.ly/3y0NEi3
+ messageFontSize: "16px",
+ noteFontSize: "16px"
+ }
+ })),
+ map(() => undefined),
+ shareReplay(1)
+ )
+
+ /* Render diagram */
+ mermaid$.subscribe(() => {
+ el.classList.add("mermaid") // Hack: mitigate https://bit.ly/3CiN6Du
+ const id = `__mermaid_${sequence++}`
+
+ /* Create host element to replace code block */
+ const host = h("div", { class: "mermaid" })
+ const text = el.textContent
+
+ /* Render and inject diagram */
+ mermaid.mermaidAPI.render(id, text, (svg: string, fn: Function) => {
+
+ /* Create a shadow root and inject diagram */
+ const shadow = host.attachShadow({ mode: "closed" })
+ shadow.innerHTML = svg
+
+ /* Replace code block with diagram and bind functions */
+ el.replaceWith(host)
+ fn?.(shadow)
+ })
+ })
+
+ /* Create and return component */
+ return mermaid$
+ .pipe(
+ map(() => ({ ref: el }))
+ )
+}
diff --git a/src/templates/assets/javascripts/components/content/table/index.ts b/src/templates/assets/javascripts/components/content/table/index.ts
new file mode 100644
index 00000000..c318e7a6
--- /dev/null
+++ b/src/templates/assets/javascripts/components/content/table/index.ts
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import { Observable, of } from "rxjs"
+
+import { renderTable } from "~/templates"
+import { h } from "~/utilities"
+
+import { Component } from "../../_"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Data table
+ */
+export interface DataTable {}
+
+/* ----------------------------------------------------------------------------
+ * Data
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Sentinel for replacement
+ */
+const sentinel = h("table")
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mount data table
+ *
+ * This function wraps a data table in another scrollable container, so it can
+ * be smoothly scrolled on smaller screen sizes and won't break the layout.
+ *
+ * @param el - Data table element
+ *
+ * @returns Data table component observable
+ */
+export function mountDataTable(
+ el: HTMLElement
+): Observable<Component<DataTable>> {
+ el.replaceWith(sentinel)
+ sentinel.replaceWith(renderTable(el))
+
+ /* Create and return component */
+ return of({ ref: el })
+}
diff --git a/src/templates/assets/javascripts/components/content/tabs/index.ts b/src/templates/assets/javascripts/components/content/tabs/index.ts
new file mode 100644
index 00000000..f57447e2
--- /dev/null
+++ b/src/templates/assets/javascripts/components/content/tabs/index.ts
@@ -0,0 +1,265 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ Observable,
+ Subject,
+ animationFrameScheduler,
+ asyncScheduler,
+ auditTime,
+ combineLatest,
+ defer,
+ endWith,
+ finalize,
+ fromEvent,
+ ignoreElements,
+ map,
+ merge,
+ skip,
+ startWith,
+ subscribeOn,
+ takeUntil,
+ tap,
+ withLatestFrom
+} from "rxjs"
+
+import { feature } from "~/_"
+import {
+ Viewport,
+ getElement,
+ getElementContentOffset,
+ getElementContentSize,
+ getElementOffset,
+ getElementSize,
+ getElements,
+ watchElementContentOffset,
+ watchElementSize
+} from "~/browser"
+import { renderTabbedControl } from "~/templates"
+
+import { Component } from "../../_"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Content tabs
+ */
+export interface ContentTabs {
+ active: HTMLLabelElement /* Active tab label */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mount options
+ */
+interface MountOptions {
+ viewport$: Observable<Viewport> /* Viewport observable */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch content tabs
+ *
+ * @param el - Content tabs element
+ *
+ * @returns Content tabs observable
+ */
+export function watchContentTabs(
+ el: HTMLElement
+): Observable<ContentTabs> {
+ const inputs = getElements<HTMLInputElement>(":scope > input", el)
+ const initial = inputs.find(input => input.checked) || inputs[0]
+ return merge(...inputs.map(input => fromEvent(input, "change")
+ .pipe(
+ map(() => getElement<HTMLLabelElement>(`label[for="${input.id}"]`))
+ )
+ ))
+ .pipe(
+ startWith(getElement<HTMLLabelElement>(`label[for="${initial.id}"]`)),
+ map(active => ({ active }))
+ )
+}
+
+/**
+ * Mount content tabs
+ *
+ * This function scrolls the active tab into view. While this functionality is
+ * provided by browsers as part of `scrollInfoView`, browsers will always also
+ * scroll the vertical axis, which we do not want. Thus, we decided to provide
+ * this functionality ourselves.
+ *
+ * @param el - Content tabs element
+ * @param options - Options
+ *
+ * @returns Content tabs component observable
+ */
+export function mountContentTabs(
+ el: HTMLElement, { viewport$ }: MountOptions
+): Observable<Component<ContentTabs>> {
+
+ /* Render content tab previous button for pagination */
+ const prev = renderTabbedControl("prev")
+ el.append(prev)
+
+ /* Render content tab next button for pagination */
+ const next = renderTabbedControl("next")
+ el.append(next)
+
+ /* Mount component on subscription */
+ const container = getElement(".tabbed-labels", el)
+ return defer(() => {
+ const push$ = new Subject<ContentTabs>()
+ const done$ = push$.pipe(ignoreElements(), endWith(true))
+ combineLatest([push$, watchElementSize(el)])
+ .pipe(
+ auditTime(1, animationFrameScheduler),
+ takeUntil(done$)
+ )
+ .subscribe({
+
+ /* Handle emission */
+ next([{ active }, size]) {
+ const offset = getElementOffset(active)
+ const { width } = getElementSize(active)
+
+ /* Set tab indicator offset and width */
+ el.style.setProperty("--md-indicator-x", `${offset.x}px`)
+ el.style.setProperty("--md-indicator-width", `${width}px`)
+
+ /* Scroll container to active content tab */
+ const content = getElementContentOffset(container)
+ if (
+ offset.x < content.x ||
+ offset.x + width > content.x + size.width
+ )
+ container.scrollTo({
+ left: Math.max(0, offset.x - 16),
+ behavior: "smooth"
+ })
+ },
+
+ /* Handle complete */
+ complete() {
+ el.style.removeProperty("--md-indicator-x")
+ el.style.removeProperty("--md-indicator-width")
+ }
+ })
+
+ /* Hide content tab buttons on borders */
+ combineLatest([
+ watchElementContentOffset(container),
+ watchElementSize(container)
+ ])
+ .pipe(
+ takeUntil(done$)
+ )
+ .subscribe(([offset, size]) => {
+ const content = getElementContentSize(container)
+ prev.hidden = offset.x < 16
+ next.hidden = offset.x > content.width - size.width - 16
+ })
+
+ /* Paginate content tab container on click */
+ merge(
+ fromEvent(prev, "click").pipe(map(() => -1)),
+ fromEvent(next, "click").pipe(map(() => +1))
+ )
+ .pipe(
+ takeUntil(done$)
+ )
+ .subscribe(direction => {
+ const { width } = getElementSize(container)
+ container.scrollBy({
+ left: width * direction,
+ behavior: "smooth"
+ })
+ })
+
+ /* Set up linking of content tabs, if enabled */
+ if (feature("content.tabs.link"))
+ push$.pipe(
+ skip(1),
+ withLatestFrom(viewport$)
+ )
+ .subscribe(([{ active }, { offset }]) => {
+ const tab = active.innerText.trim()
+ if (active.hasAttribute("data-md-switching")) {
+ active.removeAttribute("data-md-switching")
+
+ /* Determine viewport offset of active tab */
+ } else {
+ const y = el.offsetTop - offset.y
+
+ /* Passively activate other tabs */
+ for (const set of getElements("[data-tabs]"))
+ for (const input of getElements<HTMLInputElement>(
+ ":scope > input", set
+ )) {
+ const label = getElement(`label[for="${input.id}"]`)
+ if (
+ label !== active &&
+ label.innerText.trim() === tab
+ ) {
+ label.setAttribute("data-md-switching", "")
+ input.click()
+ break
+ }
+ }
+
+ /* Bring active tab into view */
+ window.scrollTo({
+ top: el.offsetTop - y
+ })
+
+ /* Persist active tabs in local storage */
+ const tabs = __md_get<string[]>("__tabs") || []
+ __md_set("__tabs", [...new Set([tab, ...tabs])])
+ }
+ })
+
+ /* Pause media (audio, video) on switch - see https://bit.ly/3Bk6cel */
+ push$.pipe(takeUntil(done$))
+ .subscribe(() => {
+ for (const media of getElements<HTMLAudioElement>("audio, video", el))
+ media.pause()
+ })
+
+ /* Create and return component */
+ return watchContentTabs(el)
+ .pipe(
+ tap(state => push$.next(state)),
+ finalize(() => push$.complete()),
+ map(state => ({ ref: el, ...state }))
+ )
+ })
+ .pipe(
+ subscribeOn(asyncScheduler)
+ )
+}
diff --git a/src/templates/assets/javascripts/components/dialog/index.ts b/src/templates/assets/javascripts/components/dialog/index.ts
new file mode 100644
index 00000000..6ff1bd44
--- /dev/null
+++ b/src/templates/assets/javascripts/components/dialog/index.ts
@@ -0,0 +1,128 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ Observable,
+ Subject,
+ defer,
+ delay,
+ finalize,
+ map,
+ merge,
+ of,
+ switchMap,
+ tap
+} from "rxjs"
+
+import { getElement } from "~/browser"
+
+import { Component } from "../_"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Dialog
+ */
+export interface Dialog {
+ message: string /* Dialog message */
+ active: boolean /* Dialog is active */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch options
+ */
+interface WatchOptions {
+ alert$: Subject<string> /* Alert subject */
+}
+
+/**
+ * Mount options
+ */
+interface MountOptions {
+ alert$: Subject<string> /* Alert subject */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch dialog
+ *
+ * @param _el - Dialog element
+ * @param options - Options
+ *
+ * @returns Dialog observable
+ */
+export function watchDialog(
+ _el: HTMLElement, { alert$ }: WatchOptions
+): Observable<Dialog> {
+ return alert$
+ .pipe(
+ switchMap(message => merge(
+ of(true),
+ of(false).pipe(delay(2000))
+ )
+ .pipe(
+ map(active => ({ message, active }))
+ )
+ )
+ )
+}
+
+/**
+ * Mount dialog
+ *
+ * This function reveals the dialog in the right corner when a new alert is
+ * emitted through the subject that is passed as part of the options.
+ *
+ * @param el - Dialog element
+ * @param options - Options
+ *
+ * @returns Dialog component observable
+ */
+export function mountDialog(
+ el: HTMLElement, options: MountOptions
+): Observable<Component<Dialog>> {
+ const inner = getElement(".md-typeset", el)
+ return defer(() => {
+ const push$ = new Subject<Dialog>()
+ push$.subscribe(({ message, active }) => {
+ el.classList.toggle("md-dialog--active", active)
+ inner.textContent = message
+ })
+
+ /* Create and return component */
+ return watchDialog(el, options)
+ .pipe(
+ tap(state => push$.next(state)),
+ finalize(() => push$.complete()),
+ map(state => ({ ref: el, ...state }))
+ )
+ })
+}
diff --git a/src/templates/assets/javascripts/components/header/_/index.ts b/src/templates/assets/javascripts/components/header/_/index.ts
new file mode 100644
index 00000000..0f33eb48
--- /dev/null
+++ b/src/templates/assets/javascripts/components/header/_/index.ts
@@ -0,0 +1,200 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ Observable,
+ Subject,
+ bufferCount,
+ combineLatest,
+ combineLatestWith,
+ defer,
+ distinctUntilChanged,
+ distinctUntilKeyChanged,
+ endWith,
+ filter,
+ ignoreElements,
+ map,
+ of,
+ shareReplay,
+ startWith,
+ switchMap,
+ takeUntil
+} from "rxjs"
+
+import { feature } from "~/_"
+import {
+ Viewport,
+ watchElementSize,
+ watchToggle
+} from "~/browser"
+
+import { Component } from "../../_"
+import { Main } from "../../main"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Header
+ */
+export interface Header {
+ height: number /* Header visible height */
+ hidden: boolean /* Header is hidden */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch options
+ */
+interface WatchOptions {
+ viewport$: Observable<Viewport> /* Viewport observable */
+}
+
+/**
+ * Mount options
+ */
+interface MountOptions {
+ viewport$: Observable<Viewport> /* Viewport observable */
+ header$: Observable<Header> /* Header observable */
+ main$: Observable<Main> /* Main area observable */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Compute whether the header is hidden
+ *
+ * If the user scrolls past a certain threshold, the header can be hidden when
+ * scrolling down, and shown when scrolling up.
+ *
+ * @param options - Options
+ *
+ * @returns Toggle observable
+ */
+function isHidden({ viewport$ }: WatchOptions): Observable<boolean> {
+ if (!feature("header.autohide"))
+ return of(false)
+
+ /* Compute direction and turning point */
+ const direction$ = viewport$
+ .pipe(
+ map(({ offset: { y } }) => y),
+ bufferCount(2, 1),
+ map(([a, b]) => [a < b, b] as const),
+ distinctUntilKeyChanged(0)
+ )
+
+ /* Compute whether header should be hidden */
+ const hidden$ = combineLatest([viewport$, direction$])
+ .pipe(
+ filter(([{ offset }, [, y]]) => Math.abs(y - offset.y) > 100),
+ map(([, [direction]]) => direction),
+ distinctUntilChanged()
+ )
+
+ /* Compute threshold for hiding */
+ const search$ = watchToggle("search")
+ return combineLatest([viewport$, search$])
+ .pipe(
+ map(([{ offset }, search]) => offset.y > 400 && !search),
+ distinctUntilChanged(),
+ switchMap(active => active ? hidden$ : of(false)),
+ startWith(false)
+ )
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch header
+ *
+ * @param el - Header element
+ * @param options - Options
+ *
+ * @returns Header observable
+ */
+export function watchHeader(
+ el: HTMLElement, options: WatchOptions
+): Observable<Header> {
+ return defer(() => combineLatest([
+ watchElementSize(el),
+ isHidden(options)
+ ]))
+ .pipe(
+ map(([{ height }, hidden]) => ({
+ height,
+ hidden
+ })),
+ distinctUntilChanged((a, b) => (
+ a.height === b.height &&
+ a.hidden === b.hidden
+ )),
+ shareReplay(1)
+ )
+}
+
+/**
+ * Mount header
+ *
+ * This function manages the different states of the header, i.e. whether it's
+ * hidden or rendered with a shadow. This depends heavily on the main area.
+ *
+ * @param el - Header element
+ * @param options - Options
+ *
+ * @returns Header component observable
+ */
+export function mountHeader(
+ el: HTMLElement, { header$, main$ }: MountOptions
+): Observable<Component<Header>> {
+ return defer(() => {
+ const push$ = new Subject<Main>()
+ const done$ = push$.pipe(ignoreElements(), endWith(true))
+ push$
+ .pipe(
+ distinctUntilKeyChanged("active"),
+ combineLatestWith(header$)
+ )
+ .subscribe(([{ active }, { hidden }]) => {
+ el.classList.toggle("md-header--shadow", active && !hidden)
+ el.hidden = hidden
+ })
+
+ /* Link to main area */
+ main$.subscribe(push$)
+
+ /* Create and return component */
+ return header$
+ .pipe(
+ takeUntil(done$),
+ map(state => ({ ref: el, ...state }))
+ )
+ })
+}
diff --git a/src/templates/assets/javascripts/components/header/index.ts b/src/templates/assets/javascripts/components/header/index.ts
new file mode 100644
index 00000000..cf23ec1a
--- /dev/null
+++ b/src/templates/assets/javascripts/components/header/index.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2016-2023 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.
+ */
+
+export * from "./_"
+export * from "./title"
diff --git a/src/templates/assets/javascripts/components/header/title/index.ts b/src/templates/assets/javascripts/components/header/title/index.ts
new file mode 100644
index 00000000..f3bc0d08
--- /dev/null
+++ b/src/templates/assets/javascripts/components/header/title/index.ts
@@ -0,0 +1,144 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ EMPTY,
+ Observable,
+ Subject,
+ defer,
+ distinctUntilKeyChanged,
+ finalize,
+ map,
+ tap
+} from "rxjs"
+
+import {
+ Viewport,
+ getElementSize,
+ getOptionalElement,
+ watchViewportAt
+} from "~/browser"
+
+import { Component } from "../../_"
+import { Header } from "../_"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Header
+ */
+export interface HeaderTitle {
+ active: boolean /* Header title is active */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch options
+ */
+interface WatchOptions {
+ viewport$: Observable<Viewport> /* Viewport observable */
+ header$: Observable<Header> /* Header observable */
+}
+
+/**
+ * Mount options
+ */
+interface MountOptions {
+ viewport$: Observable<Viewport> /* Viewport observable */
+ header$: Observable<Header> /* Header observable */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch header title
+ *
+ * @param el - Heading element
+ * @param options - Options
+ *
+ * @returns Header title observable
+ */
+export function watchHeaderTitle(
+ el: HTMLElement, { viewport$, header$ }: WatchOptions
+): Observable<HeaderTitle> {
+ return watchViewportAt(el, { viewport$, header$ })
+ .pipe(
+ map(({ offset: { y } }) => {
+ const { height } = getElementSize(el)
+ return {
+ active: y >= height
+ }
+ }),
+ distinctUntilKeyChanged("active")
+ )
+}
+
+/**
+ * Mount header title
+ *
+ * This function swaps the header title from the site title to the title of the
+ * current page when the user scrolls past the first headline.
+ *
+ * @param el - Header title element
+ * @param options - Options
+ *
+ * @returns Header title component observable
+ */
+export function mountHeaderTitle(
+ el: HTMLElement, options: MountOptions
+): Observable<Component<HeaderTitle>> {
+ return defer(() => {
+ const push$ = new Subject<HeaderTitle>()
+ push$.subscribe({
+
+ /* Handle emission */
+ next({ active }) {
+ el.classList.toggle("md-header__title--active", active)
+ },
+
+ /* Handle complete */
+ complete() {
+ el.classList.remove("md-header__title--active")
+ }
+ })
+
+ /* Obtain headline, if any */
+ const heading = getOptionalElement(".md-content h1")
+ if (typeof heading === "undefined")
+ return EMPTY
+
+ /* Create and return component */
+ return watchHeaderTitle(heading, options)
+ .pipe(
+ tap(state => push$.next(state)),
+ finalize(() => push$.complete()),
+ map(state => ({ ref: el, ...state }))
+ )
+ })
+}
diff --git a/src/templates/assets/javascripts/components/index.ts b/src/templates/assets/javascripts/components/index.ts
new file mode 100644
index 00000000..3d4391d1
--- /dev/null
+++ b/src/templates/assets/javascripts/components/index.ts
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2016-2023 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.
+ */
+
+export * from "./_"
+export * from "./announce"
+export * from "./consent"
+export * from "./content"
+export * from "./dialog"
+export * from "./header"
+export * from "./main"
+export * from "./palette"
+export * from "./progress"
+export * from "./search"
+export * from "./sidebar"
+export * from "./source"
+export * from "./tabs"
+export * from "./toc"
+export * from "./top"
diff --git a/src/templates/assets/javascripts/components/main/index.ts b/src/templates/assets/javascripts/components/main/index.ts
new file mode 100644
index 00000000..2509f9b9
--- /dev/null
+++ b/src/templates/assets/javascripts/components/main/index.ts
@@ -0,0 +1,125 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ Observable,
+ combineLatest,
+ distinctUntilChanged,
+ distinctUntilKeyChanged,
+ map,
+ switchMap
+} from "rxjs"
+
+import {
+ Viewport,
+ watchElementSize
+} from "~/browser"
+
+import { Header } from "../header"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Main area
+ */
+export interface Main {
+ offset: number /* Main area top offset */
+ height: number /* Main area visible height */
+ active: boolean /* Main area is active */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch options
+ */
+interface WatchOptions {
+ viewport$: Observable<Viewport> /* Viewport observable */
+ header$: Observable<Header> /* Header observable */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch main area
+ *
+ * This function returns an observable that computes the visual parameters of
+ * the main area which depends on the viewport vertical offset and height, as
+ * well as the height of the header element, if the header is fixed.
+ *
+ * @param el - Main area element
+ * @param options - Options
+ *
+ * @returns Main area observable
+ */
+export function watchMain(
+ el: HTMLElement, { viewport$, header$ }: WatchOptions
+): Observable<Main> {
+
+ /* Compute necessary adjustment for header */
+ const adjust$ = header$
+ .pipe(
+ map(({ height }) => height),
+ distinctUntilChanged()
+ )
+
+ /* Compute the main area's top and bottom borders */
+ const border$ = adjust$
+ .pipe(
+ switchMap(() => watchElementSize(el)
+ .pipe(
+ map(({ height }) => ({
+ top: el.offsetTop,
+ bottom: el.offsetTop + height
+ })),
+ distinctUntilKeyChanged("bottom")
+ )
+ )
+ )
+
+ /* Compute the main area's offset, visible height and if we scrolled past */
+ return combineLatest([adjust$, border$, viewport$])
+ .pipe(
+ map(([header, { top, bottom }, { offset: { y }, size: { height } }]) => {
+ height = Math.max(0, height
+ - Math.max(0, top - y, header)
+ - Math.max(0, height + y - bottom)
+ )
+ return {
+ offset: top - header,
+ height,
+ active: top - header <= y
+ }
+ }),
+ distinctUntilChanged((a, b) => (
+ a.offset === b.offset &&
+ a.height === b.height &&
+ a.active === b.active
+ ))
+ )
+}
diff --git a/src/templates/assets/javascripts/components/palette/index.ts b/src/templates/assets/javascripts/components/palette/index.ts
new file mode 100644
index 00000000..cf578f60
--- /dev/null
+++ b/src/templates/assets/javascripts/components/palette/index.ts
@@ -0,0 +1,180 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ Observable,
+ Subject,
+ asyncScheduler,
+ defer,
+ finalize,
+ fromEvent,
+ map,
+ mergeMap,
+ observeOn,
+ of,
+ shareReplay,
+ startWith,
+ tap
+} from "rxjs"
+
+import { getElements } from "~/browser"
+import { h } from "~/utilities"
+
+import {
+ Component,
+ getComponentElement
+} from "../_"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Palette colors
+ */
+export interface PaletteColor {
+ scheme?: string /* Color scheme */
+ primary?: string /* Primary color */
+ accent?: string /* Accent color */
+}
+
+/**
+ * Palette
+ */
+export interface Palette {
+ index: number /* Palette index */
+ color: PaletteColor /* Palette colors */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch color palette
+ *
+ * @param inputs - Color palette element
+ *
+ * @returns Color palette observable
+ */
+export function watchPalette(
+ inputs: HTMLInputElement[]
+): Observable<Palette> {
+ const current = __md_get<Palette>("__palette") || {
+ index: inputs.findIndex(input => matchMedia(
+ input.getAttribute("data-md-color-media")!
+ ).matches)
+ }
+
+ /* Emit changes in color palette */
+ return of(...inputs)
+ .pipe(
+ mergeMap(input => fromEvent(input, "change")
+ .pipe(
+ map(() => input)
+ )
+ ),
+ startWith(inputs[Math.max(0, current.index)]),
+ map(input => ({
+ index: inputs.indexOf(input),
+ color: {
+ scheme: input.getAttribute("data-md-color-scheme"),
+ primary: input.getAttribute("data-md-color-primary"),
+ accent: input.getAttribute("data-md-color-accent")
+ }
+ } as Palette)),
+ shareReplay(1)
+ )
+}
+
+/**
+ * Mount color palette
+ *
+ * @param el - Color palette element
+ *
+ * @returns Color palette component observable
+ */
+export function mountPalette(
+ el: HTMLElement
+): Observable<Component<Palette>> {
+ const meta = h("meta", { name: "theme-color" })
+ document.head.appendChild(meta)
+
+ // Add color scheme meta tag
+ const scheme = h("meta", { name: "color-scheme" })
+ document.head.appendChild(scheme)
+
+ /* Mount component on subscription */
+ return defer(() => {
+ const push$ = new Subject<Palette>()
+ push$.subscribe(palette => {
+ document.body.setAttribute("data-md-color-switching", "")
+
+ /* Set color palette */
+ for (const [key, value] of Object.entries(palette.color))
+ document.body.setAttribute(`data-md-color-${key}`, value)
+
+ /* Toggle visibility */
+ for (let index = 0; index < inputs.length; index++) {
+ const label = inputs[index].nextElementSibling
+ if (label instanceof HTMLElement)
+ label.hidden = palette.index !== index
+ }
+
+ /* Persist preference in local storage */
+ __md_set("__palette", palette)
+ })
+
+ /* Update theme-color meta tag */
+ push$
+ .pipe(
+ map(() => {
+ const header = getComponentElement("header")
+ const style = window.getComputedStyle(header)
+
+ // Set color scheme
+ scheme.content = style.colorScheme
+
+ /* Return color in hexadecimal format */
+ return style.backgroundColor.match(/\d+/g)!
+ .map(value => (+value).toString(16).padStart(2, "0"))
+ .join("")
+ })
+ )
+ .subscribe(color => meta.content = `#${color}`)
+
+ /* Revert transition durations after color switch */
+ push$.pipe(observeOn(asyncScheduler))
+ .subscribe(() => {
+ document.body.removeAttribute("data-md-color-switching")
+ })
+
+ /* Create and return component */
+ const inputs = getElements<HTMLInputElement>("input", el)
+ return watchPalette(inputs)
+ .pipe(
+ tap(state => push$.next(state)),
+ finalize(() => push$.complete()),
+ map(state => ({ ref: el, ...state }))
+ )
+ })
+}
diff --git a/src/templates/assets/javascripts/components/progress/index.ts b/src/templates/assets/javascripts/components/progress/index.ts
new file mode 100644
index 00000000..30c722b8
--- /dev/null
+++ b/src/templates/assets/javascripts/components/progress/index.ts
@@ -0,0 +1,87 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ Observable,
+ Subject,
+ defer,
+ finalize,
+ map,
+ tap
+} from "rxjs"
+
+import { Component } from "../_"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Progress indicator
+ */
+export interface Progress {
+ value: number // Progress value
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mount options
+ */
+interface MountOptions {
+ progress$: Subject<number> // Progress subject
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mount progress indicator
+ *
+ * @param el - Progress indicator element
+ * @param options - Options
+ *
+ * @returns Progress indicator component observable
+ */
+export function mountProgress(
+ el: HTMLElement, { progress$ }: MountOptions
+): Observable<Component<Progress>> {
+
+ // Mount component on subscription
+ return defer(() => {
+ const push$ = new Subject<Progress>()
+ push$.subscribe(({ value }) => {
+ el.style.setProperty("--md-progress-value", `${value}`)
+ })
+
+ // Create and return component
+ return progress$
+ .pipe(
+ tap(value => push$.next({ value })),
+ finalize(() => push$.complete()),
+ map(value => ({ ref: el, value }))
+ )
+ })
+}
diff --git a/src/templates/assets/javascripts/components/search/_/index.ts b/src/templates/assets/javascripts/components/search/_/index.ts
new file mode 100644
index 00000000..aa963b47
--- /dev/null
+++ b/src/templates/assets/javascripts/components/search/_/index.ts
@@ -0,0 +1,239 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ NEVER,
+ Observable,
+ ObservableInput,
+ filter,
+ fromEvent,
+ merge,
+ mergeWith
+} from "rxjs"
+
+import { configuration } from "~/_"
+import {
+ Keyboard,
+ getActiveElement,
+ getElements,
+ setToggle
+} from "~/browser"
+import {
+ SearchIndex,
+ SearchResult,
+ setupSearchWorker
+} from "~/integrations"
+
+import {
+ Component,
+ getComponentElement,
+ getComponentElements
+} from "../../_"
+import {
+ SearchQuery,
+ mountSearchQuery
+} from "../query"
+import { mountSearchResult } from "../result"
+import {
+ SearchShare,
+ mountSearchShare
+} from "../share"
+import {
+ SearchSuggest,
+ mountSearchSuggest
+} from "../suggest"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Search
+ */
+export type Search =
+ | SearchQuery
+ | SearchResult
+ | SearchShare
+ | SearchSuggest
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mount options
+ */
+interface MountOptions {
+ index$: ObservableInput<SearchIndex> /* Search index observable */
+ keyboard$: Observable<Keyboard> /* Keyboard observable */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mount search
+ *
+ * This function sets up the search functionality, including the underlying
+ * web worker and all keyboard bindings.
+ *
+ * @param el - Search element
+ * @param options - Options
+ *
+ * @returns Search component observable
+ */
+export function mountSearch(
+ el: HTMLElement, { index$, keyboard$ }: MountOptions
+): Observable<Component<Search>> {
+ const config = configuration()
+ try {
+ const worker$ = setupSearchWorker(config.search, index$)
+
+ /* Retrieve query and result components */
+ const query = getComponentElement("search-query", el)
+ const result = getComponentElement("search-result", el)
+
+ /* Always close search on result selection */
+ fromEvent<PointerEvent>(el, "click")
+ .pipe(
+ filter(({ target }) => (
+ target instanceof Element && !!target.closest("a")
+ ))
+ )
+ .subscribe(() => setToggle("search", false))
+
+ /* Set up search keyboard handlers */
+ keyboard$
+ .pipe(
+ filter(({ mode }) => mode === "search")
+ )
+ .subscribe(key => {
+ const active = getActiveElement()
+ switch (key.type) {
+
+ /* Enter: go to first (best) result */
+ case "Enter":
+ if (active === query) {
+ const anchors = new Map<HTMLAnchorElement, number>()
+ for (const anchor of getElements<HTMLAnchorElement>(
+ ":first-child [href]", result
+ )) {
+ const article = anchor.firstElementChild!
+ anchors.set(anchor, parseFloat(
+ article.getAttribute("data-md-score")!
+ ))
+ }
+
+ /* Go to result with highest score, if any */
+ if (anchors.size) {
+ const [[best]] = [...anchors].sort(([, a], [, b]) => b - a)
+ best.click()
+ }
+
+ /* Otherwise omit form submission */
+ key.claim()
+ }
+ break
+
+ /* Escape or Tab: close search */
+ case "Escape":
+ case "Tab":
+ setToggle("search", false)
+ query.blur()
+ break
+
+ /* Vertical arrows: select previous or next search result */
+ case "ArrowUp":
+ case "ArrowDown":
+ if (typeof active === "undefined") {
+ query.focus()
+ } else {
+ const els = [query, ...getElements(
+ ":not(details) > [href], summary, details[open] [href]",
+ result
+ )]
+ const i = Math.max(0, (
+ Math.max(0, els.indexOf(active)) + els.length + (
+ key.type === "ArrowUp" ? -1 : +1
+ )
+ ) % els.length)
+ els[i].focus()
+ }
+
+ /* Prevent scrolling of page */
+ key.claim()
+ break
+
+ /* All other keys: hand to search query */
+ default:
+ if (query !== getActiveElement())
+ query.focus()
+ }
+ })
+
+ /* Set up global keyboard handlers */
+ keyboard$
+ .pipe(
+ filter(({ mode }) => mode === "global")
+ )
+ .subscribe(key => {
+ switch (key.type) {
+
+ /* Open search and select query */
+ case "f":
+ case "s":
+ case "/":
+ query.focus()
+ query.select()
+
+ /* Prevent scrolling of page */
+ key.claim()
+ break
+ }
+ })
+
+ /* Create and return component */
+ const query$ = mountSearchQuery(query, { worker$ })
+ return merge(
+ query$,
+ mountSearchResult(result, { worker$, query$ })
+ )
+ .pipe(
+ mergeWith(
+
+ /* Search sharing */
+ ...getComponentElements("search-share", el)
+ .map(child => mountSearchShare(child, { query$ })),
+
+ /* Search suggestions */
+ ...getComponentElements("search-suggest", el)
+ .map(child => mountSearchSuggest(child, { worker$, keyboard$ }))
+ )
+ )
+
+ /* Gracefully handle broken search */
+ } catch (err) {
+ el.hidden = true
+ return NEVER
+ }
+}
diff --git a/src/templates/assets/javascripts/components/search/highlight/.eslintrc b/src/templates/assets/javascripts/components/search/highlight/.eslintrc
new file mode 100644
index 00000000..38a5714d
--- /dev/null
+++ b/src/templates/assets/javascripts/components/search/highlight/.eslintrc
@@ -0,0 +1,5 @@
+{
+ "rules": {
+ "no-null/no-null": "off"
+ }
+}
diff --git a/src/templates/assets/javascripts/components/search/highlight/index.ts b/src/templates/assets/javascripts/components/search/highlight/index.ts
new file mode 100644
index 00000000..bc3f94c9
--- /dev/null
+++ b/src/templates/assets/javascripts/components/search/highlight/index.ts
@@ -0,0 +1,115 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ Observable,
+ ObservableInput,
+ combineLatest,
+ filter,
+ map,
+ startWith
+} from "rxjs"
+
+import { getLocation } from "~/browser"
+import {
+ SearchIndex,
+ setupSearchHighlighter
+} from "~/integrations"
+import { h } from "~/utilities"
+
+import { Component } from "../../_"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Search highlighting
+ */
+export interface SearchHighlight {
+ nodes: Map<ChildNode, string> /* Map of replacements */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mount options
+ */
+interface MountOptions {
+ index$: ObservableInput<SearchIndex> /* Search index observable */
+ location$: Observable<URL> /* Location observable */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mount search highlighting
+ *
+ * @param el - Content element
+ * @param options - Options
+ *
+ * @returns Search highlighting component observable
+ */
+export function mountSearchHiglight(
+ el: HTMLElement, { index$, location$ }: MountOptions
+): Observable<Component<SearchHighlight>> {
+ return combineLatest([
+ index$,
+ location$
+ .pipe(
+ startWith(getLocation()),
+ filter(url => !!url.searchParams.get("h"))
+ )
+ ])
+ .pipe(
+ map(([index, url]) => setupSearchHighlighter(index.config)(
+ url.searchParams.get("h")!
+ )),
+ map(fn => {
+ const nodes = new Map<ChildNode, string>()
+
+ /* Traverse text nodes and collect matches */
+ const it = document.createNodeIterator(el, NodeFilter.SHOW_TEXT)
+ for (let node = it.nextNode(); node; node = it.nextNode()) {
+ if (node.parentElement?.offsetHeight) {
+ const original = node.textContent!
+ const replaced = fn(original)
+ if (replaced.length > original.length)
+ nodes.set(node as ChildNode, replaced)
+ }
+ }
+
+ /* Replace original nodes with matches */
+ for (const [node, text] of nodes) {
+ const { childNodes } = h("span", null, text)
+ node.replaceWith(...Array.from(childNodes))
+ }
+
+ /* Return component */
+ return { ref: el, nodes }
+ })
+ )
+}
diff --git a/src/templates/assets/javascripts/components/search/index.ts b/src/templates/assets/javascripts/components/search/index.ts
new file mode 100644
index 00000000..846d8685
--- /dev/null
+++ b/src/templates/assets/javascripts/components/search/index.ts
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2016-2023 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.
+ */
+
+export * from "./_"
+export * from "./highlight"
+export * from "./query"
+export * from "./result"
+export * from "./share"
+export * from "./suggest"
diff --git a/src/templates/assets/javascripts/components/search/query/index.ts b/src/templates/assets/javascripts/components/search/query/index.ts
new file mode 100644
index 00000000..4ce21279
--- /dev/null
+++ b/src/templates/assets/javascripts/components/search/query/index.ts
@@ -0,0 +1,206 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ Observable,
+ Subject,
+ combineLatest,
+ distinctUntilChanged,
+ distinctUntilKeyChanged,
+ endWith,
+ finalize,
+ first,
+ fromEvent,
+ ignoreElements,
+ map,
+ merge,
+ shareReplay,
+ takeUntil,
+ tap
+} from "rxjs"
+
+import {
+ getElement,
+ getLocation,
+ setToggle,
+ watchElementFocus,
+ watchToggle
+} from "~/browser"
+import {
+ SearchMessage,
+ SearchMessageType,
+ isSearchReadyMessage
+} from "~/integrations"
+
+import { Component } from "../../_"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Search query
+ */
+export interface SearchQuery {
+ value: string /* Query value */
+ focus: boolean /* Query focus */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch options
+ */
+interface WatchOptions {
+ worker$: Subject<SearchMessage> /* Search worker */
+}
+
+/**
+ * Mount options
+ */
+interface MountOptions {
+ worker$: Subject<SearchMessage> /* Search worker */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch search query
+ *
+ * Note that the focus event which triggers re-reading the current query value
+ * is delayed by `1ms` so the input's empty state is allowed to propagate.
+ *
+ * @param el - Search query element
+ * @param options - Options
+ *
+ * @returns Search query observable
+ */
+export function watchSearchQuery(
+ el: HTMLInputElement, { worker$ }: WatchOptions
+): Observable<SearchQuery> {
+
+ /* Support search deep linking */
+ const { searchParams } = getLocation()
+ if (searchParams.has("q")) {
+ setToggle("search", true)
+
+ /* Set query from parameter */
+ el.value = searchParams.get("q")!
+ el.focus()
+
+ /* Remove query parameter on close */
+ watchToggle("search")
+ .pipe(
+ first(active => !active)
+ )
+ .subscribe(() => {
+ const url = getLocation()
+ url.searchParams.delete("q")
+ history.replaceState({}, "", `${url}`)
+ })
+ }
+
+ /* Intercept focus and input events */
+ const focus$ = watchElementFocus(el)
+ const value$ = merge(
+ worker$.pipe(first(isSearchReadyMessage)),
+ fromEvent(el, "keyup"),
+ focus$
+ )
+ .pipe(
+ map(() => el.value),
+ distinctUntilChanged()
+ )
+
+ /* Combine into single observable */
+ return combineLatest([value$, focus$])
+ .pipe(
+ map(([value, focus]) => ({ value, focus })),
+ shareReplay(1)
+ )
+}
+
+/**
+ * Mount search query
+ *
+ * @param el - Search query element
+ * @param options - Options
+ *
+ * @returns Search query component observable
+ */
+export function mountSearchQuery(
+ el: HTMLInputElement, { worker$ }: MountOptions
+): Observable<Component<SearchQuery, HTMLInputElement>> {
+ const push$ = new Subject<SearchQuery>()
+ const done$ = push$.pipe(ignoreElements(), endWith(true))
+
+ /* Handle value change */
+ combineLatest([
+ worker$.pipe(first(isSearchReadyMessage)),
+ push$
+ ], (_, query) => query)
+ .pipe(
+ distinctUntilKeyChanged("value")
+ )
+ .subscribe(({ value }) => worker$.next({
+ type: SearchMessageType.QUERY,
+ data: value
+ }))
+
+ /* Handle focus change */
+ push$
+ .pipe(
+ distinctUntilKeyChanged("focus")
+ )
+ .subscribe(({ focus }) => {
+ if (focus)
+ setToggle("search", focus)
+ })
+
+ /* Handle reset */
+ fromEvent(el.form!, "reset")
+ .pipe(
+ takeUntil(done$)
+ )
+ .subscribe(() => el.focus())
+
+ // Focus search query on label click - note that this is necessary to bring
+ // up the keyboard on iOS and other mobile platforms, as the search dialog is
+ // not visible at first, and programatically focusing an input element must
+ // be triggered by a user interaction - see https://t.ly/Cb30n
+ const label = getElement("header [for=__search]")
+ fromEvent(label, "click")
+ .subscribe(() => el.focus())
+
+ /* Create and return component */
+ return watchSearchQuery(el, { worker$ })
+ .pipe(
+ tap(state => push$.next(state)),
+ finalize(() => push$.complete()),
+ map(state => ({ ref: el, ...state })),
+ shareReplay(1)
+ )
+}
diff --git a/src/templates/assets/javascripts/components/search/result/index.ts b/src/templates/assets/javascripts/components/search/result/index.ts
new file mode 100644
index 00000000..c3c9ef20
--- /dev/null
+++ b/src/templates/assets/javascripts/components/search/result/index.ts
@@ -0,0 +1,197 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ EMPTY,
+ Observable,
+ Subject,
+ bufferCount,
+ filter,
+ finalize,
+ first,
+ fromEvent,
+ map,
+ merge,
+ mergeMap,
+ of,
+ share,
+ skipUntil,
+ switchMap,
+ takeUntil,
+ tap,
+ withLatestFrom,
+ zipWith
+} from "rxjs"
+
+import { translation } from "~/_"
+import {
+ getElement,
+ getOptionalElement,
+ watchElementBoundary,
+ watchToggle
+} from "~/browser"
+import {
+ SearchMessage,
+ SearchResult,
+ isSearchReadyMessage,
+ isSearchResultMessage
+} from "~/integrations"
+import { renderSearchResultItem } from "~/templates"
+import { round } from "~/utilities"
+
+import { Component } from "../../_"
+import { SearchQuery } from "../query"
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mount options
+ */
+interface MountOptions {
+ query$: Observable<SearchQuery> /* Search query observable */
+ worker$: Subject<SearchMessage> /* Search worker */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mount search result list
+ *
+ * This function performs a lazy rendering of the search results, depending on
+ * the vertical offset of the search result container.
+ *
+ * @param el - Search result list element
+ * @param options - Options
+ *
+ * @returns Search result list component observable
+ */
+export function mountSearchResult(
+ el: HTMLElement, { worker$, query$ }: MountOptions
+): Observable<Component<SearchResult>> {
+ const push$ = new Subject<SearchResult>()
+ const boundary$ = watchElementBoundary(el.parentElement!)
+ .pipe(
+ filter(Boolean)
+ )
+
+ /* Retrieve container */
+ const container = el.parentElement!
+
+ /* Retrieve nested components */
+ const meta = getElement(":scope > :first-child", el)
+ const list = getElement(":scope > :last-child", el)
+
+ /* Reveal to accessibility tree – see https://bit.ly/3iAA7t8 */
+ watchToggle("search")
+ .subscribe(active => list.setAttribute(
+ "role", active ? "list" : "presentation"
+ ))
+
+ /* Update search result metadata */
+ push$
+ .pipe(
+ withLatestFrom(query$),
+ skipUntil(worker$.pipe(first(isSearchReadyMessage)))
+ )
+ .subscribe(([{ items }, { value }]) => {
+ switch (items.length) {
+
+ /* No results */
+ case 0:
+ meta.textContent = value.length
+ ? translation("search.result.none")
+ : translation("search.result.placeholder")
+ break
+
+ /* One result */
+ case 1:
+ meta.textContent = translation("search.result.one")
+ break
+
+ /* Multiple result */
+ default:
+ const count = round(items.length)
+ meta.textContent = translation("search.result.other", count)
+ }
+ })
+
+ /* Render search result item */
+ const render$ = push$
+ .pipe(
+ tap(() => list.innerHTML = ""),
+ switchMap(({ items }) => merge(
+ of(...items.slice(0, 10)),
+ of(...items.slice(10))
+ .pipe(
+ bufferCount(4),
+ zipWith(boundary$),
+ switchMap(([chunk]) => chunk)
+ )
+ )),
+ map(renderSearchResultItem),
+ share()
+ )
+
+ /* Update search result list */
+ render$.subscribe(item => list.appendChild(item))
+ render$
+ .pipe(
+ mergeMap(item => {
+ const details = getOptionalElement("details", item)
+ if (typeof details === "undefined")
+ return EMPTY
+
+ /* Keep position of details element stable */
+ return fromEvent(details, "toggle")
+ .pipe(
+ takeUntil(push$),
+ map(() => details)
+ )
+ })
+ )
+ .subscribe(details => {
+ if (
+ details.open === false &&
+ details.offsetTop <= container.scrollTop
+ )
+ container.scrollTo({ top: details.offsetTop })
+ })
+
+ /* Filter search result message */
+ const result$ = worker$
+ .pipe(
+ filter(isSearchResultMessage),
+ map(({ data }) => data)
+ )
+
+ /* Create and return component */
+ return result$
+ .pipe(
+ tap(state => push$.next(state)),
+ finalize(() => push$.complete()),
+ map(state => ({ ref: el, ...state }))
+ )
+}
diff --git a/src/templates/assets/javascripts/components/search/share/index.ts b/src/templates/assets/javascripts/components/search/share/index.ts
new file mode 100644
index 00000000..3db382c8
--- /dev/null
+++ b/src/templates/assets/javascripts/components/search/share/index.ts
@@ -0,0 +1,135 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ Observable,
+ Subject,
+ endWith,
+ finalize,
+ fromEvent,
+ ignoreElements,
+ map,
+ takeUntil,
+ tap
+} from "rxjs"
+
+import { getLocation } from "~/browser"
+
+import { Component } from "../../_"
+import { SearchQuery } from "../query"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Search sharing
+ */
+export interface SearchShare {
+ url: URL /* Deep link for sharing */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch options
+ */
+interface WatchOptions {
+ query$: Observable<SearchQuery> /* Search query observable */
+}
+
+/**
+ * Mount options
+ */
+interface MountOptions {
+ query$: Observable<SearchQuery> /* Search query observable */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mount search sharing
+ *
+ * @param _el - Search sharing element
+ * @param options - Options
+ *
+ * @returns Search sharing observable
+ */
+export function watchSearchShare(
+ _el: HTMLElement, { query$ }: WatchOptions
+): Observable<SearchShare> {
+ return query$
+ .pipe(
+ map(({ value }) => {
+ const url = getLocation()
+ url.hash = ""
+
+ /* Compute readable query strings */
+ value = value
+ .replace(/\s+/g, "+") /* Collapse whitespace */
+ .replace(/&/g, "%26") /* Escape '&' character */
+ .replace(/=/g, "%3D") /* Escape '=' character */
+
+ /* Replace query string */
+ url.search = `q=${value}`
+ return { url }
+ })
+ )
+}
+
+/**
+ * Mount search sharing
+ *
+ * @param el - Search sharing element
+ * @param options - Options
+ *
+ * @returns Search sharing component observable
+ */
+export function mountSearchShare(
+ el: HTMLAnchorElement, options: MountOptions
+): Observable<Component<SearchShare>> {
+ const push$ = new Subject<SearchShare>()
+ const done$ = push$.pipe(ignoreElements(), endWith(true))
+ push$.subscribe(({ url }) => {
+ el.setAttribute("data-clipboard-text", el.href)
+ el.href = `${url}`
+ })
+
+ /* Prevent following of link */
+ fromEvent(el, "click")
+ .pipe(
+ takeUntil(done$)
+ )
+ .subscribe(ev => ev.preventDefault())
+
+ /* Create and return component */
+ return watchSearchShare(el, options)
+ .pipe(
+ tap(state => push$.next(state)),
+ finalize(() => push$.complete()),
+ map(state => ({ ref: el, ...state }))
+ )
+}
diff --git a/src/templates/assets/javascripts/components/search/suggest/index.ts b/src/templates/assets/javascripts/components/search/suggest/index.ts
new file mode 100644
index 00000000..e7881475
--- /dev/null
+++ b/src/templates/assets/javascripts/components/search/suggest/index.ts
@@ -0,0 +1,154 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ Observable,
+ Subject,
+ asyncScheduler,
+ combineLatestWith,
+ distinctUntilChanged,
+ filter,
+ finalize,
+ fromEvent,
+ map,
+ merge,
+ observeOn,
+ tap
+} from "rxjs"
+
+import { Keyboard } from "~/browser"
+import {
+ SearchMessage,
+ SearchResult,
+ isSearchResultMessage
+} from "~/integrations"
+
+import { Component, getComponentElement } from "../../_"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Search suggestions
+ */
+export interface SearchSuggest {}
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mount options
+ */
+interface MountOptions {
+ keyboard$: Observable<Keyboard> /* Keyboard observable */
+ worker$: Subject<SearchMessage> /* Search worker */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Mount search suggestions
+ *
+ * This function will perform a lazy rendering of the search results, depending
+ * on the vertical offset of the search result container.
+ *
+ * @param el - Search result list element
+ * @param options - Options
+ *
+ * @returns Search result list component observable
+ */
+export function mountSearchSuggest(
+ el: HTMLElement, { worker$, keyboard$ }: MountOptions
+): Observable<Component<SearchSuggest>> {
+ const push$ = new Subject<SearchResult>()
+
+ /* Retrieve query component and track all changes */
+ const query = getComponentElement("search-query")
+ const query$ = merge(
+ fromEvent(query, "keydown"),
+ fromEvent(query, "focus")
+ )
+ .pipe(
+ observeOn(asyncScheduler),
+ map(() => query.value),
+ distinctUntilChanged(),
+ )
+
+ /* Update search suggestions */
+ push$
+ .pipe(
+ combineLatestWith(query$),
+ map(([{ suggest }, value]) => {
+ const words = value.split(/([\s-]+)/)
+ if (suggest?.length && words[words.length - 1]) {
+ const last = suggest[suggest.length - 1]
+ if (last.startsWith(words[words.length - 1]))
+ words[words.length - 1] = last
+ } else {
+ words.length = 0
+ }
+ return words
+ })
+ )
+ .subscribe(words => el.innerHTML = words
+ .join("")
+ .replace(/\s/g, "&nbsp;")
+ )
+
+ /* Set up search keyboard handlers */
+ keyboard$
+ .pipe(
+ filter(({ mode }) => mode === "search")
+ )
+ .subscribe(key => {
+ switch (key.type) {
+
+ /* Right arrow: accept current suggestion */
+ case "ArrowRight":
+ if (
+ el.innerText.length &&
+ query.selectionStart === query.value.length
+ )
+ query.value = el.innerText
+ break
+ }
+ })
+
+ /* Filter search result message */
+ const result$ = worker$
+ .pipe(
+ filter(isSearchResultMessage),
+ map(({ data }) => data)
+ )
+
+ /* Create and return component */
+ return result$
+ .pipe(
+ tap(state => push$.next(state)),
+ finalize(() => push$.complete()),
+ map(() => ({ ref: el }))
+ )
+}
diff --git a/src/templates/assets/javascripts/components/sidebar/index.ts b/src/templates/assets/javascripts/components/sidebar/index.ts
new file mode 100644
index 00000000..82f3d03e
--- /dev/null
+++ b/src/templates/assets/javascripts/components/sidebar/index.ts
@@ -0,0 +1,227 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ Observable,
+ Subject,
+ animationFrameScheduler,
+ asyncScheduler,
+ auditTime,
+ combineLatest,
+ defer,
+ distinctUntilChanged,
+ endWith,
+ finalize,
+ first,
+ from,
+ fromEvent,
+ ignoreElements,
+ map,
+ mergeMap,
+ observeOn,
+ takeUntil,
+ tap,
+ withLatestFrom
+} from "rxjs"
+
+import {
+ Viewport,
+ getElement,
+ getElementContainer,
+ getElementOffset,
+ getElementSize,
+ getElements
+} from "~/browser"
+
+import { Component } from "../_"
+import { Header } from "../header"
+import { Main } from "../main"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Sidebar
+ */
+export interface Sidebar {
+ height: number /* Sidebar height */
+ locked: boolean /* Sidebar is locked */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch options
+ */
+interface WatchOptions {
+ viewport$: Observable<Viewport> /* Viewport observable */
+ main$: Observable<Main> /* Main area observable */
+}
+
+/**
+ * Mount options
+ */
+interface MountOptions {
+ viewport$: Observable<Viewport> /* Viewport observable */
+ header$: Observable<Header> /* Header observable */
+ main$: Observable<Main> /* Main area observable */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch sidebar
+ *
+ * This function returns an observable that computes the visual parameters of
+ * the sidebar which depends on the vertical viewport offset, as well as the
+ * height of the main area. When the page is scrolled beyond the header, the
+ * sidebar is locked and fills the remaining space.
+ *
+ * @param el - Sidebar element
+ * @param options - Options
+ *
+ * @returns Sidebar observable
+ */
+export function watchSidebar(
+ el: HTMLElement, { viewport$, main$ }: WatchOptions
+): Observable<Sidebar> {
+ const parent = el.closest<HTMLElement>(".md-grid")!
+ const adjust =
+ parent.offsetTop -
+ parent.parentElement!.offsetTop
+
+ /* Compute the sidebar's available height and if it should be locked */
+ return combineLatest([main$, viewport$])
+ .pipe(
+ map(([{ offset, height }, { offset: { y } }]) => {
+ height = height
+ + Math.min(adjust, Math.max(0, y - offset))
+ - adjust
+ return {
+ height,
+ locked: y >= offset + adjust
+ }
+ }),
+ distinctUntilChanged((a, b) => (
+ a.height === b.height &&
+ a.locked === b.locked
+ ))
+ )
+}
+
+/**
+ * Mount sidebar
+ *
+ * This function doesn't set the height of the actual sidebar, but of its first
+ * child – the `.md-sidebar__scrollwrap` element in order to mitigiate jittery
+ * sidebars when the footer is scrolled into view. At some point we switched
+ * from `absolute` / `fixed` positioning to `sticky` positioning, significantly
+ * reducing jitter in some browsers (respectively Firefox and Safari) when
+ * scrolling from the top. However, top-aligned sticky positioning means that
+ * the sidebar snaps to the bottom when the end of the container is reached.
+ * This is what leads to the mentioned jitter, as the sidebar's height may be
+ * updated too slowly.
+ *
+ * This behaviour can be mitigiated by setting the height of the sidebar to `0`
+ * while preserving the padding, and the height on its first element.
+ *
+ * @param el - Sidebar element
+ * @param options - Options
+ *
+ * @returns Sidebar component observable
+ */
+export function mountSidebar(
+ el: HTMLElement, { header$, ...options }: MountOptions
+): Observable<Component<Sidebar>> {
+ const inner = getElement(".md-sidebar__scrollwrap", el)
+ const { y } = getElementOffset(inner)
+ return defer(() => {
+ const push$ = new Subject<Sidebar>()
+ const done$ = push$.pipe(ignoreElements(), endWith(true))
+ const next$ = push$
+ .pipe(
+ auditTime(0, animationFrameScheduler)
+ )
+
+ /* Update sidebar height and offset */
+ next$.pipe(withLatestFrom(header$))
+ .subscribe({
+
+ /* Handle emission */
+ next([{ height }, { height: offset }]) {
+ inner.style.height = `${height - 2 * y}px`
+ el.style.top = `${offset}px`
+ },
+
+ /* Handle complete */
+ complete() {
+ inner.style.height = ""
+ el.style.top = ""
+ }
+ })
+
+ /* Bring active item into view on initial load */
+ next$.pipe(first())
+ .subscribe(() => {
+ for (const item of getElements(".md-nav__link--active[href]", el)) {
+ const container = getElementContainer(item)
+ if (typeof container !== "undefined") {
+ const offset = item.offsetTop - container.offsetTop
+ const { height } = getElementSize(container)
+ container.scrollTo({
+ top: offset - height / 2
+ })
+ }
+ }
+ })
+
+ /* Handle accessibility for expandable items, see https://bit.ly/3jaod9p */
+ from(getElements<HTMLLabelElement>("label[tabindex]", el))
+ .pipe(
+ mergeMap(label => fromEvent(label, "click")
+ .pipe(
+ observeOn(asyncScheduler),
+ map(() => label),
+ takeUntil(done$)
+ )
+ )
+ )
+ .subscribe(label => {
+ const input = getElement<HTMLInputElement>(`[id="${label.htmlFor}"]`)
+ const nav = getElement(`[aria-labelledby="${label.id}"]`)
+ nav.setAttribute("aria-expanded", `${input.checked}`)
+ })
+
+ /* Create and return component */
+ return watchSidebar(el, options)
+ .pipe(
+ tap(state => push$.next(state)),
+ finalize(() => push$.complete()),
+ map(state => ({ ref: el, ...state }))
+ )
+ })
+}
diff --git a/src/templates/assets/javascripts/components/source/_/index.ts b/src/templates/assets/javascripts/components/source/_/index.ts
new file mode 100644
index 00000000..5f6c4d11
--- /dev/null
+++ b/src/templates/assets/javascripts/components/source/_/index.ts
@@ -0,0 +1,142 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ EMPTY,
+ Observable,
+ Subject,
+ catchError,
+ defer,
+ filter,
+ finalize,
+ map,
+ of,
+ shareReplay,
+ tap
+} from "rxjs"
+
+import { getElement } from "~/browser"
+import { ConsentDefaults } from "~/components/consent"
+import { renderSourceFacts } from "~/templates"
+
+import {
+ Component,
+ getComponentElements
+} from "../../_"
+import {
+ SourceFacts,
+ fetchSourceFacts
+} from "../facts"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Repository information
+ */
+export interface Source {
+ facts: SourceFacts /* Repository facts */
+}
+
+/* ----------------------------------------------------------------------------
+ * Data
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Repository information observable
+ */
+let fetch$: Observable<Source>
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch repository information
+ *
+ * This function tries to read the repository facts from session storage, and
+ * if unsuccessful, fetches them from the underlying provider.
+ *
+ * @param el - Repository information element
+ *
+ * @returns Repository information observable
+ */
+export function watchSource(
+ el: HTMLAnchorElement
+): Observable<Source> {
+ return fetch$ ||= defer(() => {
+ const cached = __md_get<SourceFacts>("__source", sessionStorage)
+ if (cached) {
+ return of(cached)
+ } else {
+
+ /* Check if consent is configured and was given */
+ const els = getComponentElements("consent")
+ if (els.length) {
+ const consent = __md_get<ConsentDefaults>("__consent")
+ if (!(consent && consent.github))
+ return EMPTY
+ }
+
+ /* Fetch repository facts */
+ return fetchSourceFacts(el.href)
+ .pipe(
+ tap(facts => __md_set("__source", facts, sessionStorage))
+ )
+ }
+ })
+ .pipe(
+ catchError(() => EMPTY),
+ filter(facts => Object.keys(facts).length > 0),
+ map(facts => ({ facts })),
+ shareReplay(1)
+ )
+}
+
+/**
+ * Mount repository information
+ *
+ * @param el - Repository information element
+ *
+ * @returns Repository information component observable
+ */
+export function mountSource(
+ el: HTMLAnchorElement
+): Observable<Component<Source>> {
+ const inner = getElement(":scope > :last-child", el)
+ return defer(() => {
+ const push$ = new Subject<Source>()
+ push$.subscribe(({ facts }) => {
+ inner.appendChild(renderSourceFacts(facts))
+ inner.classList.add("md-source__repository--active")
+ })
+
+ /* Create and return component */
+ return watchSource(el)
+ .pipe(
+ tap(state => push$.next(state)),
+ finalize(() => push$.complete()),
+ map(state => ({ ref: el, ...state }))
+ )
+ })
+}
diff --git a/src/templates/assets/javascripts/components/source/facts/_/index.ts b/src/templates/assets/javascripts/components/source/facts/_/index.ts
new file mode 100644
index 00000000..154f229f
--- /dev/null
+++ b/src/templates/assets/javascripts/components/source/facts/_/index.ts
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import { EMPTY, Observable } from "rxjs"
+
+import { fetchSourceFactsFromGitHub } from "../github"
+import { fetchSourceFactsFromGitLab } from "../gitlab"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Repository facts for repositories
+ */
+export interface RepositoryFacts {
+ stars?: number /* Number of stars */
+ forks?: number /* Number of forks */
+ version?: string /* Latest version */
+}
+
+/**
+ * Repository facts for organizations
+ */
+export interface OrganizationFacts {
+ repositories?: number /* Number of repositories */
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Repository facts
+ */
+export type SourceFacts =
+ | RepositoryFacts
+ | OrganizationFacts
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Fetch repository facts
+ *
+ * @param url - Repository URL
+ *
+ * @returns Repository facts observable
+ */
+export function fetchSourceFacts(
+ url: string
+): Observable<SourceFacts> {
+
+ /* Try to match GitHub repository */
+ let match = url.match(/^.+github\.com\/([^/]+)\/?([^/]+)?/i)
+ if (match) {
+ const [, user, repo] = match
+ return fetchSourceFactsFromGitHub(user, repo)
+ }
+
+ /* Try to match GitLab repository */
+ match = url.match(/^.+?([^/]*gitlab[^/]+)\/(.+?)\/?$/i)
+ if (match) {
+ const [, base, slug] = match
+ return fetchSourceFactsFromGitLab(base, slug)
+ }
+
+ /* Fallback */
+ return EMPTY
+}
diff --git a/src/templates/assets/javascripts/components/source/facts/github/index.ts b/src/templates/assets/javascripts/components/source/facts/github/index.ts
new file mode 100644
index 00000000..12cc55e0
--- /dev/null
+++ b/src/templates/assets/javascripts/components/source/facts/github/index.ts
@@ -0,0 +1,103 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import { Repo, User } from "github-types"
+import {
+ EMPTY,
+ Observable,
+ catchError,
+ defaultIfEmpty,
+ map,
+ zip
+} from "rxjs"
+
+import { requestJSON } from "~/browser"
+
+import { SourceFacts } from "../_"
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * GitHub release (partial)
+ */
+interface Release {
+ tag_name: string /* Tag name */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Fetch GitHub repository facts
+ *
+ * @param user - GitHub user or organization
+ * @param repo - GitHub repository
+ *
+ * @returns Repository facts observable
+ */
+export function fetchSourceFactsFromGitHub(
+ user: string, repo?: string
+): Observable<SourceFacts> {
+ if (typeof repo !== "undefined") {
+ const url = `https://api.github.com/repos/${user}/${repo}`
+ return zip(
+
+ /* Fetch version */
+ requestJSON<Release>(`${url}/releases/latest`)
+ .pipe(
+ catchError(() => EMPTY), // @todo refactor instant loading
+ map(release => ({
+ version: release.tag_name
+ })),
+ defaultIfEmpty({})
+ ),
+
+ /* Fetch stars and forks */
+ requestJSON<Repo>(url)
+ .pipe(
+ catchError(() => EMPTY), // @todo refactor instant loading
+ map(info => ({
+ stars: info.stargazers_count,
+ forks: info.forks_count
+ })),
+ defaultIfEmpty({})
+ )
+ )
+ .pipe(
+ map(([release, info]) => ({ ...release, ...info }))
+ )
+
+ /* User or organization */
+ } else {
+ const url = `https://api.github.com/users/${user}`
+ return requestJSON<User>(url)
+ .pipe(
+ map(info => ({
+ repositories: info.public_repos
+ })),
+ defaultIfEmpty({})
+ )
+ }
+}
diff --git a/src/templates/assets/javascripts/components/source/facts/gitlab/index.ts b/src/templates/assets/javascripts/components/source/facts/gitlab/index.ts
new file mode 100644
index 00000000..d85d4afd
--- /dev/null
+++ b/src/templates/assets/javascripts/components/source/facts/gitlab/index.ts
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import { ProjectSchema } from "gitlab"
+import {
+ EMPTY,
+ Observable,
+ catchError,
+ defaultIfEmpty,
+ map
+} from "rxjs"
+
+import { requestJSON } from "~/browser"
+
+import { SourceFacts } from "../_"
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Fetch GitLab repository facts
+ *
+ * @param base - GitLab base
+ * @param project - GitLab project
+ *
+ * @returns Repository facts observable
+ */
+export function fetchSourceFactsFromGitLab(
+ base: string, project: string
+): Observable<SourceFacts> {
+ const url = `https://${base}/api/v4/projects/${encodeURIComponent(project)}`
+ return requestJSON<ProjectSchema>(url)
+ .pipe(
+ catchError(() => EMPTY), // @todo refactor instant loading
+ map(({ star_count, forks_count }) => ({
+ stars: star_count,
+ forks: forks_count
+ })),
+ defaultIfEmpty({})
+ )
+}
diff --git a/src/templates/assets/javascripts/components/source/facts/index.ts b/src/templates/assets/javascripts/components/source/facts/index.ts
new file mode 100644
index 00000000..f9bda64d
--- /dev/null
+++ b/src/templates/assets/javascripts/components/source/facts/index.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2016-2023 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.
+ */
+
+export * from "./_"
+export * from "./github"
+export * from "./gitlab"
diff --git a/src/templates/assets/javascripts/components/source/index.ts b/src/templates/assets/javascripts/components/source/index.ts
new file mode 100644
index 00000000..7fac4813
--- /dev/null
+++ b/src/templates/assets/javascripts/components/source/index.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2016-2023 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.
+ */
+
+export * from "./_"
+export * from "./facts"
diff --git a/src/templates/assets/javascripts/components/tabs/index.ts b/src/templates/assets/javascripts/components/tabs/index.ts
new file mode 100644
index 00000000..1e69df28
--- /dev/null
+++ b/src/templates/assets/javascripts/components/tabs/index.ts
@@ -0,0 +1,144 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ Observable,
+ Subject,
+ defer,
+ distinctUntilKeyChanged,
+ finalize,
+ map,
+ of,
+ switchMap,
+ tap
+} from "rxjs"
+
+import { feature } from "~/_"
+import {
+ Viewport,
+ watchElementSize,
+ watchViewportAt
+} from "~/browser"
+
+import { Component } from "../_"
+import { Header } from "../header"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Navigation tabs
+ */
+export interface Tabs {
+ hidden: boolean /* Navigation tabs are hidden */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch options
+ */
+interface WatchOptions {
+ viewport$: Observable<Viewport> /* Viewport observable */
+ header$: Observable<Header> /* Header observable */
+}
+
+/**
+ * Mount options
+ */
+interface MountOptions {
+ viewport$: Observable<Viewport> /* Viewport observable */
+ header$: Observable<Header> /* Header observable */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch navigation tabs
+ *
+ * @param el - Navigation tabs element
+ * @param options - Options
+ *
+ * @returns Navigation tabs observable
+ */
+export function watchTabs(
+ el: HTMLElement, { viewport$, header$ }: WatchOptions
+): Observable<Tabs> {
+ return watchElementSize(document.body)
+ .pipe(
+ switchMap(() => watchViewportAt(el, { header$, viewport$ })),
+ map(({ offset: { y } }) => {
+ return {
+ hidden: y >= 10
+ }
+ }),
+ distinctUntilKeyChanged("hidden")
+ )
+}
+
+/**
+ * Mount navigation tabs
+ *
+ * This function hides the navigation tabs when scrolling past the threshold
+ * and makes them reappear in a nice CSS animation when scrolling back up.
+ *
+ * @param el - Navigation tabs element
+ * @param options - Options
+ *
+ * @returns Navigation tabs component observable
+ */
+export function mountTabs(
+ el: HTMLElement, options: MountOptions
+): Observable<Component<Tabs>> {
+ return defer(() => {
+ const push$ = new Subject<Tabs>()
+ push$.subscribe({
+
+ /* Handle emission */
+ next({ hidden }) {
+ el.hidden = hidden
+ },
+
+ /* Handle complete */
+ complete() {
+ el.hidden = false
+ }
+ })
+
+ /* Create and return component */
+ return (
+ feature("navigation.tabs.sticky")
+ ? of({ hidden: false })
+ : watchTabs(el, options)
+ )
+ .pipe(
+ tap(state => push$.next(state)),
+ finalize(() => push$.complete()),
+ map(state => ({ ref: el, ...state }))
+ )
+ })
+}
diff --git a/src/templates/assets/javascripts/components/toc/index.ts b/src/templates/assets/javascripts/components/toc/index.ts
new file mode 100644
index 00000000..04b8d85f
--- /dev/null
+++ b/src/templates/assets/javascripts/components/toc/index.ts
@@ -0,0 +1,379 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ Observable,
+ Subject,
+ asyncScheduler,
+ bufferCount,
+ combineLatestWith,
+ debounceTime,
+ defer,
+ distinctUntilChanged,
+ distinctUntilKeyChanged,
+ endWith,
+ filter,
+ finalize,
+ ignoreElements,
+ map,
+ merge,
+ observeOn,
+ of,
+ repeat,
+ scan,
+ share,
+ skip,
+ startWith,
+ switchMap,
+ takeUntil,
+ tap,
+ withLatestFrom
+} from "rxjs"
+
+import { feature } from "~/_"
+import {
+ Viewport,
+ getElement,
+ getElementContainer,
+ getElementSize,
+ getElements,
+ getLocation,
+ getOptionalElement,
+ watchElementSize
+} from "~/browser"
+
+import {
+ Component,
+ getComponentElement
+} from "../_"
+import { Header } from "../header"
+import { Main } from "../main"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Table of contents
+ */
+export interface TableOfContents {
+ prev: HTMLAnchorElement[][] /* Anchors (previous) */
+ next: HTMLAnchorElement[][] /* Anchors (next) */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch options
+ */
+interface WatchOptions {
+ viewport$: Observable<Viewport> /* Viewport observable */
+ header$: Observable<Header> /* Header observable */
+}
+
+/**
+ * Mount options
+ */
+interface MountOptions {
+ viewport$: Observable<Viewport> /* Viewport observable */
+ header$: Observable<Header> /* Header observable */
+ main$: Observable<Main> /* Main area observable */
+ target$: Observable<HTMLElement> /* Location target observable */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch table of contents
+ *
+ * This is effectively a scroll spy implementation which will account for the
+ * fixed header and automatically re-calculate anchor offsets when the viewport
+ * is resized. The returned observable will only emit if the table of contents
+ * needs to be repainted.
+ *
+ * This implementation tracks an anchor element's entire path starting from its
+ * level up to the top-most anchor element, e.g. `[h3, h2, h1]`. Although the
+ * Material theme currently doesn't make use of this information, it enables
+ * the styling of the entire hierarchy through customization.
+ *
+ * Note that the current anchor is the last item of the `prev` anchor list.
+ *
+ * @param el - Table of contents element
+ * @param options - Options
+ *
+ * @returns Table of contents observable
+ */
+export function watchTableOfContents(
+ el: HTMLElement, { viewport$, header$ }: WatchOptions
+): Observable<TableOfContents> {
+ const table = new Map<HTMLAnchorElement, HTMLElement>()
+
+ /* Compute anchor-to-target mapping */
+ const anchors = getElements<HTMLAnchorElement>("[href^=\\#]", el)
+ for (const anchor of anchors) {
+ const id = decodeURIComponent(anchor.hash.substring(1))
+ const target = getOptionalElement(`[id="${id}"]`)
+ if (typeof target !== "undefined")
+ table.set(anchor, target)
+ }
+
+ /* Compute necessary adjustment for header */
+ const adjust$ = header$
+ .pipe(
+ distinctUntilKeyChanged("height"),
+ map(({ height }) => {
+ const main = getComponentElement("main")
+ const grid = getElement(":scope > :first-child", main)
+ return height + 0.8 * (
+ grid.offsetTop -
+ main.offsetTop
+ )
+ }),
+ share()
+ )
+
+ /* Compute partition of previous and next anchors */
+ const partition$ = watchElementSize(document.body)
+ .pipe(
+ distinctUntilKeyChanged("height"),
+
+ /* Build index to map anchor paths to vertical offsets */
+ switchMap(body => defer(() => {
+ let path: HTMLAnchorElement[] = []
+ return of([...table].reduce((index, [anchor, target]) => {
+ while (path.length) {
+ const last = table.get(path[path.length - 1])!
+ if (last.tagName >= target.tagName) {
+ path.pop()
+ } else {
+ break
+ }
+ }
+
+ /* If the current anchor is hidden, continue with its parent */
+ let offset = target.offsetTop
+ while (!offset && target.parentElement) {
+ target = target.parentElement
+ offset = target.offsetTop
+ }
+
+ /* Fix anchor offsets in tables - see https://bit.ly/3CUFOcn */
+ let parent = target.offsetParent as HTMLElement
+ for (; parent; parent = parent.offsetParent as HTMLElement)
+ offset += parent.offsetTop
+
+ /* Map reversed anchor path to vertical offset */
+ return index.set(
+ [...path = [...path, anchor]].reverse(),
+ offset
+ )
+ }, new Map<HTMLAnchorElement[], number>()))
+ })
+ .pipe(
+
+ /* Sort index by vertical offset (see https://bit.ly/30z6QSO) */
+ map(index => new Map([...index].sort(([, a], [, b]) => a - b))),
+ combineLatestWith(adjust$),
+
+ /* Re-compute partition when viewport offset changes */
+ switchMap(([index, adjust]) => viewport$
+ .pipe(
+ scan(([prev, next], { offset: { y }, size }) => {
+ const last = y + size.height >= Math.floor(body.height)
+
+ /* Look forward */
+ while (next.length) {
+ const [, offset] = next[0]
+ if (offset - adjust < y || last) {
+ prev = [...prev, next.shift()!]
+ } else {
+ break
+ }
+ }
+
+ /* Look backward */
+ while (prev.length) {
+ const [, offset] = prev[prev.length - 1]
+ if (offset - adjust >= y && !last) {
+ next = [prev.pop()!, ...next]
+ } else {
+ break
+ }
+ }
+
+ /* Return partition */
+ return [prev, next]
+ }, [[], [...index]]),
+ distinctUntilChanged((a, b) => (
+ a[0] === b[0] &&
+ a[1] === b[1]
+ ))
+ )
+ )
+ )
+ )
+ )
+
+ /* Compute and return anchor list migrations */
+ return partition$
+ .pipe(
+ map(([prev, next]) => ({
+ prev: prev.map(([path]) => path),
+ next: next.map(([path]) => path)
+ })),
+
+ /* Extract anchor list migrations */
+ startWith({ prev: [], next: [] }),
+ bufferCount(2, 1),
+ map(([a, b]) => {
+
+ /* Moving down */
+ if (a.prev.length < b.prev.length) {
+ return {
+ prev: b.prev.slice(Math.max(0, a.prev.length - 1), b.prev.length),
+ next: []
+ }
+
+ /* Moving up */
+ } else {
+ return {
+ prev: b.prev.slice(-1),
+ next: b.next.slice(0, b.next.length - a.next.length)
+ }
+ }
+ })
+ )
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Mount table of contents
+ *
+ * @param el - Table of contents element
+ * @param options - Options
+ *
+ * @returns Table of contents component observable
+ */
+export function mountTableOfContents(
+ el: HTMLElement, { viewport$, header$, main$, target$ }: MountOptions
+): Observable<Component<TableOfContents>> {
+ return defer(() => {
+ const push$ = new Subject<TableOfContents>()
+ const done$ = push$.pipe(ignoreElements(), endWith(true))
+ push$.subscribe(({ prev, next }) => {
+
+ /* Look forward */
+ for (const [anchor] of next) {
+ anchor.classList.remove("md-nav__link--passed")
+ anchor.classList.remove("md-nav__link--active")
+ }
+
+ /* Look backward */
+ for (const [index, [anchor]] of prev.entries()) {
+ anchor.classList.add("md-nav__link--passed")
+ anchor.classList.toggle(
+ "md-nav__link--active",
+ index === prev.length - 1
+ )
+ }
+ })
+
+ /* Set up following, if enabled */
+ if (feature("toc.follow")) {
+
+ /* Toggle smooth scrolling only for anchor clicks */
+ const smooth$ = merge(
+ viewport$.pipe(debounceTime(1), map(() => undefined)),
+ viewport$.pipe(debounceTime(250), map(() => "smooth" as const))
+ )
+
+ /* Bring active anchor into view */ // @todo: refactor
+ push$
+ .pipe(
+ filter(({ prev }) => prev.length > 0),
+ combineLatestWith(main$.pipe(observeOn(asyncScheduler))),
+ withLatestFrom(smooth$)
+ )
+ .subscribe(([[{ prev }], behavior]) => {
+ const [anchor] = prev[prev.length - 1]
+ if (anchor.offsetHeight) {
+
+ /* Retrieve overflowing container and scroll */
+ const container = getElementContainer(anchor)
+ if (typeof container !== "undefined") {
+ const offset = anchor.offsetTop - container.offsetTop
+ const { height } = getElementSize(container)
+ container.scrollTo({
+ top: offset - height / 2,
+ behavior
+ })
+ }
+ }
+ })
+ }
+
+ /* Set up anchor tracking, if enabled */
+ if (feature("navigation.tracking"))
+ viewport$
+ .pipe(
+ takeUntil(done$),
+ distinctUntilKeyChanged("offset"),
+ debounceTime(250),
+ skip(1),
+ takeUntil(target$.pipe(skip(1))),
+ repeat({ delay: 250 }),
+ withLatestFrom(push$)
+ )
+ .subscribe(([, { prev }]) => {
+ const url = getLocation()
+
+ /* Set hash fragment to active anchor */
+ const anchor = prev[prev.length - 1]
+ if (anchor && anchor.length) {
+ const [active] = anchor
+ const { hash } = new URL(active.href)
+ if (url.hash !== hash) {
+ url.hash = hash
+ history.replaceState({}, "", `${url}`)
+ }
+
+ /* Reset anchor when at the top */
+ } else {
+ url.hash = ""
+ history.replaceState({}, "", `${url}`)
+ }
+ })
+
+ /* Create and return component */
+ return watchTableOfContents(el, { viewport$, header$ })
+ .pipe(
+ tap(state => push$.next(state)),
+ finalize(() => push$.complete()),
+ map(state => ({ ref: el, ...state }))
+ )
+ })
+}
diff --git a/src/templates/assets/javascripts/components/top/index.ts b/src/templates/assets/javascripts/components/top/index.ts
new file mode 100644
index 00000000..82e88b61
--- /dev/null
+++ b/src/templates/assets/javascripts/components/top/index.ts
@@ -0,0 +1,184 @@
+/*
+ * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {
+ Observable,
+ Subject,
+ bufferCount,
+ combineLatest,
+ distinctUntilChanged,
+ distinctUntilKeyChanged,
+ endWith,
+ finalize,
+ fromEvent,
+ ignoreElements,
+ map,
+ repeat,
+ skip,
+ takeUntil,
+ tap
+} from "rxjs"
+
+import { Viewport } from "~/browser"
+
+import { Component } from "../_"
+import { Header } from "../header"
+import { Main } from "../main"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Back-to-top button
+ */
+export interface BackToTop {
+ hidden: boolean /* Back-to-top button is hidden */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch options
+ */
+interface WatchOptions {
+ viewport$: Observable<Viewport> /* Viewport observable */
+ main$: Observable<Main> /* Main area observable */
+ target$: Observable<HTMLElement> /* Location target observable */
+}
+
+/**
+ * Mount options
+ */
+interface MountOptions {
+ viewport$: Observable<Viewport> /* Viewport observable */
+ header$: Observable<Header> /* Header observable */
+ main$: Observable<Main> /* Main area observable */
+ target$: Observable<HTMLElement> /* Location target observable */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch back-to-top
+ *
+ * @param _el - Back-to-top element
+ * @param options - Options
+ *
+ * @returns Back-to-top observable
+ */
+export function watchBackToTop(
+ _el: HTMLElement, { viewport$, main$, target$ }: WatchOptions
+): Observable<BackToTop> {
+
+ /* Compute direction */
+ const direction$ = viewport$
+ .pipe(
+ map(({ offset: { y } }) => y),
+ bufferCount(2, 1),
+ map(([a, b]) => a > b && b > 0),
+ distinctUntilChanged()
+ )
+
+ /* Compute whether main area is active */
+ const active$ = main$
+ .pipe(
+ map(({ active }) => active)
+ )
+
+ /* Compute threshold for hiding */
+ return combineLatest([active$, direction$])
+ .pipe(
+ map(([active, direction]) => !(active && direction)),
+ distinctUntilChanged(),
+ takeUntil(target$.pipe(skip(1))),
+ endWith(true),
+ repeat({ delay: 250 }),
+ map(hidden => ({ hidden }))
+ )
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Mount back-to-top
+ *
+ * @param el - Back-to-top element
+ * @param options - Options
+ *
+ * @returns Back-to-top component observable
+ */
+export function mountBackToTop(
+ el: HTMLElement, { viewport$, header$, main$, target$ }: MountOptions
+): Observable<Component<BackToTop>> {
+ const push$ = new Subject<BackToTop>()
+ const done$ = push$.pipe(ignoreElements(), endWith(true))
+ push$.subscribe({
+
+ /* Handle emission */
+ next({ hidden }) {
+ el.hidden = hidden
+ if (hidden) {
+ el.setAttribute("tabindex", "-1")
+ el.blur()
+ } else {
+ el.removeAttribute("tabindex")
+ }
+ },
+
+ /* Handle complete */
+ complete() {
+ el.style.top = ""
+ el.hidden = true
+ el.removeAttribute("tabindex")
+ }
+ })
+
+ /* Watch header height */
+ header$
+ .pipe(
+ takeUntil(done$),
+ distinctUntilKeyChanged("height")
+ )
+ .subscribe(({ height }) => {
+ el.style.top = `${height + 16}px`
+ })
+
+ /* Go back to top */
+ fromEvent(el, "click")
+ .subscribe(ev => {
+ ev.preventDefault()
+ window.scrollTo({ top: 0 })
+ })
+
+ /* Create and return component */
+ return watchBackToTop(el, { viewport$, main$, target$ })
+ .pipe(
+ tap(state => push$.next(state)),
+ finalize(() => push$.complete()),
+ map(state => ({ ref: el, ...state }))
+ )
+}