aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/docs/src/templates/assets/javascripts
diff options
context:
space:
mode:
author简律纯 <i@jyunko.cn>2023-12-15 09:11:47 +0800
committer简律纯 <i@jyunko.cn>2023-12-15 09:11:47 +0800
commitbe8de118db913711eb72ae5187d26e54a0055727 (patch)
tree96cd6c012dafa3f4015e54edef90df5eaaab0ddb /docs/src/templates/assets/javascripts
parent9b2d27ba1d91a0d5531bc9c0d52c3887a2dfb2aa (diff)
downloadinfini-be8de118db913711eb72ae5187d26e54a0055727.tar.gz
infini-be8de118db913711eb72ae5187d26e54a0055727.zip
refactor(docs): optmst `docs` dir & `deps`
Diffstat (limited to 'docs/src/templates/assets/javascripts')
-rw-r--r--docs/src/templates/assets/javascripts/_/index.ts148
-rw-r--r--docs/src/templates/assets/javascripts/browser/document/index.ts48
-rw-r--r--docs/src/templates/assets/javascripts/browser/element/_/.eslintrc6
-rw-r--r--docs/src/templates/assets/javascripts/browser/element/_/index.ts120
-rw-r--r--docs/src/templates/assets/javascripts/browser/element/focus/index.ts81
-rw-r--r--docs/src/templates/assets/javascripts/browser/element/index.ts27
-rw-r--r--docs/src/templates/assets/javascripts/browser/element/offset/_/index.ts86
-rw-r--r--docs/src/templates/assets/javascripts/browser/element/offset/content/index.ts76
-rw-r--r--docs/src/templates/assets/javascripts/browser/element/offset/index.ts24
-rw-r--r--docs/src/templates/assets/javascripts/browser/element/size/_/index.ts151
-rw-r--r--docs/src/templates/assets/javascripts/browser/element/size/content/index.ts67
-rw-r--r--docs/src/templates/assets/javascripts/browser/element/size/index.ts24
-rw-r--r--docs/src/templates/assets/javascripts/browser/element/visibility/index.ts131
-rw-r--r--docs/src/templates/assets/javascripts/browser/index.ts32
-rw-r--r--docs/src/templates/assets/javascripts/browser/keyboard/index.ts148
-rw-r--r--docs/src/templates/assets/javascripts/browser/location/_/index.ts85
-rw-r--r--docs/src/templates/assets/javascripts/browser/location/hash/index.ts104
-rw-r--r--docs/src/templates/assets/javascripts/browser/location/index.ts24
-rw-r--r--docs/src/templates/assets/javascripts/browser/media/index.ts95
-rw-r--r--docs/src/templates/assets/javascripts/browser/request/index.ts141
-rw-r--r--docs/src/templates/assets/javascripts/browser/script/index.ts70
-rw-r--r--docs/src/templates/assets/javascripts/browser/toggle/index.ts102
-rw-r--r--docs/src/templates/assets/javascripts/browser/viewport/_/index.ts69
-rw-r--r--docs/src/templates/assets/javascripts/browser/viewport/at/index.ts84
-rw-r--r--docs/src/templates/assets/javascripts/browser/viewport/index.ts26
-rw-r--r--docs/src/templates/assets/javascripts/browser/viewport/offset/index.ts78
-rw-r--r--docs/src/templates/assets/javascripts/browser/viewport/size/index.ts71
-rw-r--r--docs/src/templates/assets/javascripts/browser/worker/index.ts112
-rw-r--r--docs/src/templates/assets/javascripts/bundle.ts316
-rw-r--r--docs/src/templates/assets/javascripts/components/_/index.ts138
-rw-r--r--docs/src/templates/assets/javascripts/components/announce/index.ts110
-rw-r--r--docs/src/templates/assets/javascripts/components/consent/index.ts116
-rw-r--r--docs/src/templates/assets/javascripts/components/content/_/index.ts125
-rw-r--r--docs/src/templates/assets/javascripts/components/content/annotation/_/index.ts272
-rw-r--r--docs/src/templates/assets/javascripts/components/content/annotation/block/index.ts88
-rw-r--r--docs/src/templates/assets/javascripts/components/content/annotation/index.ts25
-rw-r--r--docs/src/templates/assets/javascripts/components/content/annotation/list/index.ts209
-rw-r--r--docs/src/templates/assets/javascripts/components/content/code/_/index.ts238
-rw-r--r--docs/src/templates/assets/javascripts/components/content/code/index.ts23
-rw-r--r--docs/src/templates/assets/javascripts/components/content/details/index.ts138
-rw-r--r--docs/src/templates/assets/javascripts/components/content/index.ts28
-rw-r--r--docs/src/templates/assets/javascripts/components/content/mermaid/index.css430
-rw-r--r--docs/src/templates/assets/javascripts/components/content/mermaid/index.ts133
-rw-r--r--docs/src/templates/assets/javascripts/components/content/table/index.ts70
-rw-r--r--docs/src/templates/assets/javascripts/components/content/tabs/index.ts265
-rw-r--r--docs/src/templates/assets/javascripts/components/dialog/index.ts128
-rw-r--r--docs/src/templates/assets/javascripts/components/header/_/index.ts200
-rw-r--r--docs/src/templates/assets/javascripts/components/header/index.ts24
-rw-r--r--docs/src/templates/assets/javascripts/components/header/title/index.ts144
-rw-r--r--docs/src/templates/assets/javascripts/components/index.ts37
-rw-r--r--docs/src/templates/assets/javascripts/components/main/index.ts125
-rw-r--r--docs/src/templates/assets/javascripts/components/palette/index.ts180
-rw-r--r--docs/src/templates/assets/javascripts/components/progress/index.ts87
-rw-r--r--docs/src/templates/assets/javascripts/components/search/_/index.ts239
-rw-r--r--docs/src/templates/assets/javascripts/components/search/highlight/.eslintrc5
-rw-r--r--docs/src/templates/assets/javascripts/components/search/highlight/index.ts115
-rw-r--r--docs/src/templates/assets/javascripts/components/search/index.ts28
-rw-r--r--docs/src/templates/assets/javascripts/components/search/query/index.ts206
-rw-r--r--docs/src/templates/assets/javascripts/components/search/result/index.ts197
-rw-r--r--docs/src/templates/assets/javascripts/components/search/share/index.ts135
-rw-r--r--docs/src/templates/assets/javascripts/components/search/suggest/index.ts154
-rw-r--r--docs/src/templates/assets/javascripts/components/sidebar/index.ts227
-rw-r--r--docs/src/templates/assets/javascripts/components/source/_/index.ts142
-rw-r--r--docs/src/templates/assets/javascripts/components/source/facts/_/index.ts88
-rw-r--r--docs/src/templates/assets/javascripts/components/source/facts/github/index.ts103
-rw-r--r--docs/src/templates/assets/javascripts/components/source/facts/gitlab/index.ts61
-rw-r--r--docs/src/templates/assets/javascripts/components/source/facts/index.ts25
-rw-r--r--docs/src/templates/assets/javascripts/components/source/index.ts24
-rw-r--r--docs/src/templates/assets/javascripts/components/tabs/index.ts144
-rw-r--r--docs/src/templates/assets/javascripts/components/toc/index.ts379
-rw-r--r--docs/src/templates/assets/javascripts/components/top/index.ts184
-rw-r--r--docs/src/templates/assets/javascripts/integrations/clipboard/index.ts99
-rw-r--r--docs/src/templates/assets/javascripts/integrations/index.ts27
-rw-r--r--docs/src/templates/assets/javascripts/integrations/instant/.eslintrc6
-rw-r--r--docs/src/templates/assets/javascripts/integrations/instant/index.ts446
-rw-r--r--docs/src/templates/assets/javascripts/integrations/search/_/index.ts332
-rw-r--r--docs/src/templates/assets/javascripts/integrations/search/config/index.ts115
-rw-r--r--docs/src/templates/assets/javascripts/integrations/search/highlighter/index.ts93
-rw-r--r--docs/src/templates/assets/javascripts/integrations/search/index.ts27
-rw-r--r--docs/src/templates/assets/javascripts/integrations/search/internal/.eslintrc6
-rw-r--r--docs/src/templates/assets/javascripts/integrations/search/internal/_/index.ts74
-rw-r--r--docs/src/templates/assets/javascripts/integrations/search/internal/extract/index.ts107
-rw-r--r--docs/src/templates/assets/javascripts/integrations/search/internal/highlight/index.ts162
-rw-r--r--docs/src/templates/assets/javascripts/integrations/search/internal/index.ts26
-rw-r--r--docs/src/templates/assets/javascripts/integrations/search/internal/tokenize/index.ts136
-rw-r--r--docs/src/templates/assets/javascripts/integrations/search/query/.eslintrc6
-rw-r--r--docs/src/templates/assets/javascripts/integrations/search/query/_/index.ts172
-rw-r--r--docs/src/templates/assets/javascripts/integrations/search/query/index.ts25
-rw-r--r--docs/src/templates/assets/javascripts/integrations/search/query/segment/index.ts81
-rw-r--r--docs/src/templates/assets/javascripts/integrations/search/query/transform/index.ts99
-rw-r--r--docs/src/templates/assets/javascripts/integrations/search/worker/_/index.ts95
-rw-r--r--docs/src/templates/assets/javascripts/integrations/search/worker/index.ts24
-rw-r--r--docs/src/templates/assets/javascripts/integrations/search/worker/main/.eslintrc6
-rw-r--r--docs/src/templates/assets/javascripts/integrations/search/worker/main/index.ts192
-rw-r--r--docs/src/templates/assets/javascripts/integrations/search/worker/message/index.ts112
-rw-r--r--docs/src/templates/assets/javascripts/integrations/sitemap/index.ts107
-rw-r--r--docs/src/templates/assets/javascripts/integrations/version/.eslintrc5
-rw-r--r--docs/src/templates/assets/javascripts/integrations/version/index.ts186
-rw-r--r--docs/src/templates/assets/javascripts/patches/indeterminate/index.ts85
-rw-r--r--docs/src/templates/assets/javascripts/patches/index.ts25
-rw-r--r--docs/src/templates/assets/javascripts/patches/scrollfix/index.ts100
-rw-r--r--docs/src/templates/assets/javascripts/patches/scrolllock/index.ts89
-rw-r--r--docs/src/templates/assets/javascripts/polyfills/index.ts96
-rw-r--r--docs/src/templates/assets/javascripts/templates/annotation/index.tsx65
-rw-r--r--docs/src/templates/assets/javascripts/templates/clipboard/index.tsx45
-rw-r--r--docs/src/templates/assets/javascripts/templates/index.ts29
-rw-r--r--docs/src/templates/assets/javascripts/templates/search/index.tsx170
-rw-r--r--docs/src/templates/assets/javascripts/templates/source/index.tsx47
-rw-r--r--docs/src/templates/assets/javascripts/templates/tabbed/index.tsx56
-rw-r--r--docs/src/templates/assets/javascripts/templates/table/index.tsx44
-rw-r--r--docs/src/templates/assets/javascripts/templates/tooltip/index.tsx42
-rw-r--r--docs/src/templates/assets/javascripts/templates/version/index.tsx92
-rw-r--r--docs/src/templates/assets/javascripts/utilities/h/.eslintrc7
-rw-r--r--docs/src/templates/assets/javascripts/utilities/h/index.ts132
-rw-r--r--docs/src/templates/assets/javascripts/utilities/index.ts24
-rw-r--r--docs/src/templates/assets/javascripts/utilities/round/index.ts50
-rw-r--r--docs/src/templates/assets/javascripts/workers/search.ts23
117 files changed, 12322 insertions, 0 deletions
diff --git a/docs/src/templates/assets/javascripts/_/index.ts b/docs/src/templates/assets/javascripts/_/index.ts
new file mode 100644
index 00000000..be0f4a42
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/_/index.ts
@@ -0,0 +1,148 @@
+/*
+ * 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, getLocation } from "~/browser"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Feature flag
+ */
+export type Flag =
+ | "announce.dismiss" /* Dismissable announcement bar */
+ | "content.code.annotate" /* Code annotations */
+ | "content.code.copy" /* Code copy button */
+ | "content.lazy" /* Lazy content elements */
+ | "content.tabs.link" /* Link content tabs */
+ | "header.autohide" /* Hide header */
+ | "navigation.expand" /* Automatic expansion */
+ | "navigation.indexes" /* Section pages */
+ | "navigation.instant" /* Instant navigation */
+ | "navigation.instant.progress" /* Instant navigation progress */
+ | "navigation.sections" /* Section navigation */
+ | "navigation.tabs" /* Tabs navigation */
+ | "navigation.tabs.sticky" /* Tabs navigation (sticky) */
+ | "navigation.top" /* Back-to-top button */
+ | "navigation.tracking" /* Anchor tracking */
+ | "search.highlight" /* Search highlighting */
+ | "search.share" /* Search sharing */
+ | "search.suggest" /* Search suggestions */
+ | "toc.follow" /* Following table of contents */
+ | "toc.integrate" /* Integrated table of contents */
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Translation
+ */
+export type Translation =
+ | "clipboard.copy" /* Copy to clipboard */
+ | "clipboard.copied" /* Copied to clipboard */
+ | "search.result.placeholder" /* Type to start searching */
+ | "search.result.none" /* No matching documents */
+ | "search.result.one" /* 1 matching document */
+ | "search.result.other" /* # matching documents */
+ | "search.result.more.one" /* 1 more on this page */
+ | "search.result.more.other" /* # more on this page */
+ | "search.result.term.missing" /* Missing */
+ | "select.version" /* Version selector */
+
+/**
+ * Translations
+ */
+export type Translations =
+ Record<Translation, string>
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Versioning
+ */
+export interface Versioning {
+ provider: "mike" /* Version provider */
+ default?: string | string[] /* Default version */
+}
+
+/**
+ * Configuration
+ */
+export interface Config {
+ base: string /* Base URL */
+ features: Flag[] /* Feature flags */
+ translations: Translations /* Translations */
+ search: string /* Search worker URL */
+ tags?: Record<string, string> /* Tags mapping */
+ version?: Versioning /* Versioning */
+}
+
+/* ----------------------------------------------------------------------------
+ * Data
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Retrieve global configuration and make base URL absolute
+ */
+const script = getElement("#__config")
+const config: Config = JSON.parse(script.textContent!)
+config.base = `${new URL(config.base, getLocation())}`
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Retrieve global configuration
+ *
+ * @returns Global configuration
+ */
+export function configuration(): Config {
+ return config
+}
+
+/**
+ * Check whether a feature flag is enabled
+ *
+ * @param flag - Feature flag
+ *
+ * @returns Test result
+ */
+export function feature(flag: Flag): boolean {
+ return config.features.includes(flag)
+}
+
+/**
+ * Retrieve the translation for the given key
+ *
+ * @param key - Key to be translated
+ * @param value - Positional value, if any
+ *
+ * @returns Translation
+ */
+export function translation(
+ key: Translation, value?: string | number
+): string {
+ return typeof value !== "undefined"
+ ? config.translations[key].replace("#", value.toString())
+ : config.translations[key]
+}
diff --git a/docs/src/templates/assets/javascripts/browser/document/index.ts b/docs/src/templates/assets/javascripts/browser/document/index.ts
new file mode 100644
index 00000000..354c9b5c
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/document/index.ts
@@ -0,0 +1,48 @@
+/*
+ * 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 {
+ ReplaySubject,
+ Subject,
+ fromEvent
+} from "rxjs"
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch document
+ *
+ * Documents are implemented as subjects, so all downstream observables are
+ * automatically updated when a new document is emitted.
+ *
+ * @returns Document subject
+ */
+export function watchDocument(): Subject<Document> {
+ const document$ = new ReplaySubject<Document>(1)
+ fromEvent(document, "DOMContentLoaded", { once: true })
+ .subscribe(() => document$.next(document))
+
+ /* Return document */
+ return document$
+}
diff --git a/docs/src/templates/assets/javascripts/browser/element/_/.eslintrc b/docs/src/templates/assets/javascripts/browser/element/_/.eslintrc
new file mode 100644
index 00000000..16973760
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/element/_/.eslintrc
@@ -0,0 +1,6 @@
+{
+ "rules": {
+ "jsdoc/require-jsdoc": "off",
+ "jsdoc/require-returns-check": "off"
+ }
+}
diff --git a/docs/src/templates/assets/javascripts/browser/element/_/index.ts b/docs/src/templates/assets/javascripts/browser/element/_/index.ts
new file mode 100644
index 00000000..b7beb462
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/element/_/index.ts
@@ -0,0 +1,120 @@
+/*
+ * 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.
+ */
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Retrieve all elements matching the query selector
+ *
+ * @template T - Element type
+ *
+ * @param selector - Query selector
+ * @param node - Node of reference
+ *
+ * @returns Elements
+ */
+export function getElements<T extends keyof HTMLElementTagNameMap>(
+ selector: T, node?: ParentNode
+): HTMLElementTagNameMap[T][]
+
+export function getElements<T extends HTMLElement>(
+ selector: string, node?: ParentNode
+): T[]
+
+export function getElements<T extends HTMLElement>(
+ selector: string, node: ParentNode = document
+): T[] {
+ return Array.from(node.querySelectorAll<T>(selector))
+}
+
+/**
+ * Retrieve an element matching a query selector or throw a reference error
+ *
+ * Note that this function assumes that the element is present. If unsure if an
+ * element is existent, use the `getOptionalElement` function instead.
+ *
+ * @template T - Element type
+ *
+ * @param selector - Query selector
+ * @param node - Node of reference
+ *
+ * @returns Element
+ */
+export function getElement<T extends keyof HTMLElementTagNameMap>(
+ selector: T, node?: ParentNode
+): HTMLElementTagNameMap[T]
+
+export function getElement<T extends HTMLElement>(
+ selector: string, node?: ParentNode
+): T
+
+export function getElement<T extends HTMLElement>(
+ selector: string, node: ParentNode = document
+): T {
+ const el = getOptionalElement<T>(selector, node)
+ if (typeof el === "undefined")
+ throw new ReferenceError(
+ `Missing element: expected "${selector}" to be present`
+ )
+
+ /* Return element */
+ return el
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Retrieve an optional element matching the query selector
+ *
+ * @template T - Element type
+ *
+ * @param selector - Query selector
+ * @param node - Node of reference
+ *
+ * @returns Element or nothing
+ */
+export function getOptionalElement<T extends keyof HTMLElementTagNameMap>(
+ selector: T, node?: ParentNode
+): HTMLElementTagNameMap[T] | undefined
+
+export function getOptionalElement<T extends HTMLElement>(
+ selector: string, node?: ParentNode
+): T | undefined
+
+export function getOptionalElement<T extends HTMLElement>(
+ selector: string, node: ParentNode = document
+): T | undefined {
+ return node.querySelector<T>(selector) || undefined
+}
+
+/**
+ * Retrieve the currently active element
+ *
+ * @returns Element or nothing
+ */
+export function getActiveElement(): HTMLElement | undefined {
+ return document.activeElement instanceof HTMLElement
+ ? document.activeElement || undefined
+ : undefined
+}
diff --git a/docs/src/templates/assets/javascripts/browser/element/focus/index.ts b/docs/src/templates/assets/javascripts/browser/element/focus/index.ts
new file mode 100644
index 00000000..f31fe276
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/element/focus/index.ts
@@ -0,0 +1,81 @@
+/*
+ * 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,
+ debounceTime,
+ distinctUntilChanged,
+ fromEvent,
+ map,
+ merge,
+ shareReplay,
+ startWith
+} from "rxjs"
+
+import { getActiveElement } from "../_"
+
+/* ----------------------------------------------------------------------------
+ * Data
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Focus observable
+ *
+ * Previously, this observer used `focus` and `blur` events to determine whether
+ * an element is focused, but this doesn't work if there are focusable elements
+ * within the elements itself. A better solutions are `focusin` and `focusout`
+ * events, which bubble up the tree and allow for more fine-grained control.
+ *
+ * `debounceTime` is necessary, because when a focus change happens inside an
+ * element, the observable would first emit `false` and then `true` again.
+ */
+const observer$ = merge(
+ fromEvent(document.body, "focusin"),
+ fromEvent(document.body, "focusout")
+)
+ .pipe(
+ debounceTime(1),
+ startWith(undefined),
+ map(() => getActiveElement() || document.body),
+ shareReplay(1)
+ )
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch element focus
+ *
+ * @param el - Element
+ *
+ * @returns Element focus observable
+ */
+export function watchElementFocus(
+ el: HTMLElement
+): Observable<boolean> {
+ return observer$
+ .pipe(
+ map(active => el.contains(active)),
+ distinctUntilChanged()
+ )
+}
diff --git a/docs/src/templates/assets/javascripts/browser/element/index.ts b/docs/src/templates/assets/javascripts/browser/element/index.ts
new file mode 100644
index 00000000..50ce84b2
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/element/index.ts
@@ -0,0 +1,27 @@
+/*
+ * 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 "./focus"
+export * from "./offset"
+export * from "./size"
+export * from "./visibility"
diff --git a/docs/src/templates/assets/javascripts/browser/element/offset/_/index.ts b/docs/src/templates/assets/javascripts/browser/element/offset/_/index.ts
new file mode 100644
index 00000000..6dd229d5
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/element/offset/_/index.ts
@@ -0,0 +1,86 @@
+/*
+ * 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,
+ animationFrameScheduler,
+ auditTime,
+ fromEvent,
+ map,
+ merge,
+ startWith
+} from "rxjs"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Element offset
+ */
+export interface ElementOffset {
+ x: number /* Horizontal offset */
+ y: number /* Vertical offset */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Retrieve element offset
+ *
+ * @param el - Element
+ *
+ * @returns Element offset
+ */
+export function getElementOffset(
+ el: HTMLElement
+): ElementOffset {
+ return {
+ x: el.offsetLeft,
+ y: el.offsetTop
+ }
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Watch element offset
+ *
+ * @param el - Element
+ *
+ * @returns Element offset observable
+ */
+export function watchElementOffset(
+ el: HTMLElement
+): Observable<ElementOffset> {
+ return merge(
+ fromEvent(window, "load"),
+ fromEvent(window, "resize")
+ )
+ .pipe(
+ auditTime(0, animationFrameScheduler),
+ map(() => getElementOffset(el)),
+ startWith(getElementOffset(el))
+ )
+}
diff --git a/docs/src/templates/assets/javascripts/browser/element/offset/content/index.ts b/docs/src/templates/assets/javascripts/browser/element/offset/content/index.ts
new file mode 100644
index 00000000..557301a6
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/element/offset/content/index.ts
@@ -0,0 +1,76 @@
+/*
+ * 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,
+ animationFrameScheduler,
+ auditTime,
+ fromEvent,
+ map,
+ merge,
+ startWith
+} from "rxjs"
+
+import { ElementOffset } from "../_"
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Retrieve element content offset (= scroll offset)
+ *
+ * @param el - Element
+ *
+ * @returns Element content offset
+ */
+export function getElementContentOffset(
+ el: HTMLElement
+): ElementOffset {
+ return {
+ x: el.scrollLeft,
+ y: el.scrollTop
+ }
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Watch element content offset
+ *
+ * @param el - Element
+ *
+ * @returns Element content offset observable
+ */
+export function watchElementContentOffset(
+ el: HTMLElement
+): Observable<ElementOffset> {
+ return merge(
+ fromEvent(el, "scroll"),
+ fromEvent(window, "resize")
+ )
+ .pipe(
+ auditTime(0, animationFrameScheduler),
+ map(() => getElementContentOffset(el)),
+ startWith(getElementContentOffset(el))
+ )
+}
diff --git a/docs/src/templates/assets/javascripts/browser/element/offset/index.ts b/docs/src/templates/assets/javascripts/browser/element/offset/index.ts
new file mode 100644
index 00000000..602ff2cf
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/element/offset/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 "./content"
diff --git a/docs/src/templates/assets/javascripts/browser/element/size/_/index.ts b/docs/src/templates/assets/javascripts/browser/element/size/_/index.ts
new file mode 100644
index 00000000..35a5e68b
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/element/size/_/index.ts
@@ -0,0 +1,151 @@
+/*
+ * 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,
+ Subject,
+ defer,
+ filter,
+ finalize,
+ map,
+ merge,
+ of,
+ shareReplay,
+ startWith,
+ switchMap,
+ tap
+} from "rxjs"
+
+import { watchScript } from "../../../script"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Element offset
+ */
+export interface ElementSize {
+ width: number /* Element width */
+ height: number /* Element height */
+}
+
+/* ----------------------------------------------------------------------------
+ * Data
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Resize observer entry subject
+ */
+const entry$ = new Subject<ResizeObserverEntry>()
+
+/**
+ * Resize observer observable
+ *
+ * This observable will create a `ResizeObserver` on the first subscription
+ * and will automatically terminate it when there are no more subscribers.
+ * It's quite important to centralize observation in a single `ResizeObserver`,
+ * as the performance difference can be quite dramatic, as the link shows.
+ *
+ * If the browser doesn't have a `ResizeObserver` implementation available, a
+ * polyfill is automatically downloaded from unpkg.com. This is also compatible
+ * with the built-in privacy plugin, which will download the polyfill and put
+ * it alongside the built site for self-hosting.
+ *
+ * @see https://bit.ly/3iIYfEm - Google Groups on performance
+ */
+const observer$ = defer(() => (
+ typeof ResizeObserver === "undefined"
+ ? watchScript("https://unpkg.com/resize-observer-polyfill")
+ : of(undefined)
+))
+ .pipe(
+ map(() => new ResizeObserver(entries => {
+ for (const entry of entries)
+ entry$.next(entry)
+ })),
+ switchMap(observer => merge(NEVER, of(observer))
+ .pipe(
+ finalize(() => observer.disconnect())
+ )
+ ),
+ shareReplay(1)
+ )
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Retrieve element size
+ *
+ * @param el - Element
+ *
+ * @returns Element size
+ */
+export function getElementSize(
+ el: HTMLElement
+): ElementSize {
+ return {
+ width: el.offsetWidth,
+ height: el.offsetHeight
+ }
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Watch element size
+ *
+ * This function returns an observable that subscribes to a single internal
+ * instance of `ResizeObserver` upon subscription, and emit resize events until
+ * termination. Note that this function should not be called with the same
+ * element twice, as the first unsubscription will terminate observation.
+ *
+ * Sadly, we can't use the `DOMRect` objects returned by the observer, because
+ * we need the emitted values to be consistent with `getElementSize`, which will
+ * return the used values (rounded) and not actual values (unrounded). Thus, we
+ * use the `offset*` properties. See the linked GitHub issue.
+ *
+ * @see https://bit.ly/3m0k3he - GitHub issue
+ *
+ * @param el - Element
+ *
+ * @returns Element size observable
+ */
+export function watchElementSize(
+ el: HTMLElement
+): Observable<ElementSize> {
+ return observer$
+ .pipe(
+ tap(observer => observer.observe(el)),
+ switchMap(observer => entry$
+ .pipe(
+ filter(({ target }) => target === el),
+ finalize(() => observer.unobserve(el)),
+ map(() => getElementSize(el))
+ )
+ ),
+ startWith(getElementSize(el))
+ )
+}
diff --git a/docs/src/templates/assets/javascripts/browser/element/size/content/index.ts b/docs/src/templates/assets/javascripts/browser/element/size/content/index.ts
new file mode 100644
index 00000000..5ed388cf
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/element/size/content/index.ts
@@ -0,0 +1,67 @@
+/*
+ * 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 { ElementSize } from "../_"
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Retrieve element content size (= scroll width and height)
+ *
+ * @param el - Element
+ *
+ * @returns Element content size
+ */
+export function getElementContentSize(
+ el: HTMLElement
+): ElementSize {
+ return {
+ width: el.scrollWidth,
+ height: el.scrollHeight
+ }
+}
+
+/**
+ * Retrieve the overflowing container of an element, if any
+ *
+ * @param el - Element
+ *
+ * @returns Overflowing container or nothing
+ */
+export function getElementContainer(
+ el: HTMLElement
+): HTMLElement | undefined {
+ let parent = el.parentElement
+ while (parent)
+ if (
+ el.scrollWidth <= parent.scrollWidth &&
+ el.scrollHeight <= parent.scrollHeight
+ )
+ parent = (el = parent).parentElement
+ else
+ break
+
+ /* Return overflowing container */
+ return parent ? el : undefined
+}
diff --git a/docs/src/templates/assets/javascripts/browser/element/size/index.ts b/docs/src/templates/assets/javascripts/browser/element/size/index.ts
new file mode 100644
index 00000000..602ff2cf
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/element/size/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 "./content"
diff --git a/docs/src/templates/assets/javascripts/browser/element/visibility/index.ts b/docs/src/templates/assets/javascripts/browser/element/visibility/index.ts
new file mode 100644
index 00000000..1ffe0b8d
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/element/visibility/index.ts
@@ -0,0 +1,131 @@
+/*
+ * 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,
+ Subject,
+ defer,
+ distinctUntilChanged,
+ filter,
+ finalize,
+ map,
+ merge,
+ of,
+ shareReplay,
+ switchMap,
+ tap
+} from "rxjs"
+
+import {
+ getElementContentSize,
+ getElementSize,
+ watchElementContentOffset
+} from "~/browser"
+
+/* ----------------------------------------------------------------------------
+ * Data
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Intersection observer entry subject
+ */
+const entry$ = new Subject<IntersectionObserverEntry>()
+
+/**
+ * Intersection observer observable
+ *
+ * This observable will create an `IntersectionObserver` on first subscription
+ * and will automatically terminate it when there are no more subscribers.
+ *
+ * @see https://bit.ly/3iIYfEm - Google Groups on performance
+ */
+const observer$ = defer(() => of(
+ new IntersectionObserver(entries => {
+ for (const entry of entries)
+ entry$.next(entry)
+ }, {
+ threshold: 0
+ })
+))
+ .pipe(
+ switchMap(observer => merge(NEVER, of(observer))
+ .pipe(
+ finalize(() => observer.disconnect())
+ )
+ ),
+ shareReplay(1)
+ )
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch element visibility
+ *
+ * @param el - Element
+ *
+ * @returns Element visibility observable
+ */
+export function watchElementVisibility(
+ el: HTMLElement
+): Observable<boolean> {
+ return observer$
+ .pipe(
+ tap(observer => observer.observe(el)),
+ switchMap(observer => entry$
+ .pipe(
+ filter(({ target }) => target === el),
+ finalize(() => observer.unobserve(el)),
+ map(({ isIntersecting }) => isIntersecting)
+ )
+ )
+ )
+}
+
+/**
+ * Watch element boundary
+ *
+ * This function returns an observable which emits whether the bottom content
+ * boundary (= scroll offset) of an element is within a certain threshold.
+ *
+ * @param el - Element
+ * @param threshold - Threshold
+ *
+ * @returns Element boundary observable
+ */
+export function watchElementBoundary(
+ el: HTMLElement, threshold = 16
+): Observable<boolean> {
+ return watchElementContentOffset(el)
+ .pipe(
+ map(({ y }) => {
+ const visible = getElementSize(el)
+ const content = getElementContentSize(el)
+ return y >= (
+ content.height - visible.height - threshold
+ )
+ }),
+ distinctUntilChanged()
+ )
+}
diff --git a/docs/src/templates/assets/javascripts/browser/index.ts b/docs/src/templates/assets/javascripts/browser/index.ts
new file mode 100644
index 00000000..f1ee2bae
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/index.ts
@@ -0,0 +1,32 @@
+/*
+ * 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 "./document"
+export * from "./element"
+export * from "./keyboard"
+export * from "./location"
+export * from "./media"
+export * from "./request"
+export * from "./script"
+export * from "./toggle"
+export * from "./viewport"
+export * from "./worker"
diff --git a/docs/src/templates/assets/javascripts/browser/keyboard/index.ts b/docs/src/templates/assets/javascripts/browser/keyboard/index.ts
new file mode 100644
index 00000000..783f2cda
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/keyboard/index.ts
@@ -0,0 +1,148 @@
+/*
+ * 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,
+ filter,
+ fromEvent,
+ map,
+ merge,
+ share,
+ startWith,
+ switchMap
+} from "rxjs"
+
+import { getActiveElement } from "../element"
+import { getToggle } from "../toggle"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Keyboard mode
+ */
+export type KeyboardMode =
+ | "global" /* Global */
+ | "search" /* Search is open */
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Keyboard
+ */
+export interface Keyboard {
+ mode: KeyboardMode /* Keyboard mode */
+ type: string /* Key type */
+ claim(): void /* Key claim */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Check whether an element may receive keyboard input
+ *
+ * @param el - Element
+ * @param type - Key type
+ *
+ * @returns Test result
+ */
+function isSusceptibleToKeyboard(
+ el: HTMLElement, type: string
+): boolean {
+ switch (el.constructor) {
+
+ /* Input elements */
+ case HTMLInputElement:
+ /* @ts-expect-error - omit unnecessary type cast */
+ if (el.type === "radio")
+ return /^Arrow/.test(type)
+ else
+ return true
+
+ /* Select element and textarea */
+ case HTMLSelectElement:
+ case HTMLTextAreaElement:
+ return true
+
+ /* Everything else */
+ default:
+ return el.isContentEditable
+ }
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch composition events
+ *
+ * @returns Composition observable
+ */
+export function watchComposition(): Observable<boolean> {
+ return merge(
+ fromEvent(window, "compositionstart").pipe(map(() => true)),
+ fromEvent(window, "compositionend").pipe(map(() => false))
+ )
+ .pipe(
+ startWith(false)
+ )
+}
+
+/**
+ * Watch keyboard
+ *
+ * @returns Keyboard observable
+ */
+export function watchKeyboard(): Observable<Keyboard> {
+ const keyboard$ = fromEvent<KeyboardEvent>(window, "keydown")
+ .pipe(
+ filter(ev => !(ev.metaKey || ev.ctrlKey)),
+ map(ev => ({
+ mode: getToggle("search") ? "search" : "global",
+ type: ev.key,
+ claim() {
+ ev.preventDefault()
+ ev.stopPropagation()
+ }
+ } as Keyboard)),
+ filter(({ mode, type }) => {
+ if (mode === "global") {
+ const active = getActiveElement()
+ if (typeof active !== "undefined")
+ return !isSusceptibleToKeyboard(active, type)
+ }
+ return true
+ }),
+ share()
+ )
+
+ /* Don't emit during composition events - see https://bit.ly/3te3Wl8 */
+ return watchComposition()
+ .pipe(
+ switchMap(active => !active ? keyboard$ : EMPTY)
+ )
+}
diff --git a/docs/src/templates/assets/javascripts/browser/location/_/index.ts b/docs/src/templates/assets/javascripts/browser/location/_/index.ts
new file mode 100644
index 00000000..2672fa74
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/location/_/index.ts
@@ -0,0 +1,85 @@
+/*
+ * 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 { Subject } from "rxjs"
+
+import { feature } from "~/_"
+import { h } from "~/utilities"
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Retrieve location
+ *
+ * This function returns a `URL` object (and not `Location`) to normalize the
+ * typings across the application. Furthermore, locations need to be tracked
+ * without setting them and `Location` is a singleton which represents the
+ * current location.
+ *
+ * @returns URL
+ */
+export function getLocation(): URL {
+ return new URL(location.href)
+}
+
+/**
+ * Set location
+ *
+ * If instant navigation is enabled, this function creates a temporary anchor
+ * element, sets the `href` attribute, appends it to the body, clicks it, and
+ * then removes it again. The event will bubble up the DOM and trigger be
+ * intercepted by the instant loading business logic.
+ *
+ * Note that we must append and remove the anchor element, or the event will
+ * not bubble up the DOM, making it impossible to intercept it.
+ *
+ * @param url - URL to navigate to
+ * @param navigate - Force navigation
+ */
+export function setLocation(
+ url: URL | HTMLLinkElement, navigate = false
+): void {
+ if (feature("navigation.instant") && !navigate) {
+ const el = h("a", { href: url.href })
+ document.body.appendChild(el)
+ el.click()
+ el.remove()
+
+ // If we're not using instant navigation, and the page should not be reloaded
+ // just instruct the browser to navigate to the given URL
+ } else {
+ location.href = url.href
+ }
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Watch location
+ *
+ * @returns Location subject
+ */
+export function watchLocation(): Subject<URL> {
+ return new Subject<URL>()
+}
diff --git a/docs/src/templates/assets/javascripts/browser/location/hash/index.ts b/docs/src/templates/assets/javascripts/browser/location/hash/index.ts
new file mode 100644
index 00000000..5d3a134a
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/location/hash/index.ts
@@ -0,0 +1,104 @@
+/*
+ * 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,
+ filter,
+ fromEvent,
+ map,
+ merge,
+ shareReplay,
+ startWith
+} from "rxjs"
+
+import { getOptionalElement } from "~/browser"
+import { h } from "~/utilities"
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Retrieve location hash
+ *
+ * @returns Location hash
+ */
+export function getLocationHash(): string {
+ return location.hash.slice(1)
+}
+
+/**
+ * Set location hash
+ *
+ * Setting a new fragment identifier via `location.hash` will have no effect
+ * if the value doesn't change. When a new fragment identifier is set, we want
+ * the browser to target the respective element at all times, which is why we
+ * use this dirty little trick.
+ *
+ * @param hash - Location hash
+ */
+export function setLocationHash(hash: string): void {
+ const el = h("a", { href: hash })
+ el.addEventListener("click", ev => ev.stopPropagation())
+ el.click()
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Watch location hash
+ *
+ * @param location$ - Location observable
+ *
+ * @returns Location hash observable
+ */
+export function watchLocationHash(
+ location$: Observable<URL>
+): Observable<string> {
+ return merge(
+ fromEvent<HashChangeEvent>(window, "hashchange"),
+ location$
+ )
+ .pipe(
+ map(getLocationHash),
+ startWith(getLocationHash()),
+ filter(hash => hash.length > 0),
+ shareReplay(1)
+ )
+}
+
+/**
+ * Watch location target
+ *
+ * @param location$ - Location observable
+ *
+ * @returns Location target observable
+ */
+export function watchLocationTarget(
+ location$: Observable<URL>
+): Observable<HTMLElement> {
+ return watchLocationHash(location$)
+ .pipe(
+ map(id => getOptionalElement(`[id="${id}"]`)!),
+ filter(el => typeof el !== "undefined")
+ )
+}
diff --git a/docs/src/templates/assets/javascripts/browser/location/index.ts b/docs/src/templates/assets/javascripts/browser/location/index.ts
new file mode 100644
index 00000000..d77a5444
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/location/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 "./hash"
diff --git a/docs/src/templates/assets/javascripts/browser/media/index.ts b/docs/src/templates/assets/javascripts/browser/media/index.ts
new file mode 100644
index 00000000..dd7400d4
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/media/index.ts
@@ -0,0 +1,95 @@
+/*
+ * 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,
+ fromEvent,
+ fromEventPattern,
+ map,
+ merge,
+ startWith,
+ switchMap
+} from "rxjs"
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch media query
+ *
+ * Note that although `MediaQueryList.addListener` is deprecated we have to
+ * use it, because it's the only way to ensure proper downward compatibility.
+ *
+ * @see https://bit.ly/3dUBH2m - GitHub issue
+ *
+ * @param query - Media query
+ *
+ * @returns Media observable
+ */
+export function watchMedia(query: string): Observable<boolean> {
+ const media = matchMedia(query)
+ return fromEventPattern<boolean>(next => (
+ media.addListener(() => next(media.matches))
+ ))
+ .pipe(
+ startWith(media.matches)
+ )
+}
+
+/**
+ * Watch print mode
+ *
+ * @returns Print observable
+ */
+export function watchPrint(): Observable<boolean> {
+ const media = matchMedia("print")
+ return merge(
+ fromEvent(window, "beforeprint").pipe(map(() => true)),
+ fromEvent(window, "afterprint").pipe(map(() => false))
+ )
+ .pipe(
+ startWith(media.matches)
+ )
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Toggle an observable with a media observable
+ *
+ * @template T - Data type
+ *
+ * @param query$ - Media observable
+ * @param factory - Observable factory
+ *
+ * @returns Toggled observable
+ */
+export function at<T>(
+ query$: Observable<boolean>, factory: () => Observable<T>
+): Observable<T> {
+ return query$
+ .pipe(
+ switchMap(active => active ? factory() : EMPTY)
+ )
+}
diff --git a/docs/src/templates/assets/javascripts/browser/request/index.ts b/docs/src/templates/assets/javascripts/browser/request/index.ts
new file mode 100644
index 00000000..74a56a64
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/request/index.ts
@@ -0,0 +1,141 @@
+/*
+ * 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,
+ map,
+ shareReplay,
+ switchMap
+} from "rxjs"
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Options
+ */
+interface Options {
+ progress$?: Subject<number> // Progress subject
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Fetch the given URL
+ *
+ * If the request fails (e.g. when dispatched from `file://` locations), the
+ * observable will complete without emitting a value.
+ *
+ * @param url - Request URL
+ * @param options - Options
+ *
+ * @returns Response observable
+ */
+export function request(
+ url: URL | string, options?: Options
+): Observable<Blob> {
+ return new Observable<Blob>(observer => {
+ const req = new XMLHttpRequest()
+ req.open("GET", `${url}`)
+ req.responseType = "blob"
+
+ // Handle response
+ req.addEventListener("load", () => {
+ if (req.status >= 200 && req.status < 300) {
+ observer.next(req.response)
+ observer.complete()
+ } else {
+ observer.error(new Error(req.statusText))
+ }
+ })
+
+ // Handle network errors
+ req.addEventListener("error", () => {
+ observer.error(new Error("Network Error"))
+ })
+
+ // Handle aborted requests
+ req.addEventListener("abort", () => {
+ observer.error(new Error("Request aborted"))
+ })
+
+ // Handle download progress
+ if (typeof options?.progress$ !== "undefined") {
+ req.addEventListener("progress", event => {
+ options.progress$!.next((event.loaded / event.total) * 100)
+ })
+
+ // Immediately set progress to 5% to indicate that we're loading
+ options.progress$.next(5)
+ }
+
+ // Send request
+ req.send()
+ })
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Fetch JSON from the given URL
+ *
+ * @template T - Data type
+ *
+ * @param url - Request URL
+ * @param options - Options
+ *
+ * @returns Data observable
+ */
+export function requestJSON<T>(
+ url: URL | string, options?: Options
+): Observable<T> {
+ return request(url, options)
+ .pipe(
+ switchMap(res => res.text()),
+ map(body => JSON.parse(body) as T),
+ shareReplay(1)
+ )
+}
+
+/**
+ * Fetch XML from the given URL
+ *
+ * @param url - Request URL
+ * @param options - Options
+ *
+ * @returns Data observable
+ */
+export function requestXML(
+ url: URL | string, options?: Options
+): Observable<Document> {
+ const dom = new DOMParser()
+ return request(url, options)
+ .pipe(
+ switchMap(res => res.text()),
+ map(res => dom.parseFromString(res, "text/xml")),
+ shareReplay(1)
+ )
+}
diff --git a/docs/src/templates/assets/javascripts/browser/script/index.ts b/docs/src/templates/assets/javascripts/browser/script/index.ts
new file mode 100644
index 00000000..ef5c89e6
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/script/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,
+ defer,
+ finalize,
+ fromEvent,
+ map,
+ merge,
+ switchMap,
+ take,
+ throwError
+} from "rxjs"
+
+import { h } from "~/utilities"
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Create and load a `script` element
+ *
+ * This function returns an observable that will emit when the script was
+ * successfully loaded, or throw an error if it wasn't.
+ *
+ * @param src - Script URL
+ *
+ * @returns Script observable
+ */
+export function watchScript(src: string): Observable<void> {
+ const script = h("script", { src })
+ return defer(() => {
+ document.head.appendChild(script)
+ return merge(
+ fromEvent(script, "load"),
+ fromEvent(script, "error")
+ .pipe(
+ switchMap(() => (
+ throwError(() => new ReferenceError(`Invalid script: ${src}`))
+ ))
+ )
+ )
+ .pipe(
+ map(() => undefined),
+ finalize(() => document.head.removeChild(script)),
+ take(1)
+ )
+ })
+}
diff --git a/docs/src/templates/assets/javascripts/browser/toggle/index.ts b/docs/src/templates/assets/javascripts/browser/toggle/index.ts
new file mode 100644
index 00000000..0be4b29d
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/toggle/index.ts
@@ -0,0 +1,102 @@
+/*
+ * 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,
+ fromEvent,
+ map,
+ startWith
+} from "rxjs"
+
+import { getElement } from "../element"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Toggle
+ */
+export type Toggle =
+ | "drawer" /* Toggle for drawer */
+ | "search" /* Toggle for search */
+
+/* ----------------------------------------------------------------------------
+ * Data
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Toggle map
+ */
+const toggles: Record<Toggle, HTMLInputElement> = {
+ drawer: getElement("[data-md-toggle=drawer]"),
+ search: getElement("[data-md-toggle=search]")
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Retrieve the value of a toggle
+ *
+ * @param name - Toggle
+ *
+ * @returns Toggle value
+ */
+export function getToggle(name: Toggle): boolean {
+ return toggles[name].checked
+}
+
+/**
+ * Set toggle
+ *
+ * Simulating a click event seems to be the most cross-browser compatible way
+ * of changing the value while also emitting a `change` event. Before, Material
+ * used `CustomEvent` to programmatically change the value of a toggle, but this
+ * is a much simpler and cleaner solution which doesn't require a polyfill.
+ *
+ * @param name - Toggle
+ * @param value - Toggle value
+ */
+export function setToggle(name: Toggle, value: boolean): void {
+ if (toggles[name].checked !== value)
+ toggles[name].click()
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Watch toggle
+ *
+ * @param name - Toggle
+ *
+ * @returns Toggle value observable
+ */
+export function watchToggle(name: Toggle): Observable<boolean> {
+ const el = toggles[name]
+ return fromEvent(el, "change")
+ .pipe(
+ map(() => el.checked),
+ startWith(el.checked)
+ )
+}
diff --git a/docs/src/templates/assets/javascripts/browser/viewport/_/index.ts b/docs/src/templates/assets/javascripts/browser/viewport/_/index.ts
new file mode 100644
index 00000000..09c45f32
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/viewport/_/index.ts
@@ -0,0 +1,69 @@
+/*
+ * 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,
+ map,
+ shareReplay
+} from "rxjs"
+
+import {
+ ViewportOffset,
+ watchViewportOffset
+} from "../offset"
+import {
+ ViewportSize,
+ watchViewportSize
+} from "../size"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Viewport
+ */
+export interface Viewport {
+ offset: ViewportOffset /* Viewport offset */
+ size: ViewportSize /* Viewport size */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch viewport
+ *
+ * @returns Viewport observable
+ */
+export function watchViewport(): Observable<Viewport> {
+ return combineLatest([
+ watchViewportOffset(),
+ watchViewportSize()
+ ])
+ .pipe(
+ map(([offset, size]) => ({ offset, size })),
+ shareReplay(1)
+ )
+}
diff --git a/docs/src/templates/assets/javascripts/browser/viewport/at/index.ts b/docs/src/templates/assets/javascripts/browser/viewport/at/index.ts
new file mode 100644
index 00000000..8769cf3b
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/viewport/at/index.ts
@@ -0,0 +1,84 @@
+/*
+ * 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,
+ distinctUntilKeyChanged,
+ map
+} from "rxjs"
+
+import { Header } from "~/components"
+
+import { getElementOffset } from "../../element"
+import { Viewport } from "../_"
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch options
+ */
+interface WatchOptions {
+ viewport$: Observable<Viewport> /* Viewport observable */
+ header$: Observable<Header> /* Header observable */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch viewport relative to element
+ *
+ * @param el - Element
+ * @param options - Options
+ *
+ * @returns Viewport observable
+ */
+export function watchViewportAt(
+ el: HTMLElement, { viewport$, header$ }: WatchOptions
+): Observable<Viewport> {
+ const size$ = viewport$
+ .pipe(
+ distinctUntilKeyChanged("size")
+ )
+
+ /* Compute element offset */
+ const offset$ = combineLatest([size$, header$])
+ .pipe(
+ map(() => getElementOffset(el))
+ )
+
+ /* Compute relative viewport, return hot observable */
+ return combineLatest([header$, viewport$, offset$])
+ .pipe(
+ map(([{ height }, { offset, size }, { x, y }]) => ({
+ offset: {
+ x: offset.x - x,
+ y: offset.y - y + height
+ },
+ size
+ }))
+ )
+}
diff --git a/docs/src/templates/assets/javascripts/browser/viewport/index.ts b/docs/src/templates/assets/javascripts/browser/viewport/index.ts
new file mode 100644
index 00000000..b3d135e9
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/viewport/index.ts
@@ -0,0 +1,26 @@
+/*
+ * 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 "./at"
+export * from "./offset"
+export * from "./size"
diff --git a/docs/src/templates/assets/javascripts/browser/viewport/offset/index.ts b/docs/src/templates/assets/javascripts/browser/viewport/offset/index.ts
new file mode 100644
index 00000000..63d37dd2
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/viewport/offset/index.ts
@@ -0,0 +1,78 @@
+/*
+ * 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,
+ fromEvent,
+ map,
+ merge,
+ startWith
+} from "rxjs"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Viewport offset
+ */
+export interface ViewportOffset {
+ x: number /* Horizontal offset */
+ y: number /* Vertical offset */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Retrieve viewport offset
+ *
+ * On iOS Safari, viewport offset can be negative due to overflow scrolling.
+ * As this may induce strange behaviors downstream, we'll just limit it to 0.
+ *
+ * @returns Viewport offset
+ */
+export function getViewportOffset(): ViewportOffset {
+ return {
+ x: Math.max(0, scrollX),
+ y: Math.max(0, scrollY)
+ }
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Watch viewport offset
+ *
+ * @returns Viewport offset observable
+ */
+export function watchViewportOffset(): Observable<ViewportOffset> {
+ return merge(
+ fromEvent(window, "scroll", { passive: true }),
+ fromEvent(window, "resize", { passive: true })
+ )
+ .pipe(
+ map(getViewportOffset),
+ startWith(getViewportOffset())
+ )
+}
diff --git a/docs/src/templates/assets/javascripts/browser/viewport/size/index.ts b/docs/src/templates/assets/javascripts/browser/viewport/size/index.ts
new file mode 100644
index 00000000..06694888
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/viewport/size/index.ts
@@ -0,0 +1,71 @@
+/*
+ * 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,
+ fromEvent,
+ map,
+ startWith
+} from "rxjs"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Viewport size
+ */
+export interface ViewportSize {
+ width: number /* Viewport width */
+ height: number /* Viewport height */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Retrieve viewport size
+ *
+ * @returns Viewport size
+ */
+export function getViewportSize(): ViewportSize {
+ return {
+ width: innerWidth,
+ height: innerHeight
+ }
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Watch viewport size
+ *
+ * @returns Viewport size observable
+ */
+export function watchViewportSize(): Observable<ViewportSize> {
+ return fromEvent(window, "resize", { passive: true })
+ .pipe(
+ map(getViewportSize),
+ startWith(getViewportSize())
+ )
+}
diff --git a/docs/src/templates/assets/javascripts/browser/worker/index.ts b/docs/src/templates/assets/javascripts/browser/worker/index.ts
new file mode 100644
index 00000000..12e4e63b
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/browser/worker/index.ts
@@ -0,0 +1,112 @@
+/*
+ * 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,
+ fromEvent,
+ ignoreElements,
+ mergeWith,
+ share,
+ takeUntil
+} from "rxjs"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Worker message
+ */
+export interface WorkerMessage {
+ type: unknown /* Message type */
+ data?: unknown /* Message data */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Create an observable for receiving from a web worker
+ *
+ * @template T - Data type
+ *
+ * @param worker - Web worker
+ *
+ * @returns Message observable
+ */
+function recv<T>(worker: Worker): Observable<T> {
+ return fromEvent<MessageEvent<T>, T>(worker, "message", ev => ev.data)
+}
+
+/**
+ * Create a subject for sending to a web worker
+ *
+ * @template T - Data type
+ *
+ * @param worker - Web worker
+ *
+ * @returns Message subject
+ */
+function send<T>(worker: Worker): Subject<T> {
+ const send$ = new Subject<T>()
+ send$.subscribe(data => worker.postMessage(data))
+
+ /* Return message subject */
+ return send$
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Create a bidirectional communication channel to a web worker
+ *
+ * @template T - Data type
+ *
+ * @param url - Worker URL
+ * @param worker - Worker
+ *
+ * @returns Worker subject
+ */
+export function watchWorker<T extends WorkerMessage>(
+ url: string, worker = new Worker(url)
+): Subject<T> {
+ const recv$ = recv<T>(worker)
+ const send$ = send<T>(worker)
+
+ /* Create worker subject and forward messages */
+ const worker$ = new Subject<T>()
+ worker$.subscribe(send$)
+
+ /* Return worker subject */
+ const done$ = send$.pipe(ignoreElements(), endWith(true))
+ return worker$
+ .pipe(
+ ignoreElements(),
+ mergeWith(recv$.pipe(takeUntil(done$))),
+ share()
+ ) as Subject<T>
+}
diff --git a/docs/src/templates/assets/javascripts/bundle.ts b/docs/src/templates/assets/javascripts/bundle.ts
new file mode 100644
index 00000000..141789c9
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/bundle.ts
@@ -0,0 +1,316 @@
+/*
+ * 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 "focus-visible"
+
+import {
+ EMPTY,
+ NEVER,
+ Observable,
+ Subject,
+ defer,
+ delay,
+ filter,
+ map,
+ merge,
+ mergeWith,
+ shareReplay,
+ switchMap
+} from "rxjs"
+
+import { configuration, feature } from "./_"
+import {
+ at,
+ getActiveElement,
+ getOptionalElement,
+ requestJSON,
+ setLocation,
+ setToggle,
+ watchDocument,
+ watchKeyboard,
+ watchLocation,
+ watchLocationTarget,
+ watchMedia,
+ watchPrint,
+ watchScript,
+ watchViewport
+} from "./browser"
+import {
+ getComponentElement,
+ getComponentElements,
+ mountAnnounce,
+ mountBackToTop,
+ mountConsent,
+ mountContent,
+ mountDialog,
+ mountHeader,
+ mountHeaderTitle,
+ mountPalette,
+ mountProgress,
+ mountSearch,
+ mountSearchHiglight,
+ mountSidebar,
+ mountSource,
+ mountTableOfContents,
+ mountTabs,
+ watchHeader,
+ watchMain
+} from "./components"
+import {
+ SearchIndex,
+ setupClipboardJS,
+ setupInstantNavigation,
+ setupVersionSelector
+} from "./integrations"
+import {
+ patchIndeterminate,
+ patchScrollfix,
+ patchScrolllock
+} from "./patches"
+import "./polyfills"
+
+/* ----------------------------------------------------------------------------
+ * Functions - @todo refactor
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Fetch search index
+ *
+ * @returns Search index observable
+ */
+function fetchSearchIndex(): Observable<SearchIndex> {
+ if (location.protocol === "file:") {
+ return watchScript(
+ `${new URL("search/search_index.js", config.base)}`
+ )
+ .pipe(
+ // @ts-ignore - @todo fix typings
+ map(() => __index),
+ shareReplay(1)
+ )
+ } else {
+ return requestJSON<SearchIndex>(
+ new URL("search/search_index.json", config.base)
+ )
+ }
+}
+
+/* ----------------------------------------------------------------------------
+ * Application
+ * ------------------------------------------------------------------------- */
+
+/* Yay, JavaScript is available */
+document.documentElement.classList.remove("no-js")
+document.documentElement.classList.add("js")
+
+/* Set up navigation observables and subjects */
+const document$ = watchDocument()
+const location$ = watchLocation()
+const target$ = watchLocationTarget(location$)
+const keyboard$ = watchKeyboard()
+
+/* Set up media observables */
+const viewport$ = watchViewport()
+const tablet$ = watchMedia("(min-width: 960px)")
+const screen$ = watchMedia("(min-width: 1220px)")
+const print$ = watchPrint()
+
+/* Retrieve search index, if search is enabled */
+const config = configuration()
+const index$ = document.forms.namedItem("search")
+ ? fetchSearchIndex()
+ : NEVER
+
+/* Set up Clipboard.js integration */
+const alert$ = new Subject<string>()
+setupClipboardJS({ alert$ })
+
+/* Set up progress indicator */
+const progress$ = new Subject<number>()
+
+/* Set up instant navigation, if enabled */
+if (feature("navigation.instant"))
+ setupInstantNavigation({ location$, viewport$, progress$ })
+ .subscribe(document$)
+
+/* Set up version selector */
+if (config.version?.provider === "mike")
+ setupVersionSelector({ document$ })
+
+/* Always close drawer and search on navigation */
+merge(location$, target$)
+ .pipe(
+ delay(125)
+ )
+ .subscribe(() => {
+ setToggle("drawer", false)
+ setToggle("search", false)
+ })
+
+/* Set up global keyboard handlers */
+keyboard$
+ .pipe(
+ filter(({ mode }) => mode === "global")
+ )
+ .subscribe(key => {
+ switch (key.type) {
+
+ /* Go to previous page */
+ case "p":
+ case ",":
+ const prev = getOptionalElement<HTMLLinkElement>("link[rel=prev]")
+ if (typeof prev !== "undefined")
+ setLocation(prev)
+ break
+
+ /* Go to next page */
+ case "n":
+ case ".":
+ const next = getOptionalElement<HTMLLinkElement>("link[rel=next]")
+ if (typeof next !== "undefined")
+ setLocation(next)
+ break
+
+ /* Expand navigation, see https://bit.ly/3ZjG5io */
+ case "Enter":
+ const active = getActiveElement()
+ if (active instanceof HTMLLabelElement)
+ active.click()
+ }
+ })
+
+/* Set up patches */
+patchIndeterminate({ document$, tablet$ })
+patchScrollfix({ document$ })
+patchScrolllock({ viewport$, tablet$ })
+
+/* Set up header and main area observable */
+const header$ = watchHeader(getComponentElement("header"), { viewport$ })
+const main$ = document$
+ .pipe(
+ map(() => getComponentElement("main")),
+ switchMap(el => watchMain(el, { viewport$, header$ })),
+ shareReplay(1)
+ )
+
+/* Set up control component observables */
+const control$ = merge(
+
+ /* Consent */
+ ...getComponentElements("consent")
+ .map(el => mountConsent(el, { target$ })),
+
+ /* Dialog */
+ ...getComponentElements("dialog")
+ .map(el => mountDialog(el, { alert$ })),
+
+ /* Header */
+ ...getComponentElements("header")
+ .map(el => mountHeader(el, { viewport$, header$, main$ })),
+
+ /* Color palette */
+ ...getComponentElements("palette")
+ .map(el => mountPalette(el)),
+
+ /* Progress bar */
+ ...getComponentElements("progress")
+ .map(el => mountProgress(el, { progress$ })),
+
+ /* Search */
+ ...getComponentElements("search")
+ .map(el => mountSearch(el, { index$, keyboard$ })),
+
+ /* Repository information */
+ ...getComponentElements("source")
+ .map(el => mountSource(el))
+)
+
+/* Set up content component observables */
+const content$ = defer(() => merge(
+
+ /* Announcement bar */
+ ...getComponentElements("announce")
+ .map(el => mountAnnounce(el)),
+
+ /* Content */
+ ...getComponentElements("content")
+ .map(el => mountContent(el, { viewport$, target$, print$ })),
+
+ /* Search highlighting */
+ ...getComponentElements("content")
+ .map(el => feature("search.highlight")
+ ? mountSearchHiglight(el, { index$, location$ })
+ : EMPTY
+ ),
+
+ /* Header title */
+ ...getComponentElements("header-title")
+ .map(el => mountHeaderTitle(el, { viewport$, header$ })),
+
+ /* Sidebar */
+ ...getComponentElements("sidebar")
+ .map(el => el.getAttribute("data-md-type") === "navigation"
+ ? at(screen$, () => mountSidebar(el, { viewport$, header$, main$ }))
+ : at(tablet$, () => mountSidebar(el, { viewport$, header$, main$ }))
+ ),
+
+ /* Navigation tabs */
+ ...getComponentElements("tabs")
+ .map(el => mountTabs(el, { viewport$, header$ })),
+
+ /* Table of contents */
+ ...getComponentElements("toc")
+ .map(el => mountTableOfContents(el, {
+ viewport$, header$, main$, target$
+ })),
+
+ /* Back-to-top button */
+ ...getComponentElements("top")
+ .map(el => mountBackToTop(el, { viewport$, header$, main$, target$ }))
+))
+
+/* Set up component observables */
+const component$ = document$
+ .pipe(
+ switchMap(() => content$),
+ mergeWith(control$),
+ shareReplay(1)
+ )
+
+/* Subscribe to all components */
+component$.subscribe()
+
+/* ----------------------------------------------------------------------------
+ * Exports
+ * ------------------------------------------------------------------------- */
+
+window.document$ = document$ /* Document observable */
+window.location$ = location$ /* Location subject */
+window.target$ = target$ /* Location target observable */
+window.keyboard$ = keyboard$ /* Keyboard observable */
+window.viewport$ = viewport$ /* Viewport observable */
+window.tablet$ = tablet$ /* Media tablet observable */
+window.screen$ = screen$ /* Media screen observable */
+window.print$ = print$ /* Media print observable */
+window.alert$ = alert$ /* Alert subject */
+window.progress$ = progress$ /* Progress indicator subject */
+window.component$ = component$ /* Component observable */
diff --git a/docs/src/templates/assets/javascripts/components/_/index.ts b/docs/src/templates/assets/javascripts/components/_/index.ts
new file mode 100644
index 00000000..61c471d9
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/announce/index.ts b/docs/src/templates/assets/javascripts/components/announce/index.ts
new file mode 100644
index 00000000..dd04b4ff
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/consent/index.ts b/docs/src/templates/assets/javascripts/components/consent/index.ts
new file mode 100644
index 00000000..bc99db58
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/content/_/index.ts b/docs/src/templates/assets/javascripts/components/content/_/index.ts
new file mode 100644
index 00000000..899a695c
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/content/annotation/_/index.ts b/docs/src/templates/assets/javascripts/components/content/annotation/_/index.ts
new file mode 100644
index 00000000..c5138fa4
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/content/annotation/block/index.ts b/docs/src/templates/assets/javascripts/components/content/annotation/block/index.ts
new file mode 100644
index 00000000..c73b01fa
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/content/annotation/index.ts b/docs/src/templates/assets/javascripts/components/content/annotation/index.ts
new file mode 100644
index 00000000..c593b723
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/content/annotation/list/index.ts b/docs/src/templates/assets/javascripts/components/content/annotation/list/index.ts
new file mode 100644
index 00000000..725dd583
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/content/code/_/index.ts b/docs/src/templates/assets/javascripts/components/content/code/_/index.ts
new file mode 100644
index 00000000..ccc09339
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/content/code/index.ts b/docs/src/templates/assets/javascripts/components/content/code/index.ts
new file mode 100644
index 00000000..3f86e2b4
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/content/details/index.ts b/docs/src/templates/assets/javascripts/components/content/details/index.ts
new file mode 100644
index 00000000..17bfae45
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/content/index.ts b/docs/src/templates/assets/javascripts/components/content/index.ts
new file mode 100644
index 00000000..a29d8b41
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/content/mermaid/index.css b/docs/src/templates/assets/javascripts/components/content/mermaid/index.css
new file mode 100644
index 00000000..3092b8ec
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/content/mermaid/index.ts b/docs/src/templates/assets/javascripts/components/content/mermaid/index.ts
new file mode 100644
index 00000000..3f6480fd
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/content/table/index.ts b/docs/src/templates/assets/javascripts/components/content/table/index.ts
new file mode 100644
index 00000000..c318e7a6
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/content/tabs/index.ts b/docs/src/templates/assets/javascripts/components/content/tabs/index.ts
new file mode 100644
index 00000000..f57447e2
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/dialog/index.ts b/docs/src/templates/assets/javascripts/components/dialog/index.ts
new file mode 100644
index 00000000..6ff1bd44
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/header/_/index.ts b/docs/src/templates/assets/javascripts/components/header/_/index.ts
new file mode 100644
index 00000000..0f33eb48
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/header/index.ts b/docs/src/templates/assets/javascripts/components/header/index.ts
new file mode 100644
index 00000000..cf23ec1a
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/header/title/index.ts b/docs/src/templates/assets/javascripts/components/header/title/index.ts
new file mode 100644
index 00000000..f3bc0d08
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/index.ts b/docs/src/templates/assets/javascripts/components/index.ts
new file mode 100644
index 00000000..3d4391d1
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/main/index.ts b/docs/src/templates/assets/javascripts/components/main/index.ts
new file mode 100644
index 00000000..2509f9b9
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/palette/index.ts b/docs/src/templates/assets/javascripts/components/palette/index.ts
new file mode 100644
index 00000000..cf578f60
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/progress/index.ts b/docs/src/templates/assets/javascripts/components/progress/index.ts
new file mode 100644
index 00000000..30c722b8
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/search/_/index.ts b/docs/src/templates/assets/javascripts/components/search/_/index.ts
new file mode 100644
index 00000000..aa963b47
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/search/highlight/.eslintrc b/docs/src/templates/assets/javascripts/components/search/highlight/.eslintrc
new file mode 100644
index 00000000..38a5714d
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/components/search/highlight/.eslintrc
@@ -0,0 +1,5 @@
+{
+ "rules": {
+ "no-null/no-null": "off"
+ }
+}
diff --git a/docs/src/templates/assets/javascripts/components/search/highlight/index.ts b/docs/src/templates/assets/javascripts/components/search/highlight/index.ts
new file mode 100644
index 00000000..bc3f94c9
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/search/index.ts b/docs/src/templates/assets/javascripts/components/search/index.ts
new file mode 100644
index 00000000..846d8685
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/search/query/index.ts b/docs/src/templates/assets/javascripts/components/search/query/index.ts
new file mode 100644
index 00000000..4ce21279
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/search/result/index.ts b/docs/src/templates/assets/javascripts/components/search/result/index.ts
new file mode 100644
index 00000000..c3c9ef20
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/search/share/index.ts b/docs/src/templates/assets/javascripts/components/search/share/index.ts
new file mode 100644
index 00000000..3db382c8
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/search/suggest/index.ts b/docs/src/templates/assets/javascripts/components/search/suggest/index.ts
new file mode 100644
index 00000000..e7881475
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/sidebar/index.ts b/docs/src/templates/assets/javascripts/components/sidebar/index.ts
new file mode 100644
index 00000000..82f3d03e
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/source/_/index.ts b/docs/src/templates/assets/javascripts/components/source/_/index.ts
new file mode 100644
index 00000000..5f6c4d11
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/source/facts/_/index.ts b/docs/src/templates/assets/javascripts/components/source/facts/_/index.ts
new file mode 100644
index 00000000..154f229f
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/source/facts/github/index.ts b/docs/src/templates/assets/javascripts/components/source/facts/github/index.ts
new file mode 100644
index 00000000..12cc55e0
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/source/facts/gitlab/index.ts b/docs/src/templates/assets/javascripts/components/source/facts/gitlab/index.ts
new file mode 100644
index 00000000..d85d4afd
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/source/facts/index.ts b/docs/src/templates/assets/javascripts/components/source/facts/index.ts
new file mode 100644
index 00000000..f9bda64d
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/source/index.ts b/docs/src/templates/assets/javascripts/components/source/index.ts
new file mode 100644
index 00000000..7fac4813
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/tabs/index.ts b/docs/src/templates/assets/javascripts/components/tabs/index.ts
new file mode 100644
index 00000000..1e69df28
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/toc/index.ts b/docs/src/templates/assets/javascripts/components/toc/index.ts
new file mode 100644
index 00000000..04b8d85f
--- /dev/null
+++ b/docs/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/docs/src/templates/assets/javascripts/components/top/index.ts b/docs/src/templates/assets/javascripts/components/top/index.ts
new file mode 100644
index 00000000..82e88b61
--- /dev/null
+++ b/docs/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 }))
+ )
+}
diff --git a/docs/src/templates/assets/javascripts/integrations/clipboard/index.ts b/docs/src/templates/assets/javascripts/integrations/clipboard/index.ts
new file mode 100644
index 00000000..cf46f601
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/clipboard/index.ts
@@ -0,0 +1,99 @@
+/*
+ * 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 {
+ Observable,
+ Subject,
+ map,
+ tap
+} from "rxjs"
+
+import { translation } from "~/_"
+import { getElement } from "~/browser"
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Setup options
+ */
+interface SetupOptions {
+ alert$: Subject<string> /* Alert subject */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Extract text to copy
+ *
+ * @param el - HTML element
+ *
+ * @returns Extracted text
+ */
+function extract(el: HTMLElement): string {
+ el.setAttribute("data-md-copying", "")
+ const copy = el.closest("[data-copy]")
+ const text = copy
+ ? copy.getAttribute("data-copy")!
+ : el.innerText
+ el.removeAttribute("data-md-copying")
+ return text
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Set up Clipboard.js integration
+ *
+ * @param options - Options
+ */
+export function setupClipboardJS(
+ { alert$ }: SetupOptions
+): void {
+ if (ClipboardJS.isSupported()) {
+ new Observable<ClipboardJS.Event>(subscriber => {
+ new ClipboardJS("[data-clipboard-target], [data-clipboard-text]", {
+ text: el => (
+ el.getAttribute("data-clipboard-text")! ||
+ extract(getElement(
+ el.getAttribute("data-clipboard-target")!
+ ))
+ )
+ })
+ .on("success", ev => subscriber.next(ev))
+ })
+ .pipe(
+ tap(ev => {
+ const trigger = ev.trigger as HTMLElement
+ trigger.focus()
+ }),
+ map(() => translation("clipboard.copied"))
+ )
+ .subscribe(alert$)
+ }
+}
diff --git a/docs/src/templates/assets/javascripts/integrations/index.ts b/docs/src/templates/assets/javascripts/integrations/index.ts
new file mode 100644
index 00000000..5d91a9d5
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/index.ts
@@ -0,0 +1,27 @@
+/*
+ * 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 "./clipboard"
+export * from "./instant"
+export * from "./search"
+export * from "./sitemap"
+export * from "./version"
diff --git a/docs/src/templates/assets/javascripts/integrations/instant/.eslintrc b/docs/src/templates/assets/javascripts/integrations/instant/.eslintrc
new file mode 100644
index 00000000..5adf108a
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/instant/.eslintrc
@@ -0,0 +1,6 @@
+{
+ "rules": {
+ "no-self-assign": "off",
+ "no-null/no-null": "off"
+ }
+}
diff --git a/docs/src/templates/assets/javascripts/integrations/instant/index.ts b/docs/src/templates/assets/javascripts/integrations/instant/index.ts
new file mode 100644
index 00000000..d321b751
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/instant/index.ts
@@ -0,0 +1,446 @@
+/*
+ * 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,
+ catchError,
+ concat,
+ debounceTime,
+ distinctUntilKeyChanged,
+ endWith,
+ filter,
+ fromEvent,
+ ignoreElements,
+ map,
+ of,
+ sample,
+ share,
+ skip,
+ startWith,
+ switchMap,
+ take,
+ withLatestFrom
+} from "rxjs"
+
+import { configuration, feature } from "~/_"
+import {
+ Viewport,
+ getElement,
+ getElements,
+ getLocation,
+ getOptionalElement,
+ request,
+ setLocation,
+ setLocationHash
+} from "~/browser"
+import { getComponentElement } from "~/components"
+
+import { fetchSitemap } from "../sitemap"
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Setup options
+ */
+interface SetupOptions {
+ location$: Subject<URL> // Location subject
+ viewport$: Observable<Viewport> // Viewport observable
+ progress$: Subject<number> // Progress suject
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Create a map of head elements for lookup and replacement
+ *
+ * @param head - Document head
+ *
+ * @returns Element map
+ */
+function lookup(head: HTMLHeadElement): Map<string, HTMLElement> {
+
+ // @todo When resolving URLs, we must make sure to use the correct base for
+ // resolution. The next time we refactor instant loading, we should use the
+ // location subject as a source, which is also used for anchor links tracking,
+ // but for now we just rely on canonical.
+ const canonical = getElement<HTMLLinkElement>("[rel=canonical]", head)
+ canonical.href = canonical.href.replace("//localhost:", "//127.0.0.1")
+
+ // Create tag map and index elements in head
+ const tags = new Map<string, HTMLElement>()
+ for (const el of getElements(":scope > *", head)) {
+ let html = el.outerHTML
+
+ // If the current element is a style sheet or script, we must resolve the
+ // URL relative to the current location and make it absolute, so it's easy
+ // to deduplicate it later on by comparing the outer HTML of tags. We must
+ // keep identical style sheets and scripts without replacing them.
+ for (const key of ["href", "src"]) {
+ const value = el.getAttribute(key)!
+ if (value === null)
+ continue
+
+ // Resolve URL relative to current location
+ const url = new URL(value, canonical.href)
+ const ref = el.cloneNode() as HTMLElement
+
+ // Set resolved URL and retrieve HTML for deduplication
+ ref.setAttribute(key, `${url}`)
+ html = ref.outerHTML
+ break
+ }
+
+ // Index element in tag map
+ tags.set(html, el)
+ }
+
+ // Return tag map
+ return tags
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Set up instant navigation
+ *
+ * This is a heavily orchestrated operation - see inline comments to learn how
+ * this works with Material for MkDocs, and how you can hook into it.
+ *
+ * @param options - Options
+ *
+ * @returns Document observable
+ */
+export function setupInstantNavigation(
+ { location$, viewport$, progress$ }: SetupOptions
+): Observable<Document> {
+ const config = configuration()
+ if (location.protocol === "file:")
+ return EMPTY
+
+ // Load sitemap immediately, so we have it available when the user initiates
+ // the first instant navigation request, and canonicalize URLs to the current
+ // base URL. The base URL will remain stable in between loads, as it's only
+ // read at the first initialization of the application.
+ const sitemap$ = fetchSitemap()
+ .pipe(
+ map(paths => paths.map(path => `${new URL(path, config.base)}`))
+ )
+
+ // Intercept inter-site navigation - to keep the number of event listeners
+ // low we use the fact that uncaptured events bubble up to the body. This also
+ // has the nice property that we don't need to detach and then again attach
+ // event listeners when instant navigation occurs.
+ const instant$ = fromEvent<MouseEvent>(document.body, "click")
+ .pipe(
+ withLatestFrom(sitemap$),
+ switchMap(([ev, sitemap]) => {
+ if (!(ev.target instanceof Element))
+ return EMPTY
+
+ // Skip, as target is not within a link - clicks on non-link elements
+ // are also captured, which we need to exclude from processing
+ const el = ev.target.closest("a")
+ if (el === null)
+ return EMPTY
+
+ // Skip, as link opens in new window - we now know we have captured a
+ // click on a link, but the link either has a `target` property defined,
+ // or the user pressed the `meta` or `ctrl` key to open it in a new
+ // window. Thus, we need to filter those events, too.
+ if (el.target || ev.metaKey || ev.ctrlKey)
+ return EMPTY
+
+ // Next, we must check if the URL is relevant for us, i.e., if it's an
+ // internal link to a page that is managed by MkDocs. Only then we can
+ // be sure that the structure of the page to be loaded adheres to the
+ // current document structure and can subsequently be injected into it
+ // without doing a full reload. For this reason, we must canonicalize
+ // the URL by removing all search parameters and hash fragments.
+ const url = new URL(el.href)
+ url.search = url.hash = ""
+
+ // Skip, if URL is not included in the sitemap - this could be the case
+ // when linking between versions or languages, or to another page that
+ // the author included as part of the build, but that is not managed by
+ // MkDocs. In that case we must not continue with instant navigation.
+ if (!sitemap.includes(`${url}`))
+ return EMPTY
+
+ // We now know that we have a link to an internal page, so we prevent
+ // the browser from navigation and emit the URL for instant navigation.
+ // Note that this also includes anchor links, which means we need to
+ // implement anchor positioning ourselves. The reason for this is that
+ // if we wouldn't manage anchor links as well, scroll restoration will
+ // not work correctly (e.g. following an anchor link and scrolling).
+ ev.preventDefault()
+ return of(new URL(el.href))
+ }),
+ share()
+ )
+
+ // Before fetching for the first time, resolve the absolute favicon position,
+ // as the browser will try to fetch the icon immediately
+ instant$.pipe(take(1))
+ .subscribe(() => {
+ const favicon = getOptionalElement<HTMLLinkElement>("link[rel=icon]")
+ if (typeof favicon !== "undefined")
+ favicon.href = favicon.href
+ })
+
+ // Enable scroll restoration before window unloads - this is essential to
+ // ensure that full reloads (F5) restore the viewport offset correctly. If
+ // only popstate events wouldn't reset the scroll position prior to their
+ // emission, we could just reset this in popstate. Meh.
+ fromEvent(window, "beforeunload")
+ .subscribe(() => {
+ history.scrollRestoration = "auto"
+ })
+
+ // When an instant navigation event occurs, disable scroll restoration, since
+ // we must normalize and synchronize the behavior across all browsers. For
+ // instance, when the user clicks the back or forward button, the browser
+ // would immediately jump to the position of the previous document.
+ instant$.pipe(withLatestFrom(viewport$))
+ .subscribe(([url, { offset }]) => {
+ history.scrollRestoration = "manual"
+
+ // While it would be better UX to defer the history state change until the
+ // document was fully fetched and parsed, we must schedule it here, since
+ // popstate events are emitted when history state changes happen. Moreover
+ // we need to back up the current viewport offset, so we can restore it
+ // when popstate events occur, e.g., when the browser's back and forward
+ // buttons are used for navigation.
+ history.replaceState(offset, "")
+ history.pushState(null, "", url)
+ })
+
+ // Emit URL that should be fetched via instant navigation on location subject,
+ // which was passed into this function. Instant navigation can be intercepted
+ // by other parts of the application, which can synchronously back up or
+ // restore state before instant navigation happens.
+ instant$.subscribe(location$)
+
+ // Fetch document - when fetching, we could use `responseType: document`, but
+ // since all MkDocs links are relative, we need to make sure that the current
+ // location matches the document we just loaded. Otherwise any relative links
+ // in the document might use the old location. If the request fails for some
+ // reason, we fall back to regular navigation and set the location explicitly,
+ // which will force-load the page. Furthermore, we must pre-warm the buffer
+ // for the duplicate check, or the first click on an anchor link will also
+ // trigger an instant navigation event, which doesn't make sense.
+ const response$ = location$
+ .pipe(
+ startWith(getLocation()),
+ distinctUntilKeyChanged("pathname"),
+ skip(1),
+ switchMap(url => request(url, { progress$ })
+ .pipe(
+ catchError(() => {
+ setLocation(url, true)
+ return EMPTY
+ })
+ )
+ )
+ )
+
+ // Initialize the DOM parser, parse the returned HTML, and replace selected
+ // components before handing control down to the application
+ const dom = new DOMParser()
+ const document$ = response$
+ .pipe(
+ switchMap(res => res.text()),
+ switchMap(res => {
+ const next = dom.parseFromString(res, "text/html")
+ for (const selector of [
+ "[data-md-component=announce]",
+ "[data-md-component=container]",
+ "[data-md-component=header-topic]",
+ "[data-md-component=outdated]",
+ "[data-md-component=logo]",
+ "[data-md-component=skip]",
+ ...feature("navigation.tabs.sticky")
+ ? ["[data-md-component=tabs]"]
+ : []
+ ]) {
+ const source = getOptionalElement(selector)
+ const target = getOptionalElement(selector, next)
+ if (
+ typeof source !== "undefined" &&
+ typeof target !== "undefined"
+ ) {
+ source.replaceWith(target)
+ }
+ }
+
+ // Update meta tags
+ const source = lookup(document.head)
+ const target = lookup(next.head)
+ for (const [html, el] of target) {
+
+ // Hack: skip stylesheets and scripts until we manage to replace them
+ // entirely in order to omit flashes of white content @todo refactor
+ if (
+ el.getAttribute("rel") === "stylesheet" ||
+ el.hasAttribute("src")
+ )
+ continue
+
+ if (source.has(html)) {
+ source.delete(html)
+ } else {
+ document.head.appendChild(el)
+ }
+ }
+
+ // Remove meta tags that are not present in the new document
+ for (const el of source.values())
+
+ // Hack: skip stylesheets and scripts until we manage to replace them
+ // entirely in order to omit flashes of white content @todo refactor
+ if (
+ el.getAttribute("rel") === "stylesheet" ||
+ el.hasAttribute("src")
+ )
+ continue
+ else
+ el.remove()
+
+ // After components and meta tags were replaced, re-evaluate scripts
+ // that were provided by the author as part of Markdown files
+ const container = getComponentElement("container")
+ return concat(getElements("script", container))
+ .pipe(
+ switchMap(el => {
+ const script = next.createElement("script")
+ if (el.src) {
+ for (const name of el.getAttributeNames())
+ script.setAttribute(name, el.getAttribute(name)!)
+ el.replaceWith(script)
+
+ // Complete when script is loaded
+ return new Observable(observer => {
+ script.onload = () => observer.complete()
+ })
+
+ // Complete immediately
+ } else {
+ script.textContent = el.textContent
+ el.replaceWith(script)
+ return EMPTY
+ }
+ }),
+ ignoreElements(),
+ endWith(next)
+ )
+ }),
+ share()
+ )
+
+ // Intercept popstate events, e.g. when using the browser's back and forward
+ // buttons, and emit new location for fetching and parsing
+ const popstate$ = fromEvent<PopStateEvent>(window, "popstate")
+ popstate$.pipe(map(getLocation))
+ .subscribe(location$)
+
+ // Intercept clicks on anchor links, and scroll document into position - as
+ // we disabled scroll restoration, we need to do this manually here
+ location$
+ .pipe(
+ startWith(getLocation()),
+ bufferCount(2, 1),
+ filter(([prev, next]) => (
+ prev.pathname === next.pathname &&
+ prev.hash !== next.hash
+ )),
+ map(([, next]) => next)
+ )
+ .subscribe(url => {
+ if (history.state !== null || !url.hash) {
+ window.scrollTo(0, history.state?.y ?? 0)
+ } else {
+ history.scrollRestoration = "auto"
+ setLocationHash(url.hash)
+ history.scrollRestoration = "manual"
+ }
+ })
+
+ // Intercept clicks on the same anchor link - we must use a distinct pipeline
+ // for this, or we'd end up in a loop, setting the hash again and again
+ location$
+ .pipe(
+ sample(instant$),
+ startWith(getLocation()),
+ bufferCount(2, 1),
+ filter(([prev, next]) => (
+ prev.pathname === next.pathname &&
+ prev.hash === next.hash
+ )),
+ map(([, next]) => next)
+ )
+ .subscribe(url => {
+ history.scrollRestoration = "auto"
+ setLocationHash(url.hash)
+ history.scrollRestoration = "manual"
+
+ // Hack: we need to make sure that we don't end up with multiple history
+ // entries for the same anchor link, so we just remove the last entry
+ history.back()
+ })
+
+ // After parsing the document, check if the current history entry has a state.
+ // This may happen when users press the back or forward button to visit a page
+ // that was already seen. If there's no state, it means a new page was visited
+ // and we should scroll to the top, unless an anchor is given.
+ document$.pipe(withLatestFrom(location$))
+ .subscribe(([, url]) => {
+ if (history.state !== null || !url.hash) {
+ window.scrollTo(0, history.state?.y ?? 0)
+ } else {
+ setLocationHash(url.hash)
+ }
+ })
+
+ // If the current history is not empty, register an event listener updating
+ // the current history state whenever the scroll position changes. This must
+ // be debounced and cannot be done in popstate, as popstate has already
+ // removed the entry from the history.
+ viewport$
+ .pipe(
+ distinctUntilKeyChanged("offset"),
+ debounceTime(100)
+ )
+ .subscribe(({ offset }) => {
+ history.replaceState(offset, "")
+ })
+
+ // Return document
+ return document$
+}
diff --git a/docs/src/templates/assets/javascripts/integrations/search/_/index.ts b/docs/src/templates/assets/javascripts/integrations/search/_/index.ts
new file mode 100644
index 00000000..0e217fa4
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/search/_/index.ts
@@ -0,0 +1,332 @@
+/*
+ * 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 {
+ SearchDocument,
+ SearchIndex,
+ SearchOptions,
+ setupSearchDocumentMap
+} from "../config"
+import {
+ Position,
+ PositionTable,
+ highlight,
+ highlightAll,
+ tokenize
+} from "../internal"
+import {
+ SearchQueryTerms,
+ getSearchQueryTerms,
+ parseSearchQuery,
+ segment,
+ transformSearchQuery
+} from "../query"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Search item
+ */
+export interface SearchItem
+ extends SearchDocument
+{
+ score: number /* Score (relevance) */
+ terms: SearchQueryTerms /* Search query terms */
+}
+
+/**
+ * Search result
+ */
+export interface SearchResult {
+ items: SearchItem[][] /* Search items */
+ suggest?: string[] /* Search suggestions */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Create field extractor factory
+ *
+ * @param table - Position table map
+ *
+ * @returns Extractor factory
+ */
+function extractor(table: Map<string, PositionTable>) {
+ return (name: keyof SearchDocument) => {
+ return (doc: SearchDocument) => {
+ if (typeof doc[name] === "undefined")
+ return undefined
+
+ /* Compute identifier and initialize table */
+ const id = [doc.location, name].join(":")
+ table.set(id, lunr.tokenizer.table = [])
+
+ /* Return field value */
+ return doc[name]
+ }
+ }
+}
+
+/**
+ * Compute the difference of two lists of strings
+ *
+ * @param a - 1st list of strings
+ * @param b - 2nd list of strings
+ *
+ * @returns Difference
+ */
+function difference(a: string[], b: string[]): string[] {
+ const [x, y] = [new Set(a), new Set(b)]
+ return [
+ ...new Set([...x].filter(value => !y.has(value)))
+ ]
+}
+
+/* ----------------------------------------------------------------------------
+ * Class
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Search index
+ */
+export class Search {
+
+ /**
+ * Search document map
+ */
+ protected map: Map<string, SearchDocument>
+
+ /**
+ * Search options
+ */
+ protected options: SearchOptions
+
+ /**
+ * The underlying Lunr.js search index
+ */
+ protected index: lunr.Index
+
+ /**
+ * Internal position table map
+ */
+ protected table: Map<string, PositionTable>
+
+ /**
+ * Create the search integration
+ *
+ * @param data - Search index
+ */
+ public constructor({ config, docs, options }: SearchIndex) {
+ const field = extractor(this.table = new Map())
+
+ /* Set up document map and options */
+ this.map = setupSearchDocumentMap(docs)
+ this.options = options
+
+ /* Set up document index */
+ this.index = lunr(function () {
+ this.metadataWhitelist = ["position"]
+ this.b(0)
+
+ /* Set up (multi-)language support */
+ if (config.lang.length === 1 && config.lang[0] !== "en") {
+ // @ts-expect-error - namespace indexing not supported
+ this.use(lunr[config.lang[0]])
+ } else if (config.lang.length > 1) {
+ this.use(lunr.multiLanguage(...config.lang))
+ }
+
+ /* Set up custom tokenizer (must be after language setup) */
+ this.tokenizer = tokenize as typeof lunr.tokenizer
+ lunr.tokenizer.separator = new RegExp(config.separator)
+
+ /* Set up custom segmenter, if loaded */
+ lunr.segmenter = "TinySegmenter" in lunr
+ ? new lunr.TinySegmenter()
+ : undefined
+
+ /* Compute functions to be removed from the pipeline */
+ const fns = difference([
+ "trimmer", "stopWordFilter", "stemmer"
+ ], config.pipeline)
+
+ /* Remove functions from the pipeline for registered languages */
+ for (const lang of config.lang.map(language => (
+ // @ts-expect-error - namespace indexing not supported
+ language === "en" ? lunr : lunr[language]
+ )))
+ for (const fn of fns) {
+ this.pipeline.remove(lang[fn])
+ this.searchPipeline.remove(lang[fn])
+ }
+
+ /* Set up index reference */
+ this.ref("location")
+
+ /* Set up index fields */
+ this.field("title", { boost: 1e3, extractor: field("title") })
+ this.field("text", { boost: 1e0, extractor: field("text") })
+ this.field("tags", { boost: 1e6, extractor: field("tags") })
+
+ /* Add documents to index */
+ for (const doc of docs)
+ this.add(doc, { boost: doc.boost })
+ })
+ }
+
+ /**
+ * Search for matching documents
+ *
+ * @param query - Search query
+ *
+ * @returns Search result
+ */
+ public search(query: string): SearchResult {
+
+ // Experimental Chinese segmentation
+ query = query.replace(/\p{sc=Han}+/gu, value => {
+ return [...segment(value, this.index.invertedIndex)]
+ .join("* ")
+ })
+
+ // @todo: move segmenter (above) into transformSearchQuery
+ query = transformSearchQuery(query)
+ if (!query)
+ return { items: [] }
+
+ /* Parse query to extract clauses for analysis */
+ const clauses = parseSearchQuery(query)
+ .filter(clause => (
+ clause.presence !== lunr.Query.presence.PROHIBITED
+ ))
+
+ /* Perform search and post-process results */
+ const groups = this.index.search(query)
+
+ /* Apply post-query boosts based on title and search query terms */
+ .reduce<SearchItem[]>((item, { ref, score, matchData }) => {
+ let doc = this.map.get(ref)
+ if (typeof doc !== "undefined") {
+
+ /* Shallow copy document */
+ doc = { ...doc }
+ if (doc.tags)
+ doc.tags = [...doc.tags]
+
+ /* Compute and analyze search query terms */
+ const terms = getSearchQueryTerms(
+ clauses,
+ Object.keys(matchData.metadata)
+ )
+
+ /* Highlight matches in fields */
+ for (const field of this.index.fields) {
+ if (typeof doc[field] === "undefined")
+ continue
+
+ /* Collect positions from matches */
+ const positions: Position[] = []
+ for (const match of Object.values(matchData.metadata))
+ if (typeof match[field] !== "undefined")
+ positions.push(...match[field].position)
+
+ /* Skip highlighting, if no positions were collected */
+ if (!positions.length)
+ continue
+
+ /* Load table and determine highlighting method */
+ const table = this.table.get([doc.location, field].join(":"))!
+ const fn = Array.isArray(doc[field])
+ ? highlightAll
+ : highlight
+
+ // @ts-expect-error - stop moaning, TypeScript!
+ doc[field] = fn(doc[field], table, positions, field !== "text")
+ }
+
+ /* Highlight title and text and apply post-query boosts */
+ const boost = +!doc.parent +
+ Object.values(terms)
+ .filter(t => t).length /
+ Object.keys(terms).length
+
+ /* Append item */
+ item.push({
+ ...doc,
+ score: score * (1 + boost ** 2),
+ terms
+ })
+ }
+ return item
+ }, [])
+
+ /* Sort search results again after applying boosts */
+ .sort((a, b) => b.score - a.score)
+
+ /* Group search results by article */
+ .reduce((items, result) => {
+ const doc = this.map.get(result.location)
+ if (typeof doc !== "undefined") {
+ const ref = doc.parent
+ ? doc.parent.location
+ : doc.location
+ items.set(ref, [...items.get(ref) || [], result])
+ }
+ return items
+ }, new Map<string, SearchItem[]>())
+
+ /* Ensure that every item set has an article */
+ for (const [ref, items] of groups)
+ if (!items.find(item => item.location === ref)) {
+ const doc = this.map.get(ref)!
+ items.push({ ...doc, score: 0, terms: {} })
+ }
+
+ /* Generate search suggestions, if desired */
+ let suggest: string[] | undefined
+ if (this.options.suggest) {
+ const titles = this.index.query(builder => {
+ for (const clause of clauses)
+ builder.term(clause.term, {
+ fields: ["title"],
+ presence: lunr.Query.presence.REQUIRED,
+ wildcard: lunr.Query.wildcard.TRAILING
+ })
+ })
+
+ /* Retrieve suggestions for best match */
+ suggest = titles.length
+ ? Object.keys(titles[0].matchData.metadata)
+ : []
+ }
+
+ /* Return search result */
+ return {
+ items: [...groups.values()],
+ ...typeof suggest !== "undefined" && { suggest }
+ }
+ }
+}
diff --git a/docs/src/templates/assets/javascripts/integrations/search/config/index.ts b/docs/src/templates/assets/javascripts/integrations/search/config/index.ts
new file mode 100644
index 00000000..3d88d1c6
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/search/config/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.
+ */
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Search configuration
+ */
+export interface SearchConfig {
+ lang: string[] /* Search languages */
+ separator: string /* Search separator */
+ pipeline: SearchPipelineFn[] /* Search pipeline */
+}
+
+/**
+ * Search document
+ */
+export interface SearchDocument {
+ location: string /* Document location */
+ title: string /* Document title */
+ text: string /* Document text */
+ tags?: string[] /* Document tags */
+ boost?: number /* Document boost */
+ parent?: SearchDocument /* Document parent */
+}
+
+/**
+ * Search options
+ */
+export interface SearchOptions {
+ suggest: boolean /* Search suggestions */
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Search index
+ */
+export interface SearchIndex {
+ config: SearchConfig /* Search configuration */
+ docs: SearchDocument[] /* Search documents */
+ options: SearchOptions /* Search options */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Search pipeline function
+ */
+type SearchPipelineFn =
+ | "trimmer" /* Trimmer */
+ | "stopWordFilter" /* Stop word filter */
+ | "stemmer" /* Stemmer */
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Create a search document map
+ *
+ * This function creates a mapping of URLs (including anchors) to the actual
+ * articles and sections. It relies on the invariant that the search index is
+ * ordered with the main article appearing before all sections with anchors.
+ * If this is not the case, the logic music be changed.
+ *
+ * @param docs - Search documents
+ *
+ * @returns Search document map
+ */
+export function setupSearchDocumentMap(
+ docs: SearchDocument[]
+): Map<string, SearchDocument> {
+ const map = new Map<string, SearchDocument>()
+ for (const doc of docs) {
+ const [path] = doc.location.split("#")
+
+ /* Add document article */
+ const article = map.get(path)
+ if (typeof article === "undefined") {
+ map.set(path, doc)
+
+ /* Add document section */
+ } else {
+ map.set(doc.location, doc)
+ doc.parent = article
+ }
+ }
+
+ /* Return search document map */
+ return map
+}
diff --git a/docs/src/templates/assets/javascripts/integrations/search/highlighter/index.ts b/docs/src/templates/assets/javascripts/integrations/search/highlighter/index.ts
new file mode 100644
index 00000000..0fcbb19e
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/search/highlighter/index.ts
@@ -0,0 +1,93 @@
+/*
+ * 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 escapeHTML from "escape-html"
+
+import { SearchConfig } from "../config"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Search highlight function
+ *
+ * @param value - Value
+ *
+ * @returns Highlighted value
+ */
+export type SearchHighlightFn = (value: string) => string
+
+/**
+ * Search highlight factory function
+ *
+ * @param query - Query value
+ *
+ * @returns Search highlight function
+ */
+export type SearchHighlightFactoryFn = (query: string) => SearchHighlightFn
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Create a search highlighter
+ *
+ * @param config - Search configuration
+ *
+ * @returns Search highlight factory function
+ */
+export function setupSearchHighlighter(
+ config: SearchConfig
+): SearchHighlightFactoryFn {
+ // Hack: temporarily remove pure lookaheads and lookbehinds
+ const regex = config.separator.split("|").map(term => {
+ const temp = term.replace(/(\(\?[!=<][^)]+\))/g, "")
+ return temp.length === 0 ? "�" : term
+ })
+ .join("|")
+
+ const separator = new RegExp(regex, "img")
+ const highlight = (_: unknown, data: string, term: string) => {
+ return `${data}<mark data-md-highlight>${term}</mark>`
+ }
+
+ /* Return factory function */
+ return (query: string) => {
+ query = query
+ .replace(/[\s*+\-:~^]+/g, " ")
+ .trim()
+
+ /* Create search term match expression */
+ const match = new RegExp(`(^|${config.separator}|)(${
+ query
+ .replace(/[|\\{}()[\]^$+*?.-]/g, "\\$&")
+ .replace(separator, "|")
+ })`, "img")
+
+ /* Highlight string value */
+ return value => escapeHTML(value)
+ .replace(match, highlight)
+ .replace(/<\/mark>(\s+)<mark[^>]*>/img, "$1")
+ }
+}
diff --git a/docs/src/templates/assets/javascripts/integrations/search/index.ts b/docs/src/templates/assets/javascripts/integrations/search/index.ts
new file mode 100644
index 00000000..94c010bb
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/search/index.ts
@@ -0,0 +1,27 @@
+/*
+ * 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 "./config"
+export * from "./highlighter"
+export * from "./query"
+export * from "./worker"
diff --git a/docs/src/templates/assets/javascripts/integrations/search/internal/.eslintrc b/docs/src/templates/assets/javascripts/integrations/search/internal/.eslintrc
new file mode 100644
index 00000000..9368ceb6
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/search/internal/.eslintrc
@@ -0,0 +1,6 @@
+{
+ "rules": {
+ "no-fallthrough": "off",
+ "no-underscore-dangle": "off"
+ }
+}
diff --git a/docs/src/templates/assets/javascripts/integrations/search/internal/_/index.ts b/docs/src/templates/assets/javascripts/integrations/search/internal/_/index.ts
new file mode 100644
index 00000000..ae8f6104
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/search/internal/_/index.ts
@@ -0,0 +1,74 @@
+/*
+ * 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.
+ */
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Visitor function
+ *
+ * @param start - Start offset
+ * @param end - End offset
+ */
+type VisitorFn = (
+ start: number, end: number
+) => void
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Split a string using the given separator
+ *
+ * @param input - Input value
+ * @param separator - Separator
+ * @param fn - Visitor function
+ */
+export function split(
+ input: string, separator: RegExp, fn: VisitorFn
+): void {
+ separator = new RegExp(separator, "g")
+
+ /* Split string using separator */
+ let match: RegExpExecArray | null
+ let index = 0
+ do {
+ match = separator.exec(input)
+
+ /* Emit non-empty range */
+ const until = match?.index ?? input.length
+ if (index < until)
+ fn(index, until)
+
+ /* Update last index */
+ if (match) {
+ const [term] = match
+ index = match.index + term.length
+
+ /* Support zero-length lookaheads */
+ if (term.length === 0)
+ separator.lastIndex = match.index + 1
+ }
+ } while (match)
+}
diff --git a/docs/src/templates/assets/javascripts/integrations/search/internal/extract/index.ts b/docs/src/templates/assets/javascripts/integrations/search/internal/extract/index.ts
new file mode 100644
index 00000000..2a98b9e1
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/search/internal/extract/index.ts
@@ -0,0 +1,107 @@
+/*
+ * 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.
+ */
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Extraction type
+ *
+ * This type defines the possible values that are encoded into the first two
+ * bits of a section that is part of the blocks of a tokenization table. There
+ * are three types of interest: HTML opening and closing tags, as well as the
+ * actual text content we need to extract for indexing.
+ */
+export const enum Extract {
+ TAG_OPEN = 0, /* HTML opening tag */
+ TEXT = 1, /* Text content */
+ TAG_CLOSE = 2 /* HTML closing tag */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Visitor function
+ *
+ * @param block - Block index
+ * @param type - Extraction type
+ * @param start - Start offset
+ * @param end - End offset
+ */
+type VisitorFn = (
+ block: number, type: Extract, start: number, end: number
+) => void
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Split a string into markup and text sections
+ *
+ * This function scans a string and divides it up into sections of markup and
+ * text. For each section, it invokes the given visitor function with the block
+ * index, extraction type, as well as start and end offsets. Using a visitor
+ * function (= streaming data) is ideal for minimizing pressure on the GC.
+ *
+ * @param input - Input value
+ * @param fn - Visitor function
+ */
+export function extract(
+ input: string, fn: VisitorFn
+): void {
+
+ let block = 0 /* Current block */
+ let start = 0 /* Current start offset */
+ let end = 0 /* Current end offset */
+
+ /* Split string into sections */
+ for (let stack = 0; end < input.length; end++) {
+
+ /* Opening tag after non-empty section */
+ if (input.charAt(end) === "<" && end > start) {
+ fn(block, Extract.TEXT, start, start = end)
+
+ /* Closing tag */
+ } else if (input.charAt(end) === ">") {
+ if (input.charAt(start + 1) === "/") {
+ if (--stack === 0)
+ fn(block++, Extract.TAG_CLOSE, start, end + 1)
+
+ /* Tag is not self-closing */
+ } else if (input.charAt(end - 1) !== "/") {
+ if (stack++ === 0)
+ fn(block, Extract.TAG_OPEN, start, end + 1)
+ }
+
+ /* New section */
+ start = end + 1
+ }
+ }
+
+ /* Add trailing section */
+ if (end > start)
+ fn(block, Extract.TEXT, start, end)
+}
diff --git a/docs/src/templates/assets/javascripts/integrations/search/internal/highlight/index.ts b/docs/src/templates/assets/javascripts/integrations/search/internal/highlight/index.ts
new file mode 100644
index 00000000..7cc3bf1a
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/search/internal/highlight/index.ts
@@ -0,0 +1,162 @@
+/*
+ * 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.
+ */
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Position table
+ */
+export type PositionTable = number[][]
+
+/**
+ * Position
+ */
+export type Position = number
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Highlight all occurrences in a string
+ *
+ * This function receives a field's value (e.g. like `title` or `text`), it's
+ * position table that was generated during indexing, and the positions found
+ * when executing the query. It then highlights all occurrences, and returns
+ * their concatenation. In case of multiple blocks, two are returned.
+ *
+ * @param input - Input value
+ * @param table - Table for indexing
+ * @param positions - Occurrences
+ * @param full - Full results
+ *
+ * @returns Highlighted string value
+ */
+export function highlight(
+ input: string, table: PositionTable, positions: Position[], full = false
+): string {
+ return highlightAll([input], table, positions, full).pop()!
+}
+
+/**
+ * Highlight all occurrences in a set of strings
+ *
+ * @param inputs - Input values
+ * @param table - Table for indexing
+ * @param positions - Occurrences
+ * @param full - Full results
+ *
+ * @returns Highlighted string values
+ */
+export function highlightAll(
+ inputs: string[], table: PositionTable, positions: Position[], full = false
+): string[] {
+
+ /* Map blocks to input values */
+ const mapping = [0]
+ for (let t = 1; t < table.length; t++) {
+ const prev = table[t - 1]
+ const next = table[t]
+
+ /* Check if table points to new block */
+ const p = prev[prev.length - 1] >>> 2 & 0x3FF
+ const q = next[0] >>> 12
+
+ /* Add block to mapping */
+ mapping.push(+(p > q) + mapping[mapping.length - 1])
+ }
+
+ /* Highlight strings one after another */
+ return inputs.map((input, i) => {
+ let cursor = 0
+
+ /* Map occurrences to blocks */
+ const blocks = new Map<number, number[]>()
+ for (const p of positions.sort((a, b) => a - b)) {
+ const index = p & 0xFFFFF
+ const block = p >>> 20
+ if (mapping[block] !== i)
+ continue
+
+ /* Ensure presence of block group */
+ let group = blocks.get(block)
+ if (typeof group === "undefined")
+ blocks.set(block, group = [])
+
+ /* Add index to group */
+ group.push(index)
+ }
+
+ /* Just return string, if no occurrences */
+ if (blocks.size === 0)
+ return input
+
+ /* Compute slices */
+ const slices: string[] = []
+ for (const [block, indexes] of blocks) {
+ const t = table[block]
+
+ /* Extract positions and length */
+ const start = t[0] >>> 12
+ const end = t[t.length - 1] >>> 12
+ const length = t[t.length - 1] >>> 2 & 0x3FF
+
+ /* Add prefix, if full results are desired */
+ if (full && start > cursor)
+ slices.push(input.slice(cursor, start))
+
+ /* Extract and highlight slice */
+ let slice = input.slice(start, end + length)
+ for (const j of indexes.sort((a, b) => b - a)) {
+
+ /* Retrieve offset and length of match */
+ const p = (t[j] >>> 12) - start
+ const q = (t[j] >>> 2 & 0x3FF) + p
+
+ /* Wrap occurrence */
+ slice = [
+ slice.slice(0, p),
+ "<mark>",
+ slice.slice(p, q),
+ "</mark>",
+ slice.slice(q)
+ ].join("")
+ }
+
+ /* Update cursor */
+ cursor = end + length
+
+ /* Append slice and abort if we have two */
+ if (slices.push(slice) === 2)
+ break
+ }
+
+ /* Add suffix, if full results are desired */
+ if (full && cursor < input.length)
+ slices.push(input.slice(cursor))
+
+ /* Return highlighted slices */
+ return slices.join("")
+ })
+}
diff --git a/docs/src/templates/assets/javascripts/integrations/search/internal/index.ts b/docs/src/templates/assets/javascripts/integrations/search/internal/index.ts
new file mode 100644
index 00000000..c752329e
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/search/internal/index.ts
@@ -0,0 +1,26 @@
+/*
+ * 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 "./extract"
+export * from "./highlight"
+export * from "./tokenize"
diff --git a/docs/src/templates/assets/javascripts/integrations/search/internal/tokenize/index.ts b/docs/src/templates/assets/javascripts/integrations/search/internal/tokenize/index.ts
new file mode 100644
index 00000000..f5089bc9
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/search/internal/tokenize/index.ts
@@ -0,0 +1,136 @@
+/*
+ * 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 { split } from "../_"
+import {
+ Extract,
+ extract
+} from "../extract"
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Split a string or set of strings into tokens
+ *
+ * This tokenizer supersedes the default tokenizer that is provided by Lunr.js,
+ * as it is aware of HTML tags and allows for multi-character splitting.
+ *
+ * It takes the given inputs, splits each of them into markup and text sections,
+ * tokenizes and segments (if necessary) each of them, and then indexes them in
+ * a table by using a compact bit representation. Bitwise techniques are used
+ * to write and read from the table during indexing and querying.
+ *
+ * @see https://bit.ly/3W3Xw4J - Search: better, faster, smaller
+ *
+ * @param input - Input value(s)
+ *
+ * @returns Tokens
+ */
+export function tokenize(
+ input?: string | string[]
+): lunr.Token[] {
+ const tokens: lunr.Token[] = []
+ if (typeof input === "undefined")
+ return tokens
+
+ /* Tokenize strings one after another */
+ const inputs = Array.isArray(input) ? input : [input]
+ for (let i = 0; i < inputs.length; i++) {
+ const table = lunr.tokenizer.table
+ const total = table.length
+
+ /* Split string into sections and tokenize content blocks */
+ extract(inputs[i], (block, type, start, end) => {
+ table[block += total] ||= []
+ switch (type) {
+
+ /* Handle markup */
+ case Extract.TAG_OPEN:
+ case Extract.TAG_CLOSE:
+ table[block].push(
+ start << 12 |
+ end - start << 2 |
+ type
+ )
+ break
+
+ /* Handle text content */
+ case Extract.TEXT:
+ const section = inputs[i].slice(start, end)
+ split(section, lunr.tokenizer.separator, (index, until) => {
+
+ /**
+ * Apply segmenter after tokenization. Note that the segmenter will
+ * also split words at word boundaries, which is not what we want,
+ * so we need to check if we can somehow mitigate this behavior.
+ */
+ if (typeof lunr.segmenter !== "undefined") {
+ const subsection = section.slice(index, until)
+ if (/^[MHIK]$/.test(lunr.segmenter.ctype_(subsection))) {
+ const segments = lunr.segmenter.segment(subsection)
+ for (let s = 0, l = 0; s < segments.length; s++) {
+
+ /* Add block to section */
+ table[block] ||= []
+ table[block].push(
+ start + index + l << 12 |
+ segments[s].length << 2 |
+ type
+ )
+
+ /* Add token with position */
+ tokens.push(new lunr.Token(
+ segments[s].toLowerCase(), {
+ position: block << 20 | table[block].length - 1
+ }
+ ))
+
+ /* Keep track of length */
+ l += segments[s].length
+ }
+ return
+ }
+ }
+
+ /* Add block to section */
+ table[block].push(
+ start + index << 12 |
+ until - index << 2 |
+ type
+ )
+
+ /* Add token with position */
+ tokens.push(new lunr.Token(
+ section.slice(index, until).toLowerCase(), {
+ position: block << 20 | table[block].length - 1
+ }
+ ))
+ })
+ }
+ })
+ }
+
+ /* Return tokens */
+ return tokens
+}
diff --git a/docs/src/templates/assets/javascripts/integrations/search/query/.eslintrc b/docs/src/templates/assets/javascripts/integrations/search/query/.eslintrc
new file mode 100644
index 00000000..3031c7e3
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/search/query/.eslintrc
@@ -0,0 +1,6 @@
+{
+ "rules": {
+ "no-control-regex": "off",
+ "@typescript-eslint/no-explicit-any": "off"
+ }
+}
diff --git a/docs/src/templates/assets/javascripts/integrations/search/query/_/index.ts b/docs/src/templates/assets/javascripts/integrations/search/query/_/index.ts
new file mode 100644
index 00000000..14482e43
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/search/query/_/index.ts
@@ -0,0 +1,172 @@
+/*
+ * 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 { split } from "../../internal"
+import { transform } from "../transform"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Search query clause
+ */
+export interface SearchQueryClause {
+ presence: lunr.Query.presence /* Clause presence */
+ term: string /* Clause term */
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Search query terms
+ */
+export type SearchQueryTerms = Record<string, boolean>
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Transform search query
+ *
+ * This function lexes the given search query and applies the transformation
+ * function to each term, preserving markup like `+` and `-` modifiers.
+ *
+ * @param query - Search query
+ *
+ * @returns Search query
+ */
+export function transformSearchQuery(
+ query: string
+): string {
+
+ /* Split query terms with tokenizer */
+ return transform(query, part => {
+ const terms: string[] = []
+
+ /* Initialize lexer and analyze part */
+ const lexer = new lunr.QueryLexer(part)
+ lexer.run()
+
+ /* Extract and tokenize term from lexeme */
+ for (const { type, str: term, start, end } of lexer.lexemes)
+ switch (type) {
+
+ /* Hack: remove colon - see https://bit.ly/3wD3T3I */
+ case "FIELD":
+ if (!["title", "text", "tags"].includes(term))
+ part = [
+ part.slice(0, end),
+ " ",
+ part.slice(end + 1)
+ ].join("")
+ break
+
+ /* Tokenize term */
+ case "TERM":
+ split(term, lunr.tokenizer.separator, (...range) => {
+ terms.push([
+ part.slice(0, start),
+ term.slice(...range),
+ part.slice(end)
+ ].join(""))
+ })
+ }
+
+ /* Return terms */
+ return terms
+ })
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Parse a search query for analysis
+ *
+ * Lunr.js itself has a bug where it doesn't detect or remove wildcards for
+ * query clauses, so we must do this here.
+ *
+ * @see https://bit.ly/3DpTGtz - GitHub issue
+ *
+ * @param value - Query value
+ *
+ * @returns Search query clauses
+ */
+export function parseSearchQuery(
+ value: string
+): SearchQueryClause[] {
+ const query = new lunr.Query(["title", "text", "tags"])
+ const parser = new lunr.QueryParser(value, query)
+
+ /* Parse Search query */
+ parser.parse()
+ for (const clause of query.clauses) {
+ clause.usePipeline = true
+
+ /* Handle leading wildcard */
+ if (clause.term.startsWith("*")) {
+ clause.wildcard = lunr.Query.wildcard.LEADING
+ clause.term = clause.term.slice(1)
+ }
+
+ /* Handle trailing wildcard */
+ if (clause.term.endsWith("*")) {
+ clause.wildcard = lunr.Query.wildcard.TRAILING
+ clause.term = clause.term.slice(0, -1)
+ }
+ }
+
+ /* Return query clauses */
+ return query.clauses
+}
+
+/**
+ * Analyze the search query clauses in regard to the search terms found
+ *
+ * @param query - Search query clauses
+ * @param terms - Search terms
+ *
+ * @returns Search query terms
+ */
+export function getSearchQueryTerms(
+ query: SearchQueryClause[], terms: string[]
+): SearchQueryTerms {
+ const clauses = new Set<SearchQueryClause>(query)
+
+ /* Match query clauses against terms */
+ const result: SearchQueryTerms = {}
+ for (let t = 0; t < terms.length; t++)
+ for (const clause of clauses)
+ if (terms[t].startsWith(clause.term)) {
+ result[clause.term] = true
+ clauses.delete(clause)
+ }
+
+ /* Annotate unmatched non-stopword query clauses */
+ for (const clause of clauses)
+ if (lunr.stopWordFilter?.(clause.term))
+ result[clause.term] = false
+
+ /* Return query terms */
+ return result
+}
diff --git a/docs/src/templates/assets/javascripts/integrations/search/query/index.ts b/docs/src/templates/assets/javascripts/integrations/search/query/index.ts
new file mode 100644
index 00000000..763e2fd4
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/search/query/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 "./segment"
+export * from "./transform"
diff --git a/docs/src/templates/assets/javascripts/integrations/search/query/segment/index.ts b/docs/src/templates/assets/javascripts/integrations/search/query/segment/index.ts
new file mode 100644
index 00000000..b96796f4
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/search/query/segment/index.ts
@@ -0,0 +1,81 @@
+/*
+ * 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.
+ */
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Segment a search query using the inverted index
+ *
+ * This function implements a clever approach to text segmentation for Asian
+ * languages, as it used the information already available in the search index.
+ * The idea is to greedily segment the search query based on the tokens that are
+ * already part of the index, as described in the linked issue.
+ *
+ * @see https://bit.ly/3lwjrk7 - GitHub issue
+ *
+ * @param query - Query value
+ * @param index - Inverted index
+ *
+ * @returns Segmented query value
+ */
+export function segment(
+ query: string, index: object
+): Iterable<string> {
+ const segments = new Set<string>()
+
+ /* Segment search query */
+ const wordcuts = new Uint16Array(query.length)
+ for (let i = 0; i < query.length; i++)
+ for (let j = i + 1; j < query.length; j++) {
+ const value = query.slice(i, j)
+ if (value in index)
+ wordcuts[i] = j - i
+ }
+
+ /* Compute longest matches with minimum overlap */
+ const stack = [0]
+ for (let s = stack.length; s > 0;) {
+ const p = stack[--s]
+ for (let q = 1; q < wordcuts[p]; q++)
+ if (wordcuts[p + q] > wordcuts[p] - q) {
+ segments.add(query.slice(p, p + q))
+ stack[s++] = p + q
+ }
+
+ /* Continue at end of query string */
+ const q = p + wordcuts[p]
+ if (wordcuts[q] && q < query.length - 1)
+ stack[s++] = q
+
+ /* Add current segment */
+ segments.add(query.slice(p, q))
+ }
+
+ // @todo fix this case in the code block above, this is a hotfix
+ if (segments.has(""))
+ return new Set([query])
+
+ /* Return segmented query value */
+ return segments
+}
diff --git a/docs/src/templates/assets/javascripts/integrations/search/query/transform/index.ts b/docs/src/templates/assets/javascripts/integrations/search/query/transform/index.ts
new file mode 100644
index 00000000..41497786
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/search/query/transform/index.ts
@@ -0,0 +1,99 @@
+/*
+ * 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.
+ */
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Visitor function
+ *
+ * @param value - String value
+ *
+ * @returns String term(s)
+ */
+type VisitorFn = (
+ value: string
+) => string | string[]
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Default transformation function
+ *
+ * 1. Trim excess whitespace from left and right.
+ *
+ * 2. Search for parts in quotation marks and prepend a `+` modifier to denote
+ * that the resulting document must contain all parts, converting the query
+ * to an `AND` query (as opposed to the default `OR` behavior). While users
+ * may expect parts enclosed in quotation marks to map to span queries, i.e.
+ * for which order is important, Lunr.js doesn't support them, so the best
+ * we can do is to convert the parts to an `AND` query.
+ *
+ * 3. Replace control characters which are not located at the beginning of the
+ * query or preceded by white space, or are not followed by a non-whitespace
+ * character or are at the end of the query string. Furthermore, filter
+ * unmatched quotation marks.
+ *
+ * 4. Split the query string at whitespace, then pass each part to the visitor
+ * function for tokenization, and append a wildcard to every resulting term
+ * that is not explicitly marked with a `+`, `-`, `~` or `^` modifier, since
+ * it ensures consistent and stable ranking when multiple terms are entered.
+ * Also, if a fuzzy or boost modifier are given, but no numeric value has
+ * been entered, default to 1 to not induce a query error.
+ *
+ * @param query - Query value
+ * @param fn - Visitor function
+ *
+ * @returns Transformed query value
+ */
+export function transform(
+ query: string, fn: VisitorFn = term => term
+): string {
+ return query
+
+ /* => 1 */
+ .trim()
+
+ /* => 2 */
+ .split(/"([^"]+)"/g)
+ .map((parts, index) => index & 1
+ ? parts.replace(/^\b|^(?![^\x00-\x7F]|$)|\s+/g, " +")
+ : parts
+ )
+ .join("")
+
+ /* => 3 */
+ .replace(/"|(?:^|\s+)[*+\-:^~]+(?=\s+|$)/g, "")
+
+ /* => 4 */
+ .split(/\s+/g)
+ .reduce((prev, term) => {
+ const next = fn(term)
+ return [...prev, ...Array.isArray(next) ? next : [next]]
+ }, [] as string[])
+ .map(term => /([~^]$)/.test(term) ? `${term}1` : term)
+ .map(term => /(^[+-]|[~^]\d+$)/.test(term) ? term : `${term}*`)
+ .join(" ")
+}
diff --git a/docs/src/templates/assets/javascripts/integrations/search/worker/_/index.ts b/docs/src/templates/assets/javascripts/integrations/search/worker/_/index.ts
new file mode 100644
index 00000000..26713573
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/search/worker/_/index.ts
@@ -0,0 +1,95 @@
+/*
+ * 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 RTICULAR 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 {
+ ObservableInput,
+ Subject,
+ first,
+ merge,
+ of,
+ switchMap
+} from "rxjs"
+
+import { feature } from "~/_"
+import { watchToggle, watchWorker } from "~/browser"
+
+import { SearchIndex } from "../../config"
+import {
+ SearchMessage,
+ SearchMessageType
+} from "../message"
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Set up search worker
+ *
+ * This function creates and initializes a web worker that is used for search,
+ * so that the user interface doesn't freeze. In general, the application does
+ * not care how search is implemented, as long as the web worker conforms to
+ * the format expected by the application as defined in `SearchMessage`. This
+ * allows the author to implement custom search functionality, by providing a
+ * custom web worker via configuration.
+ *
+ * Material for MkDocs' built-in search implementation makes use of Lunr.js, an
+ * efficient and fast implementation for client-side search. Leveraging a tiny
+ * iframe-based web worker shim, search is even supported for the `file://`
+ * protocol, enabling search for local non-hosted builds.
+ *
+ * If the protocol is `file://`, search initialization is deferred to mitigate
+ * freezing, as it's now synchronous by design - see https://bit.ly/3C521EO
+ *
+ * @see https://bit.ly/3igvtQv - How to implement custom search
+ *
+ * @param url - Worker URL
+ * @param index$ - Search index observable input
+ *
+ * @returns Search worker
+ */
+export function setupSearchWorker(
+ url: string, index$: ObservableInput<SearchIndex>
+): Subject<SearchMessage> {
+ const worker$ = watchWorker<SearchMessage>(url)
+ merge(
+ of(location.protocol !== "file:"),
+ watchToggle("search")
+ )
+ .pipe(
+ first(active => active),
+ switchMap(() => index$)
+ )
+ .subscribe(({ config, docs }) => worker$.next({
+ type: SearchMessageType.SETUP,
+ data: {
+ config,
+ docs,
+ options: {
+ suggest: feature("search.suggest")
+ }
+ }
+ }))
+
+ /* Return search worker */
+ return worker$
+}
diff --git a/docs/src/templates/assets/javascripts/integrations/search/worker/index.ts b/docs/src/templates/assets/javascripts/integrations/search/worker/index.ts
new file mode 100644
index 00000000..7120ad6e
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/search/worker/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 "./message"
diff --git a/docs/src/templates/assets/javascripts/integrations/search/worker/main/.eslintrc b/docs/src/templates/assets/javascripts/integrations/search/worker/main/.eslintrc
new file mode 100644
index 00000000..3df9d551
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/search/worker/main/.eslintrc
@@ -0,0 +1,6 @@
+{
+ "rules": {
+ "no-console": "off",
+ "@typescript-eslint/no-misused-promises": "off"
+ }
+}
diff --git a/docs/src/templates/assets/javascripts/integrations/search/worker/main/index.ts b/docs/src/templates/assets/javascripts/integrations/search/worker/main/index.ts
new file mode 100644
index 00000000..2df38080
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/search/worker/main/index.ts
@@ -0,0 +1,192 @@
+/*
+ * 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 RTICULAR 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 lunr from "lunr"
+
+import { getElement } from "~/browser/element/_"
+import "~/polyfills"
+
+import { Search } from "../../_"
+import { SearchConfig } from "../../config"
+import {
+ SearchMessage,
+ SearchMessageType
+} from "../message"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Add support for `iframe-worker` shim
+ *
+ * While `importScripts` is synchronous when executed inside of a web worker,
+ * it's not possible to provide a synchronous shim implementation. The cool
+ * thing is that awaiting a non-Promise will convert it into a Promise, so
+ * extending the type definition to return a `Promise` shouldn't break anything.
+ *
+ * @see https://bit.ly/2PjDnXi - GitHub comment
+ *
+ * @param urls - Scripts to load
+ *
+ * @returns Promise resolving with no result
+ */
+declare global {
+ function importScripts(...urls: string[]): Promise<void> | void
+}
+
+/* ----------------------------------------------------------------------------
+ * Data
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Search index
+ */
+let index: Search
+
+/* ----------------------------------------------------------------------------
+ * Helper functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Fetch (= import) multi-language support through `lunr-languages`
+ *
+ * This function automatically imports the stemmers necessary to process the
+ * languages which are defined as part of the search configuration.
+ *
+ * If the worker runs inside of an `iframe` (when using `iframe-worker` as
+ * a shim), the base URL for the stemmers to be loaded must be determined by
+ * searching for the first `script` element with a `src` attribute, which will
+ * contain the contents of this script.
+ *
+ * @param config - Search configuration
+ *
+ * @returns Promise resolving with no result
+ */
+async function setupSearchLanguages(
+ config: SearchConfig
+): Promise<void> {
+ let base = "../lunr"
+
+ /* Detect `iframe-worker` and fix base URL */
+ if (typeof parent !== "undefined" && "IFrameWorker" in parent) {
+ const worker = getElement<HTMLScriptElement>("script[src]")!
+ const [path] = worker.src.split("/worker")
+
+ /* Prefix base with path */
+ base = base.replace("..", path)
+ }
+
+ /* Add scripts for languages */
+ const scripts = []
+ for (const lang of config.lang) {
+ switch (lang) {
+
+ /* Add segmenter for Japanese */
+ case "ja":
+ scripts.push(`${base}/tinyseg.js`)
+ break
+
+ /* Add segmenter for Hindi and Thai */
+ case "hi":
+ case "th":
+ scripts.push(`${base}/wordcut.js`)
+ break
+ }
+
+ /* Add language support */
+ if (lang !== "en")
+ scripts.push(`${base}/min/lunr.${lang}.min.js`)
+ }
+
+ /* Add multi-language support */
+ if (config.lang.length > 1)
+ scripts.push(`${base}/min/lunr.multi.min.js`)
+
+ /* Load scripts synchronously */
+ if (scripts.length)
+ await importScripts(
+ `${base}/min/lunr.stemmer.support.min.js`,
+ ...scripts
+ )
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Message handler
+ *
+ * @param message - Source message
+ *
+ * @returns Target message
+ */
+export async function handler(
+ message: SearchMessage
+): Promise<SearchMessage> {
+ switch (message.type) {
+
+ /* Search setup message */
+ case SearchMessageType.SETUP:
+ await setupSearchLanguages(message.data.config)
+ index = new Search(message.data)
+ return {
+ type: SearchMessageType.READY
+ }
+
+ /* Search query message */
+ case SearchMessageType.QUERY:
+ const query = message.data
+ try {
+ return {
+ type: SearchMessageType.RESULT,
+ data: index.search(query)
+ }
+
+ /* Return empty result in case of error */
+ } catch (err) {
+ console.warn(`Invalid query: ${query} – see https://bit.ly/2s3ChXG`)
+ console.warn(err)
+ return {
+ type: SearchMessageType.RESULT,
+ data: { items: [] }
+ }
+ }
+
+ /* All other messages */
+ default:
+ throw new TypeError("Invalid message type")
+ }
+}
+
+/* ----------------------------------------------------------------------------
+ * Worker
+ * ------------------------------------------------------------------------- */
+
+/* Expose Lunr.js in global scope, or stemmers won't work */
+self.lunr = lunr
+
+/* Handle messages */
+addEventListener("message", async ev => {
+ postMessage(await handler(ev.data))
+})
diff --git a/docs/src/templates/assets/javascripts/integrations/search/worker/message/index.ts b/docs/src/templates/assets/javascripts/integrations/search/worker/message/index.ts
new file mode 100644
index 00000000..54d5001e
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/search/worker/message/index.ts
@@ -0,0 +1,112 @@
+/*
+ * 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 RTICULAR 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 { SearchResult } from "../../_"
+import { SearchIndex } from "../../config"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Search message type
+ */
+export const enum SearchMessageType {
+ SETUP, /* Search index setup */
+ READY, /* Search index ready */
+ QUERY, /* Search query */
+ RESULT /* Search results */
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Message containing the data necessary to setup the search index
+ */
+export interface SearchSetupMessage {
+ type: SearchMessageType.SETUP /* Message type */
+ data: SearchIndex /* Message data */
+}
+
+/**
+ * Message indicating the search index is ready
+ */
+export interface SearchReadyMessage {
+ type: SearchMessageType.READY /* Message type */
+}
+
+/**
+ * Message containing a search query
+ */
+export interface SearchQueryMessage {
+ type: SearchMessageType.QUERY /* Message type */
+ data: string /* Message data */
+}
+
+/**
+ * Message containing results for a search query
+ */
+export interface SearchResultMessage {
+ type: SearchMessageType.RESULT /* Message type */
+ data: SearchResult /* Message data */
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Message exchanged with the search worker
+ */
+export type SearchMessage =
+ | SearchSetupMessage
+ | SearchReadyMessage
+ | SearchQueryMessage
+ | SearchResultMessage
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Type guard for search ready messages
+ *
+ * @param message - Search worker message
+ *
+ * @returns Test result
+ */
+export function isSearchReadyMessage(
+ message: SearchMessage
+): message is SearchReadyMessage {
+ return message.type === SearchMessageType.READY
+}
+
+/**
+ * Type guard for search result messages
+ *
+ * @param message - Search worker message
+ *
+ * @returns Test result
+ */
+export function isSearchResultMessage(
+ message: SearchMessage
+): message is SearchResultMessage {
+ return message.type === SearchMessageType.RESULT
+}
diff --git a/docs/src/templates/assets/javascripts/integrations/sitemap/index.ts b/docs/src/templates/assets/javascripts/integrations/sitemap/index.ts
new file mode 100644
index 00000000..08695bad
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/sitemap/index.ts
@@ -0,0 +1,107 @@
+/*
+ * 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,
+ catchError,
+ defaultIfEmpty,
+ map,
+ of,
+ tap
+} from "rxjs"
+
+import { configuration } from "~/_"
+import { getElements, requestXML } from "~/browser"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Sitemap, i.e. a list of URLs
+ */
+export type Sitemap = string[]
+
+/* ----------------------------------------------------------------------------
+ * Helper functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Preprocess a list of URLs
+ *
+ * This function replaces the `site_url` in the sitemap with the actual base
+ * URL, to allow instant navigation to work in occasions like Netlify previews.
+ *
+ * @param urls - URLs
+ *
+ * @returns URL path parts
+ */
+function preprocess(urls: Sitemap): Sitemap {
+ if (urls.length < 2)
+ return [""]
+
+ /* Take the first two URLs and remove everything after the last slash */
+ const [root, next] = [...urls]
+ .sort((a, b) => a.length - b.length)
+ .map(url => url.replace(/[^/]+$/, ""))
+
+ /* Compute common prefix */
+ let index = 0
+ if (root === next)
+ index = root.length
+ else
+ while (root.charCodeAt(index) === next.charCodeAt(index))
+ index++
+
+ /* Remove common prefix and return in original order */
+ return urls.map(url => url.replace(root.slice(0, index), ""))
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Fetch the sitemap for the given base URL
+ *
+ * @param base - Base URL
+ *
+ * @returns Sitemap observable
+ */
+export function fetchSitemap(base?: URL): Observable<Sitemap> {
+ const cached = __md_get<Sitemap>("__sitemap", sessionStorage, base)
+ if (cached) {
+ return of(cached)
+ } else {
+ const config = configuration()
+ return requestXML(new URL("sitemap.xml", base || config.base))
+ .pipe(
+ map(sitemap => preprocess(getElements("loc", sitemap)
+ .map(node => node.textContent!)
+ )),
+ catchError(() => EMPTY), // @todo refactor instant loading
+ defaultIfEmpty([]),
+ tap(sitemap => __md_set("__sitemap", sitemap, sessionStorage, base))
+ )
+ }
+}
diff --git a/docs/src/templates/assets/javascripts/integrations/version/.eslintrc b/docs/src/templates/assets/javascripts/integrations/version/.eslintrc
new file mode 100644
index 00000000..38a5714d
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/version/.eslintrc
@@ -0,0 +1,5 @@
+{
+ "rules": {
+ "no-null/no-null": "off"
+ }
+}
diff --git a/docs/src/templates/assets/javascripts/integrations/version/index.ts b/docs/src/templates/assets/javascripts/integrations/version/index.ts
new file mode 100644
index 00000000..38d78f17
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/integrations/version/index.ts
@@ -0,0 +1,186 @@
+/*
+ * 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,
+ Subject,
+ catchError,
+ combineLatest,
+ filter,
+ fromEvent,
+ map,
+ of,
+ switchMap,
+ withLatestFrom
+} from "rxjs"
+
+import { configuration } from "~/_"
+import {
+ getElement,
+ getLocation,
+ requestJSON,
+ setLocation
+} from "~/browser"
+import { getComponentElements } from "~/components"
+import {
+ Version,
+ renderVersionSelector
+} from "~/templates"
+
+import { fetchSitemap } from "../sitemap"
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Setup options
+ */
+interface SetupOptions {
+ document$: Subject<Document> /* Document subject */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Set up version selector
+ *
+ * @param options - Options
+ */
+export function setupVersionSelector(
+ { document$ }: SetupOptions
+): void {
+ const config = configuration()
+ const versions$ = requestJSON<Version[]>(
+ new URL("../versions.json", config.base)
+ )
+ .pipe(
+ catchError(() => EMPTY) // @todo refactor instant loading
+ )
+
+ /* Determine current version */
+ const current$ = versions$
+ .pipe(
+ map(versions => {
+ const [, current] = config.base.match(/([^/]+)\/?$/)!
+ return versions.find(({ version, aliases }) => (
+ version === current || aliases.includes(current)
+ )) || versions[0]
+ })
+ )
+
+ /* Intercept inter-version navigation */
+ versions$
+ .pipe(
+ map(versions => new Map(versions.map(version => [
+ `${new URL(`../${version.version}/`, config.base)}`,
+ version
+ ]))),
+ switchMap(urls => fromEvent<MouseEvent>(document.body, "click")
+ .pipe(
+ filter(ev => !ev.metaKey && !ev.ctrlKey),
+ withLatestFrom(current$),
+ switchMap(([ev, current]) => {
+ if (ev.target instanceof Element) {
+ const el = ev.target.closest("a")
+ if (el && !el.target && urls.has(el.href)) {
+ const url = el.href
+ // This is a temporary hack to detect if a version inside the
+ // version selector or on another part of the site was clicked.
+ // If we're inside the version selector, we definitely want to
+ // find the same page, as we might have different deployments
+ // due to aliases. However, if we're outside the version
+ // selector, we must abort here, because we might otherwise
+ // interfere with instant navigation. We need to refactor this
+ // at some point together with instant navigation.
+ //
+ // See https://github.com/squidfunk/mkdocs-material/issues/4012
+ if (!ev.target.closest(".md-version")) {
+ const version = urls.get(url)!
+ if (version === current)
+ return EMPTY
+ }
+ ev.preventDefault()
+ return of(url)
+ }
+ }
+ return EMPTY
+ }),
+ switchMap(url => {
+ const { version } = urls.get(url)!
+ return fetchSitemap(new URL(url))
+ .pipe(
+ map(sitemap => {
+ const location = getLocation()
+ const path = location.href.replace(config.base, "")
+ return sitemap.includes(path.split("#")[0])
+ ? new URL(`../${version}/${path}`, config.base)
+ : new URL(url)
+ })
+ )
+ })
+ )
+ )
+ )
+ .subscribe(url => setLocation(url, true))
+
+ /* Render version selector and warning */
+ combineLatest([versions$, current$])
+ .subscribe(([versions, current]) => {
+ const topic = getElement(".md-header__topic")
+ topic.appendChild(renderVersionSelector(versions, current))
+ })
+
+ /* Integrate outdated version banner with instant navigation */
+ document$.pipe(switchMap(() => current$))
+ .subscribe(current => {
+
+ /* Check if version state was already determined */
+ let outdated = __md_get("__outdated", sessionStorage)
+ if (outdated === null) {
+ outdated = true
+
+ /* Obtain and normalize default versions */
+ let ignored = config.version?.default || "latest"
+ if (!Array.isArray(ignored))
+ ignored = [ignored]
+
+ /* Check if version is considered a default */
+ main: for (const ignore of ignored)
+ for (const alias of current.aliases)
+ if (new RegExp(ignore, "i").test(alias)) {
+ outdated = false
+ break main
+ }
+
+ /* Persist version state in session storage */
+ __md_set("__outdated", outdated, sessionStorage)
+ }
+
+ /* Unhide outdated version banner */
+ if (outdated)
+ for (const warning of getComponentElements("outdated"))
+ warning.hidden = false
+ })
+}
diff --git a/docs/src/templates/assets/javascripts/patches/indeterminate/index.ts b/docs/src/templates/assets/javascripts/patches/indeterminate/index.ts
new file mode 100644
index 00000000..9b7b0d5a
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/patches/indeterminate/index.ts
@@ -0,0 +1,85 @@
+/*
+ * 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,
+ fromEvent,
+ map,
+ mergeMap,
+ switchMap,
+ takeWhile,
+ tap,
+ withLatestFrom
+} from "rxjs"
+
+import { getElements } from "~/browser"
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Patch options
+ */
+interface PatchOptions {
+ document$: Observable<Document> /* Document observable */
+ tablet$: Observable<boolean> /* Media tablet observable */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Patch indeterminate checkboxes
+ *
+ * This function replaces the indeterminate "pseudo state" with the actual
+ * indeterminate state, which is used to keep navigation always expanded.
+ *
+ * @param options - Options
+ */
+export function patchIndeterminate(
+ { document$, tablet$ }: PatchOptions
+): void {
+ document$
+ .pipe(
+ switchMap(() => getElements<HTMLInputElement>(
+ ".md-toggle--indeterminate"
+ )),
+ tap(el => {
+ el.indeterminate = true
+ el.checked = false
+ }),
+ mergeMap(el => fromEvent(el, "change")
+ .pipe(
+ takeWhile(() => el.classList.contains("md-toggle--indeterminate")),
+ map(() => el)
+ )
+ ),
+ withLatestFrom(tablet$)
+ )
+ .subscribe(([el, tablet]) => {
+ el.classList.remove("md-toggle--indeterminate")
+ if (tablet)
+ el.checked = false
+ })
+}
diff --git a/docs/src/templates/assets/javascripts/patches/index.ts b/docs/src/templates/assets/javascripts/patches/index.ts
new file mode 100644
index 00000000..b6e65fc0
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/patches/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 "./indeterminate"
+export * from "./scrollfix"
+export * from "./scrolllock"
diff --git a/docs/src/templates/assets/javascripts/patches/scrollfix/index.ts b/docs/src/templates/assets/javascripts/patches/scrollfix/index.ts
new file mode 100644
index 00000000..607c46a0
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/patches/scrollfix/index.ts
@@ -0,0 +1,100 @@
+/*
+ * 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,
+ filter,
+ fromEvent,
+ map,
+ mergeMap,
+ switchMap,
+ tap
+} from "rxjs"
+
+import { getElements } from "~/browser"
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Patch options
+ */
+interface PatchOptions {
+ document$: Observable<Document> /* Document observable */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Check whether the given device is an Apple device
+ *
+ * @returns Test result
+ */
+function isAppleDevice(): boolean {
+ return /(iPad|iPhone|iPod)/.test(navigator.userAgent)
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Patch all elements with `data-md-scrollfix` attributes
+ *
+ * This is a year-old patch which ensures that overflow scrolling works at the
+ * top and bottom of containers on iOS by ensuring a `1px` scroll offset upon
+ * the start of a touch event.
+ *
+ * @see https://bit.ly/2SCtAOO - Original source
+ *
+ * @param options - Options
+ */
+export function patchScrollfix(
+ { document$ }: PatchOptions
+): void {
+ document$
+ .pipe(
+ switchMap(() => getElements("[data-md-scrollfix]")),
+ tap(el => el.removeAttribute("data-md-scrollfix")),
+ filter(isAppleDevice),
+ mergeMap(el => fromEvent(el, "touchstart")
+ .pipe(
+ map(() => el)
+ )
+ )
+ )
+ .subscribe(el => {
+ const top = el.scrollTop
+
+ /* We're at the top of the container */
+ if (top === 0) {
+ el.scrollTop = 1
+
+ /* We're at the bottom of the container */
+ } else if (top + el.offsetHeight === el.scrollHeight) {
+ el.scrollTop = top - 1
+ }
+ })
+}
diff --git a/docs/src/templates/assets/javascripts/patches/scrolllock/index.ts b/docs/src/templates/assets/javascripts/patches/scrolllock/index.ts
new file mode 100644
index 00000000..4ec3e103
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/patches/scrolllock/index.ts
@@ -0,0 +1,89 @@
+/*
+ * 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,
+ delay,
+ map,
+ of,
+ switchMap,
+ withLatestFrom
+} from "rxjs"
+
+import {
+ Viewport,
+ watchToggle
+} from "~/browser"
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Patch options
+ */
+interface PatchOptions {
+ viewport$: Observable<Viewport> /* Viewport observable */
+ tablet$: Observable<boolean> /* Media tablet observable */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Patch the document body to lock when search is open
+ *
+ * For mobile and tablet viewports, the search is rendered full screen, which
+ * leads to scroll leaking when at the top or bottom of the search result. This
+ * function locks the body when the search is in full screen mode, and restores
+ * the scroll position when leaving.
+ *
+ * @param options - Options
+ */
+export function patchScrolllock(
+ { viewport$, tablet$ }: PatchOptions
+): void {
+ combineLatest([watchToggle("search"), tablet$])
+ .pipe(
+ map(([active, tablet]) => active && !tablet),
+ switchMap(active => of(active)
+ .pipe(
+ delay(active ? 400 : 100)
+ )
+ ),
+ withLatestFrom(viewport$)
+ )
+ .subscribe(([active, { offset: { y }}]) => {
+ if (active) {
+ document.body.setAttribute("data-md-scrolllock", "")
+ document.body.style.top = `-${y}px`
+ } else {
+ const value = -1 * parseInt(document.body.style.top, 10)
+ document.body.removeAttribute("data-md-scrolllock")
+ document.body.style.top = ""
+ if (value)
+ window.scrollTo(0, value)
+ }
+ })
+}
diff --git a/docs/src/templates/assets/javascripts/polyfills/index.ts b/docs/src/templates/assets/javascripts/polyfills/index.ts
new file mode 100644
index 00000000..2aec8290
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/polyfills/index.ts
@@ -0,0 +1,96 @@
+/*
+ * 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.
+ */
+
+/* ----------------------------------------------------------------------------
+ * Polyfills
+ * ------------------------------------------------------------------------- */
+
+/* Polyfill `Object.entries` */
+if (!Object.entries)
+ Object.entries = function (obj: object) {
+ const data: [string, string][] = []
+ for (const key of Object.keys(obj))
+ // @ts-expect-error - ignore property access warning
+ data.push([key, obj[key]])
+
+ /* Return entries */
+ return data
+ }
+
+/* Polyfill `Object.values` */
+if (!Object.values)
+ Object.values = function (obj: object) {
+ const data: string[] = []
+ for (const key of Object.keys(obj))
+ // @ts-expect-error - ignore property access warning
+ data.push(obj[key])
+
+ /* Return values */
+ return data
+ }
+
+/* ------------------------------------------------------------------------- */
+
+/* Polyfills for `Element` */
+if (typeof Element !== "undefined") {
+
+ /* Polyfill `Element.scrollTo` */
+ if (!Element.prototype.scrollTo)
+ Element.prototype.scrollTo = function (
+ x?: ScrollToOptions | number, y?: number
+ ): void {
+ if (typeof x === "object") {
+ this.scrollLeft = x.left!
+ this.scrollTop = x.top!
+ } else {
+ this.scrollLeft = x!
+ this.scrollTop = y!
+ }
+ }
+
+ /* Polyfill `Element.replaceWith` */
+ if (!Element.prototype.replaceWith)
+ Element.prototype.replaceWith = function (
+ ...nodes: Array<string | Node>
+ ): void {
+ const parent = this.parentNode
+ if (parent) {
+ if (nodes.length === 0)
+ parent.removeChild(this)
+
+ /* Replace children and create text nodes */
+ for (let i = nodes.length - 1; i >= 0; i--) {
+ let node = nodes[i]
+ if (typeof node === "string")
+ node = document.createTextNode(node)
+ else if (node.parentNode)
+ node.parentNode.removeChild(node)
+
+ /* Replace child or insert before previous sibling */
+ if (!i)
+ parent.replaceChild(node, this)
+ else
+ parent.insertBefore(this.previousSibling!, node)
+ }
+ }
+ }
+}
diff --git a/docs/src/templates/assets/javascripts/templates/annotation/index.tsx b/docs/src/templates/assets/javascripts/templates/annotation/index.tsx
new file mode 100644
index 00000000..9b8f85f5
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/templates/annotation/index.tsx
@@ -0,0 +1,65 @@
+/*
+ * 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 { h } from "~/utilities"
+
+import { renderTooltip } from "../tooltip"
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Render an annotation
+ *
+ * @param id - Annotation identifier
+ * @param prefix - Tooltip identifier prefix
+ *
+ * @returns Element
+ */
+export function renderAnnotation(
+ id: string | number, prefix?: string
+): HTMLElement {
+ prefix = prefix ? `${prefix}_annotation_${id}` : undefined
+
+ /* Render tooltip with anchor, if given */
+ if (prefix) {
+ const anchor = prefix ? `#${prefix}` : undefined
+ return (
+ <aside class="md-annotation" tabIndex={0}>
+ {renderTooltip(prefix)}
+ <a href={anchor} class="md-annotation__index" tabIndex={-1}>
+ <span data-md-annotation-id={id}></span>
+ </a>
+ </aside>
+ )
+ } else {
+ return (
+ <aside class="md-annotation" tabIndex={0}>
+ {renderTooltip(prefix)}
+ <span class="md-annotation__index" tabIndex={-1}>
+ <span data-md-annotation-id={id}></span>
+ </span>
+ </aside>
+ )
+ }
+}
diff --git a/docs/src/templates/assets/javascripts/templates/clipboard/index.tsx b/docs/src/templates/assets/javascripts/templates/clipboard/index.tsx
new file mode 100644
index 00000000..95dbf12a
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/templates/clipboard/index.tsx
@@ -0,0 +1,45 @@
+/*
+ * 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 { translation } from "~/_"
+import { h } from "~/utilities"
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Render a 'copy-to-clipboard' button
+ *
+ * @param id - Unique identifier
+ *
+ * @returns Element
+ */
+export function renderClipboardButton(id: string): HTMLElement {
+ return (
+ <button
+ class="md-clipboard md-icon"
+ title={translation("clipboard.copy")}
+ data-clipboard-target={`#${id} > code`}
+ ></button>
+ )
+}
diff --git a/docs/src/templates/assets/javascripts/templates/index.ts b/docs/src/templates/assets/javascripts/templates/index.ts
new file mode 100644
index 00000000..b50b93b8
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/templates/index.ts
@@ -0,0 +1,29 @@
+/*
+ * 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 "./annotation"
+export * from "./clipboard"
+export * from "./search"
+export * from "./source"
+export * from "./tabbed"
+export * from "./table"
+export * from "./version"
diff --git a/docs/src/templates/assets/javascripts/templates/search/index.tsx b/docs/src/templates/assets/javascripts/templates/search/index.tsx
new file mode 100644
index 00000000..350c0505
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/templates/search/index.tsx
@@ -0,0 +1,170 @@
+/*
+ * 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 { ComponentChild } from "preact"
+
+import { configuration, feature, translation } from "~/_"
+import { SearchItem } from "~/integrations/search"
+import { h } from "~/utilities"
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Render flag
+ */
+const enum Flag {
+ TEASER = 1, /* Render teaser */
+ PARENT = 2 /* Render as parent */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper function
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Render a search document
+ *
+ * @param document - Search document
+ * @param flag - Render flags
+ *
+ * @returns Element
+ */
+function renderSearchDocument(
+ document: SearchItem, flag: Flag
+): HTMLElement {
+ const parent = flag & Flag.PARENT
+ const teaser = flag & Flag.TEASER
+
+ /* Render missing query terms */
+ const missing = Object.keys(document.terms)
+ .filter(key => !document.terms[key])
+ .reduce<ComponentChild[]>((list, key) => [
+ ...list, <del>{key}</del>, " "
+ ], [])
+ .slice(0, -1)
+
+ /* Assemble query string for highlighting */
+ const config = configuration()
+ const url = new URL(document.location, config.base)
+ if (feature("search.highlight"))
+ url.searchParams.set("h", Object.entries(document.terms)
+ .filter(([, match]) => match)
+ .reduce((highlight, [value]) => `${highlight} ${value}`.trim(), "")
+ )
+
+ /* Render article or section, depending on flags */
+ const { tags } = configuration()
+ return (
+ <a href={`${url}`} class="md-search-result__link" tabIndex={-1}>
+ <article
+ class="md-search-result__article md-typeset"
+ data-md-score={document.score.toFixed(2)}
+ >
+ {parent > 0 && <div class="md-search-result__icon md-icon"></div>}
+ {parent > 0 && <h1>{document.title}</h1>}
+ {parent <= 0 && <h2>{document.title}</h2>}
+ {teaser > 0 && document.text.length > 0 &&
+ document.text
+ }
+ {document.tags && document.tags.map(tag => {
+ const type = tags
+ ? tag in tags
+ ? `md-tag-icon md-tag--${tags[tag]}`
+ : "md-tag-icon"
+ : ""
+ return (
+ <span class={`md-tag ${type}`}>{tag}</span>
+ )
+ })}
+ {teaser > 0 && missing.length > 0 &&
+ <p class="md-search-result__terms">
+ {translation("search.result.term.missing")}: {...missing}
+ </p>
+ }
+ </article>
+ </a>
+ )
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Render a search result
+ *
+ * @param result - Search result
+ *
+ * @returns Element
+ */
+export function renderSearchResultItem(
+ result: SearchItem[]
+): HTMLElement {
+ const threshold = result[0].score
+ const docs = [...result]
+
+ const config = configuration()
+
+ /* Find and extract parent article */
+ const parent = docs.findIndex(doc => {
+ const l = `${new URL(doc.location, config.base)}` // @todo hacky
+ return !l.includes("#")
+ })
+ const [article] = docs.splice(parent, 1)
+
+ /* Determine last index above threshold */
+ let index = docs.findIndex(doc => doc.score < threshold)
+ if (index === -1)
+ index = docs.length
+
+ /* Partition sections */
+ const best = docs.slice(0, index)
+ const more = docs.slice(index)
+
+ /* Render children */
+ const children = [
+ renderSearchDocument(article, Flag.PARENT | +(!parent && index === 0)),
+ ...best.map(section => renderSearchDocument(section, Flag.TEASER)),
+ ...more.length ? [
+ <details class="md-search-result__more">
+ <summary tabIndex={-1}>
+ <div>
+ {more.length > 0 && more.length === 1
+ ? translation("search.result.more.one")
+ : translation("search.result.more.other", more.length)
+ }
+ </div>
+ </summary>
+ {...more.map(section => renderSearchDocument(section, Flag.TEASER))}
+ </details>
+ ] : []
+ ]
+
+ /* Render search result */
+ return (
+ <li class="md-search-result__item">
+ {children}
+ </li>
+ )
+}
diff --git a/docs/src/templates/assets/javascripts/templates/source/index.tsx b/docs/src/templates/assets/javascripts/templates/source/index.tsx
new file mode 100644
index 00000000..b59a8f67
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/templates/source/index.tsx
@@ -0,0 +1,47 @@
+/*
+ * 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 { SourceFacts } from "~/components"
+import { h, round } from "~/utilities"
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Render repository facts
+ *
+ * @param facts - Repository facts
+ *
+ * @returns Element
+ */
+export function renderSourceFacts(facts: SourceFacts): HTMLElement {
+ return (
+ <ul class="md-source__facts">
+ {Object.entries(facts).map(([key, value]) => (
+ <li class={`md-source__fact md-source__fact--${key}`}>
+ {typeof value === "number" ? round(value) : value}
+ </li>
+ ))}
+ </ul>
+ )
+}
diff --git a/docs/src/templates/assets/javascripts/templates/tabbed/index.tsx b/docs/src/templates/assets/javascripts/templates/tabbed/index.tsx
new file mode 100644
index 00000000..b283ac66
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/templates/tabbed/index.tsx
@@ -0,0 +1,56 @@
+/*
+ * 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 { h } from "~/utilities"
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Tabbed control type
+ */
+type TabbedControlType =
+ | "prev"
+ | "next"
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Render control for content tabs
+ *
+ * @param type - Control type
+ *
+ * @returns Element
+ */
+export function renderTabbedControl(
+ type: TabbedControlType
+): HTMLElement {
+ const classes = `tabbed-control tabbed-control--${type}`
+ return (
+ <div class={classes} hidden>
+ <button class="tabbed-button" tabIndex={-1} aria-hidden="true"></button>
+ </div>
+ )
+}
diff --git a/docs/src/templates/assets/javascripts/templates/table/index.tsx b/docs/src/templates/assets/javascripts/templates/table/index.tsx
new file mode 100644
index 00000000..1fcba152
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/templates/table/index.tsx
@@ -0,0 +1,44 @@
+/*
+ * 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 { h } from "~/utilities"
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Render a table inside a wrapper to improve scrolling on mobile
+ *
+ * @param table - Table element
+ *
+ * @returns Element
+ */
+export function renderTable(table: HTMLElement): HTMLElement {
+ return (
+ <div class="md-typeset__scrollwrap">
+ <div class="md-typeset__table">
+ {table}
+ </div>
+ </div>
+ )
+}
diff --git a/docs/src/templates/assets/javascripts/templates/tooltip/index.tsx b/docs/src/templates/assets/javascripts/templates/tooltip/index.tsx
new file mode 100644
index 00000000..ec583490
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/templates/tooltip/index.tsx
@@ -0,0 +1,42 @@
+/*
+ * 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 { h } from "~/utilities"
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Render a tooltip
+ *
+ * @param id - Tooltip identifier
+ *
+ * @returns Element
+ */
+export function renderTooltip(id?: string): HTMLElement {
+ return (
+ <div class="md-tooltip" id={id}>
+ <div class="md-tooltip__inner md-typeset"></div>
+ </div>
+ )
+}
diff --git a/docs/src/templates/assets/javascripts/templates/version/index.tsx b/docs/src/templates/assets/javascripts/templates/version/index.tsx
new file mode 100644
index 00000000..4aff7aa7
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/templates/version/index.tsx
@@ -0,0 +1,92 @@
+/*
+ * 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 { configuration, translation } from "~/_"
+import { h } from "~/utilities"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Version
+ */
+export interface Version {
+ version: string /* Version identifier */
+ title: string /* Version title */
+ aliases: string[] /* Version aliases */
+}
+
+/* ----------------------------------------------------------------------------
+ * Helper functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Render a version
+ *
+ * @param version - Version
+ *
+ * @returns Element
+ */
+function renderVersion(version: Version): HTMLElement {
+ const config = configuration()
+
+ /* Ensure trailing slash - see https://bit.ly/3rL5u3f */
+ const url = new URL(`../${version.version}/`, config.base)
+ return (
+ <li class="md-version__item">
+ <a href={`${url}`} class="md-version__link">
+ {version.title}
+ </a>
+ </li>
+ )
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Render a version selector
+ *
+ * @param versions - Versions
+ * @param active - Active version
+ *
+ * @returns Element
+ */
+export function renderVersionSelector(
+ versions: Version[], active: Version
+): HTMLElement {
+ return (
+ <div class="md-version">
+ <button
+ class="md-version__current"
+ aria-label={translation("select.version")}
+ >
+ {active.title}
+ </button>
+ <ul class="md-version__list">
+ {versions.map(renderVersion)}
+ </ul>
+ </div>
+ )
+}
diff --git a/docs/src/templates/assets/javascripts/utilities/h/.eslintrc b/docs/src/templates/assets/javascripts/utilities/h/.eslintrc
new file mode 100644
index 00000000..d79b45b0
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/utilities/h/.eslintrc
@@ -0,0 +1,7 @@
+{
+ "rules": {
+ "@typescript-eslint/no-explicit-any": "off",
+ "@typescript-eslint/no-namespace": "off",
+ "jsdoc/require-jsdoc": "off"
+ }
+}
diff --git a/docs/src/templates/assets/javascripts/utilities/h/index.ts b/docs/src/templates/assets/javascripts/utilities/h/index.ts
new file mode 100644
index 00000000..08d809f1
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/utilities/h/index.ts
@@ -0,0 +1,132 @@
+/*
+ * 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 { JSX as JSXInternal } from "preact"
+
+/* ----------------------------------------------------------------------------
+ * Helper types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * HTML attributes
+ */
+type Attributes =
+ & JSXInternal.HTMLAttributes
+ & JSXInternal.SVGAttributes
+ & Record<string, any>
+
+/**
+ * Child element
+ */
+type Child =
+ | ChildNode
+ | HTMLElement
+ | Text
+ | string
+ | number
+
+/* ----------------------------------------------------------------------------
+ * Helper functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Append a child node to an element
+ *
+ * @param el - Element
+ * @param child - Child node(s)
+ */
+function appendChild(el: HTMLElement, child: Child | Child[]): void {
+
+ /* Handle primitive types (including raw HTML) */
+ if (typeof child === "string" || typeof child === "number") {
+ el.innerHTML += child.toString()
+
+ /* Handle nodes */
+ } else if (child instanceof Node) {
+ el.appendChild(child)
+
+ /* Handle nested children */
+ } else if (Array.isArray(child)) {
+ for (const node of child)
+ appendChild(el, node)
+ }
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * JSX factory
+ *
+ * @template T - Element type
+ *
+ * @param tag - HTML tag
+ * @param attributes - HTML attributes
+ * @param children - Child elements
+ *
+ * @returns Element
+ */
+export function h<T extends keyof HTMLElementTagNameMap>(
+ tag: T, attributes?: Attributes | null, ...children: Child[]
+): HTMLElementTagNameMap[T]
+
+export function h<T extends h.JSX.Element>(
+ tag: string, attributes?: Attributes | null, ...children: Child[]
+): T
+
+export function h<T extends h.JSX.Element>(
+ tag: string, attributes?: Attributes | null, ...children: Child[]
+): T {
+ const el = document.createElement(tag)
+
+ /* Set attributes, if any */
+ if (attributes)
+ for (const attr of Object.keys(attributes)) {
+ if (typeof attributes[attr] === "undefined")
+ continue
+
+ /* Set default attribute or boolean */
+ if (typeof attributes[attr] !== "boolean")
+ el.setAttribute(attr, attributes[attr])
+ else
+ el.setAttribute(attr, "")
+ }
+
+ /* Append child nodes */
+ for (const child of children)
+ appendChild(el, child)
+
+ /* Return element */
+ return el as T
+}
+
+/* ----------------------------------------------------------------------------
+ * Namespace
+ * ------------------------------------------------------------------------- */
+
+export declare namespace h {
+ namespace JSX {
+ type Element = HTMLElement
+ type IntrinsicElements = JSXInternal.IntrinsicElements
+ }
+}
diff --git a/docs/src/templates/assets/javascripts/utilities/index.ts b/docs/src/templates/assets/javascripts/utilities/index.ts
new file mode 100644
index 00000000..42886e0b
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/utilities/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 "./h"
+export * from "./round"
diff --git a/docs/src/templates/assets/javascripts/utilities/round/index.ts b/docs/src/templates/assets/javascripts/utilities/round/index.ts
new file mode 100644
index 00000000..3e6bf91a
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/utilities/round/index.ts
@@ -0,0 +1,50 @@
+/*
+ * 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.
+ */
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Round a number for display with repository facts
+ *
+ * This is a reverse-engineered version of GitHub's weird rounding algorithm
+ * for stars, forks and all other numbers. While all numbers below `1,000` are
+ * returned as-is, bigger numbers are converted to fixed numbers:
+ *
+ * - `1,049` => `1k`
+ * - `1,050` => `1.1k`
+ * - `1,949` => `1.9k`
+ * - `1,950` => `2k`
+ *
+ * @param value - Original value
+ *
+ * @returns Rounded value
+ */
+export function round(value: number): string {
+ if (value > 999) {
+ const digits = +((value - 950) % 1000 > 99)
+ return `${((value + 0.000001) / 1000).toFixed(digits)}k`
+ } else {
+ return value.toString()
+ }
+}
diff --git a/docs/src/templates/assets/javascripts/workers/search.ts b/docs/src/templates/assets/javascripts/workers/search.ts
new file mode 100644
index 00000000..e995b1ff
--- /dev/null
+++ b/docs/src/templates/assets/javascripts/workers/search.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 RTICULAR 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 "~/integrations/search/worker/main"