diff options
| author | 2023-12-15 09:11:47 +0800 | |
|---|---|---|
| committer | 2023-12-15 09:11:47 +0800 | |
| commit | be8de118db913711eb72ae5187d26e54a0055727 (patch) | |
| tree | 96cd6c012dafa3f4015e54edef90df5eaaab0ddb /docs/src/templates/assets/javascripts | |
| parent | 9b2d27ba1d91a0d5531bc9c0d52c3887a2dfb2aa (diff) | |
| download | infini-be8de118db913711eb72ae5187d26e54a0055727.tar.gz infini-be8de118db913711eb72ae5187d26e54a0055727.zip | |
refactor(docs): optmst `docs` dir & `deps`
Diffstat (limited to 'docs/src/templates/assets/javascripts')
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, " ") + ) + + /* 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" |
