summaryrefslogtreecommitdiffstatshomepage
path: root/src/templates/assets/javascripts/browser
diff options
context:
space:
mode:
Diffstat (limited to 'src/templates/assets/javascripts/browser')
-rw-r--r--src/templates/assets/javascripts/browser/document/index.ts48
-rw-r--r--src/templates/assets/javascripts/browser/element/_/.eslintrc6
-rw-r--r--src/templates/assets/javascripts/browser/element/_/index.ts120
-rw-r--r--src/templates/assets/javascripts/browser/element/focus/index.ts81
-rw-r--r--src/templates/assets/javascripts/browser/element/index.ts27
-rw-r--r--src/templates/assets/javascripts/browser/element/offset/_/index.ts86
-rw-r--r--src/templates/assets/javascripts/browser/element/offset/content/index.ts76
-rw-r--r--src/templates/assets/javascripts/browser/element/offset/index.ts24
-rw-r--r--src/templates/assets/javascripts/browser/element/size/_/index.ts151
-rw-r--r--src/templates/assets/javascripts/browser/element/size/content/index.ts67
-rw-r--r--src/templates/assets/javascripts/browser/element/size/index.ts24
-rw-r--r--src/templates/assets/javascripts/browser/element/visibility/index.ts131
-rw-r--r--src/templates/assets/javascripts/browser/index.ts32
-rw-r--r--src/templates/assets/javascripts/browser/keyboard/index.ts148
-rw-r--r--src/templates/assets/javascripts/browser/location/_/index.ts85
-rw-r--r--src/templates/assets/javascripts/browser/location/hash/index.ts104
-rw-r--r--src/templates/assets/javascripts/browser/location/index.ts24
-rw-r--r--src/templates/assets/javascripts/browser/media/index.ts95
-rw-r--r--src/templates/assets/javascripts/browser/request/index.ts141
-rw-r--r--src/templates/assets/javascripts/browser/script/index.ts70
-rw-r--r--src/templates/assets/javascripts/browser/toggle/index.ts102
-rw-r--r--src/templates/assets/javascripts/browser/viewport/_/index.ts69
-rw-r--r--src/templates/assets/javascripts/browser/viewport/at/index.ts84
-rw-r--r--src/templates/assets/javascripts/browser/viewport/index.ts26
-rw-r--r--src/templates/assets/javascripts/browser/viewport/offset/index.ts78
-rw-r--r--src/templates/assets/javascripts/browser/viewport/size/index.ts71
-rw-r--r--src/templates/assets/javascripts/browser/worker/index.ts112
27 files changed, 2082 insertions, 0 deletions
diff --git a/src/templates/assets/javascripts/browser/document/index.ts b/src/templates/assets/javascripts/browser/document/index.ts
new file mode 100644
index 00000000..354c9b5c
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/element/_/.eslintrc b/src/templates/assets/javascripts/browser/element/_/.eslintrc
new file mode 100644
index 00000000..16973760
--- /dev/null
+++ b/src/templates/assets/javascripts/browser/element/_/.eslintrc
@@ -0,0 +1,6 @@
+{
+ "rules": {
+ "jsdoc/require-jsdoc": "off",
+ "jsdoc/require-returns-check": "off"
+ }
+}
diff --git a/src/templates/assets/javascripts/browser/element/_/index.ts b/src/templates/assets/javascripts/browser/element/_/index.ts
new file mode 100644
index 00000000..b7beb462
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/element/focus/index.ts b/src/templates/assets/javascripts/browser/element/focus/index.ts
new file mode 100644
index 00000000..f31fe276
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/element/index.ts b/src/templates/assets/javascripts/browser/element/index.ts
new file mode 100644
index 00000000..50ce84b2
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/element/offset/_/index.ts b/src/templates/assets/javascripts/browser/element/offset/_/index.ts
new file mode 100644
index 00000000..6dd229d5
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/element/offset/content/index.ts b/src/templates/assets/javascripts/browser/element/offset/content/index.ts
new file mode 100644
index 00000000..557301a6
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/element/offset/index.ts b/src/templates/assets/javascripts/browser/element/offset/index.ts
new file mode 100644
index 00000000..602ff2cf
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/element/size/_/index.ts b/src/templates/assets/javascripts/browser/element/size/_/index.ts
new file mode 100644
index 00000000..35a5e68b
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/element/size/content/index.ts b/src/templates/assets/javascripts/browser/element/size/content/index.ts
new file mode 100644
index 00000000..5ed388cf
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/element/size/index.ts b/src/templates/assets/javascripts/browser/element/size/index.ts
new file mode 100644
index 00000000..602ff2cf
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/element/visibility/index.ts b/src/templates/assets/javascripts/browser/element/visibility/index.ts
new file mode 100644
index 00000000..1ffe0b8d
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/index.ts b/src/templates/assets/javascripts/browser/index.ts
new file mode 100644
index 00000000..f1ee2bae
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/keyboard/index.ts b/src/templates/assets/javascripts/browser/keyboard/index.ts
new file mode 100644
index 00000000..783f2cda
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/location/_/index.ts b/src/templates/assets/javascripts/browser/location/_/index.ts
new file mode 100644
index 00000000..2672fa74
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/location/hash/index.ts b/src/templates/assets/javascripts/browser/location/hash/index.ts
new file mode 100644
index 00000000..5d3a134a
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/location/index.ts b/src/templates/assets/javascripts/browser/location/index.ts
new file mode 100644
index 00000000..d77a5444
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/media/index.ts b/src/templates/assets/javascripts/browser/media/index.ts
new file mode 100644
index 00000000..dd7400d4
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/request/index.ts b/src/templates/assets/javascripts/browser/request/index.ts
new file mode 100644
index 00000000..74a56a64
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/script/index.ts b/src/templates/assets/javascripts/browser/script/index.ts
new file mode 100644
index 00000000..ef5c89e6
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/toggle/index.ts b/src/templates/assets/javascripts/browser/toggle/index.ts
new file mode 100644
index 00000000..0be4b29d
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/viewport/_/index.ts b/src/templates/assets/javascripts/browser/viewport/_/index.ts
new file mode 100644
index 00000000..09c45f32
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/viewport/at/index.ts b/src/templates/assets/javascripts/browser/viewport/at/index.ts
new file mode 100644
index 00000000..8769cf3b
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/viewport/index.ts b/src/templates/assets/javascripts/browser/viewport/index.ts
new file mode 100644
index 00000000..b3d135e9
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/viewport/offset/index.ts b/src/templates/assets/javascripts/browser/viewport/offset/index.ts
new file mode 100644
index 00000000..63d37dd2
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/viewport/size/index.ts b/src/templates/assets/javascripts/browser/viewport/size/index.ts
new file mode 100644
index 00000000..06694888
--- /dev/null
+++ b/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/src/templates/assets/javascripts/browser/worker/index.ts b/src/templates/assets/javascripts/browser/worker/index.ts
new file mode 100644
index 00000000..12e4e63b
--- /dev/null
+++ b/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>
+}