mirror of
https://github.com/squidfunk/mkdocs-material.git
synced 2026-08-04 19:45:08 -04:00
Merged features tied to Carolina Reaper funding goal
This commit is contained in:
@@ -58,10 +58,6 @@ export type Flag =
|
||||
export type Translation =
|
||||
| "clipboard.copy" /* Copy to clipboard */
|
||||
| "clipboard.copied" /* Copied to clipboard */
|
||||
| "search.config.lang" /* Search language */
|
||||
| "search.config.pipeline" /* Search pipeline */
|
||||
| "search.config.separator" /* Search separator */
|
||||
| "search.placeholder" /* Search */
|
||||
| "search.result.placeholder" /* Type to start searching */
|
||||
| "search.result.none" /* No matching documents */
|
||||
| "search.result.one" /* 1 matching document */
|
||||
@@ -74,7 +70,8 @@ export type Translation =
|
||||
/**
|
||||
* Translations
|
||||
*/
|
||||
export type Translations = Record<Translation, string>
|
||||
export type Translations =
|
||||
Record<Translation, string>
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ export function getElementContainer(
|
||||
let parent = el.parentElement
|
||||
while (parent)
|
||||
if (
|
||||
el.scrollWidth <= parent.scrollWidth &&
|
||||
el.scrollWidth <= parent.scrollWidth &&
|
||||
el.scrollHeight <= parent.scrollHeight
|
||||
)
|
||||
parent = (el = parent).parentElement
|
||||
|
||||
@@ -21,11 +21,15 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
EMPTY,
|
||||
Observable,
|
||||
filter,
|
||||
fromEvent,
|
||||
map,
|
||||
share
|
||||
merge,
|
||||
share,
|
||||
startWith,
|
||||
switchMap
|
||||
} from "rxjs"
|
||||
|
||||
import { getActiveElement } from "../element"
|
||||
@@ -93,13 +97,28 @@ function isSusceptibleToKeyboard(
|
||||
* 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> {
|
||||
return fromEvent<KeyboardEvent>(window, "keydown")
|
||||
const keyboard$ = fromEvent<KeyboardEvent>(window, "keydown")
|
||||
.pipe(
|
||||
filter(ev => !(ev.metaKey || ev.ctrlKey)),
|
||||
map(ev => ({
|
||||
@@ -120,4 +139,10 @@ export function watchKeyboard(): Observable<Keyboard> {
|
||||
}),
|
||||
share()
|
||||
)
|
||||
|
||||
/* Don't emit during composition events - see https://bit.ly/3te3Wl8 */
|
||||
return watchComposition()
|
||||
.pipe(
|
||||
switchMap(active => !active ? keyboard$ : EMPTY)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ export function request(
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Fetch JSON from the given URL
|
||||
*
|
||||
|
||||
@@ -42,7 +42,7 @@ import { h } from "~/utilities"
|
||||
* 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 didn't.
|
||||
* successfully loaded, or throw an error if it wasn't.
|
||||
*
|
||||
* @param src - Script URL
|
||||
*
|
||||
|
||||
@@ -20,15 +20,16 @@
|
||||
* IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import "iframe-worker/shim"
|
||||
import {
|
||||
Observable,
|
||||
Subject,
|
||||
endWith,
|
||||
fromEvent,
|
||||
map,
|
||||
ignoreElements,
|
||||
mergeWith,
|
||||
share,
|
||||
switchMap,
|
||||
tap,
|
||||
throttle
|
||||
takeUntil
|
||||
} from "rxjs"
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
@@ -43,29 +44,38 @@ export interface WorkerMessage {
|
||||
data?: unknown /* Message data */
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker handler
|
||||
*
|
||||
* @template T - Message type
|
||||
*/
|
||||
export interface WorkerHandler<
|
||||
T extends WorkerMessage
|
||||
> {
|
||||
tx$: Subject<T> /* Message transmission subject */
|
||||
rx$: Observable<T> /* Message receive observable */
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Helper types
|
||||
* Helper functions
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Watch options
|
||||
* Create an observable for receiving from a web worker
|
||||
*
|
||||
* @template T - Worker message type
|
||||
* @template T - Data type
|
||||
*
|
||||
* @param worker - Web worker
|
||||
*
|
||||
* @returns Message observable
|
||||
*/
|
||||
interface WatchOptions<T extends WorkerMessage> {
|
||||
tx$: Observable<T> /* Message transmission 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$
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
@@ -73,34 +83,31 @@ interface WatchOptions<T extends WorkerMessage> {
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Watch a web worker
|
||||
* Create a bidirectional communication channel to a web worker
|
||||
*
|
||||
* This function returns an observable that sends all values emitted by the
|
||||
* message observable to the web worker. Web worker communication is expected
|
||||
* to be bidirectional (request-response) and synchronous. Messages that are
|
||||
* emitted during a pending request are throttled, the last one is emitted.
|
||||
* @template T - Data type
|
||||
*
|
||||
* @param worker - Web worker
|
||||
* @param options - Options
|
||||
* @param url - Worker URL
|
||||
* @param worker - Worker
|
||||
*
|
||||
* @returns Worker message observable
|
||||
* @returns Worker subject
|
||||
*/
|
||||
export function watchWorker<T extends WorkerMessage>(
|
||||
worker: Worker, { tx$ }: WatchOptions<T>
|
||||
): Observable<T> {
|
||||
url: string, worker = new Worker(url)
|
||||
): Subject<T> {
|
||||
const recv$ = recv<T>(worker)
|
||||
const send$ = send<T>(worker)
|
||||
|
||||
/* Intercept messages from worker-like objects */
|
||||
const rx$ = fromEvent<MessageEvent>(worker, "message")
|
||||
.pipe(
|
||||
map(({ data }) => data as T)
|
||||
)
|
||||
/* Create worker subject and forward messages */
|
||||
const worker$ = new Subject<T>()
|
||||
worker$.subscribe(send$)
|
||||
|
||||
/* Send and receive messages, return hot observable */
|
||||
return tx$
|
||||
/* Return worker subject */
|
||||
const done$ = send$.pipe(ignoreElements(), endWith(true))
|
||||
return worker$
|
||||
.pipe(
|
||||
throttle(() => rx$, { leading: true, trailing: true }),
|
||||
tap(message => worker.postMessage(message)),
|
||||
switchMap(() => rx$),
|
||||
ignoreElements(),
|
||||
mergeWith(recv$.pipe(takeUntil(done$))),
|
||||
share()
|
||||
)
|
||||
) as Subject<T>
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import "url-polyfill"
|
||||
import {
|
||||
EMPTY,
|
||||
NEVER,
|
||||
Observable,
|
||||
Subject,
|
||||
defer,
|
||||
delay,
|
||||
@@ -51,6 +52,7 @@ import {
|
||||
watchLocationTarget,
|
||||
watchMedia,
|
||||
watchPrint,
|
||||
watchScript,
|
||||
watchViewport
|
||||
} from "./browser"
|
||||
import {
|
||||
@@ -86,6 +88,32 @@ import {
|
||||
} 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
|
||||
* ------------------------------------------------------------------------- */
|
||||
@@ -109,9 +137,7 @@ const print$ = watchPrint()
|
||||
/* Retrieve search index, if search is enabled */
|
||||
const config = configuration()
|
||||
const index$ = document.forms.namedItem("search")
|
||||
? __search?.index || requestJSON<SearchIndex>(
|
||||
new URL("search/search_index.json", config.base)
|
||||
)
|
||||
? fetchSearchIndex()
|
||||
: NEVER
|
||||
|
||||
/* Set up Clipboard.js integration */
|
||||
|
||||
@@ -29,14 +29,15 @@ import {
|
||||
debounceTime,
|
||||
defer,
|
||||
delay,
|
||||
endWith,
|
||||
filter,
|
||||
finalize,
|
||||
fromEvent,
|
||||
ignoreElements,
|
||||
map,
|
||||
merge,
|
||||
switchMap,
|
||||
take,
|
||||
takeLast,
|
||||
takeUntil,
|
||||
tap,
|
||||
throttleTime,
|
||||
@@ -136,7 +137,7 @@ export function mountAnnotation(
|
||||
/* Mount component on subscription */
|
||||
return defer(() => {
|
||||
const push$ = new Subject<Annotation>()
|
||||
const done$ = push$.pipe(takeLast(1))
|
||||
const done$ = push$.pipe(ignoreElements(), endWith(true))
|
||||
push$.subscribe({
|
||||
|
||||
/* Handle emission */
|
||||
|
||||
@@ -25,10 +25,11 @@ import {
|
||||
Observable,
|
||||
Subject,
|
||||
defer,
|
||||
endWith,
|
||||
finalize,
|
||||
ignoreElements,
|
||||
merge,
|
||||
share,
|
||||
takeLast,
|
||||
takeUntil
|
||||
} from "rxjs"
|
||||
|
||||
@@ -167,7 +168,7 @@ export function mountAnnotationList(
|
||||
/* Handle print mode - see https://bit.ly/3rgPdpt */
|
||||
print$
|
||||
.pipe(
|
||||
takeUntil(done$.pipe(takeLast(1)))
|
||||
takeUntil(done$.pipe(ignoreElements(), endWith(true)))
|
||||
)
|
||||
.subscribe(active => {
|
||||
el.hidden = !active
|
||||
|
||||
@@ -28,8 +28,10 @@ import {
|
||||
auditTime,
|
||||
combineLatest,
|
||||
defer,
|
||||
endWith,
|
||||
finalize,
|
||||
fromEvent,
|
||||
ignoreElements,
|
||||
map,
|
||||
merge,
|
||||
skip,
|
||||
@@ -135,7 +137,7 @@ export function mountContentTabs(
|
||||
const container = getElement(".tabbed-labels", el)
|
||||
return defer(() => {
|
||||
const push$ = new Subject<ContentTabs>()
|
||||
const done$ = push$.pipe(takeLast(1))
|
||||
const done$ = push$.pipe(ignoreElements(), endWith(true))
|
||||
combineLatest([push$, watchElementSize(el)])
|
||||
.pipe(
|
||||
auditTime(1, animationFrameScheduler),
|
||||
|
||||
@@ -29,13 +29,14 @@ import {
|
||||
defer,
|
||||
distinctUntilChanged,
|
||||
distinctUntilKeyChanged,
|
||||
endWith,
|
||||
filter,
|
||||
ignoreElements,
|
||||
map,
|
||||
of,
|
||||
shareReplay,
|
||||
startWith,
|
||||
switchMap,
|
||||
takeLast,
|
||||
takeUntil
|
||||
} from "rxjs"
|
||||
|
||||
@@ -175,7 +176,7 @@ export function mountHeader(
|
||||
): Observable<Component<Header>> {
|
||||
return defer(() => {
|
||||
const push$ = new Subject<Main>()
|
||||
const done$ = push$.pipe(takeLast(1))
|
||||
const done$ = push$.pipe(ignoreElements(), endWith(true))
|
||||
push$
|
||||
.pipe(
|
||||
distinctUntilKeyChanged("active"),
|
||||
|
||||
@@ -26,9 +26,7 @@ import {
|
||||
ObservableInput,
|
||||
filter,
|
||||
merge,
|
||||
mergeWith,
|
||||
sample,
|
||||
take
|
||||
mergeWith
|
||||
} from "rxjs"
|
||||
|
||||
import { configuration } from "~/_"
|
||||
@@ -41,8 +39,6 @@ import {
|
||||
import {
|
||||
SearchIndex,
|
||||
SearchResult,
|
||||
isSearchQueryMessage,
|
||||
isSearchReadyMessage,
|
||||
setupSearchWorker
|
||||
} from "~/integrations"
|
||||
|
||||
@@ -110,23 +106,12 @@ export function mountSearch(
|
||||
): Observable<Component<Search>> {
|
||||
const config = configuration()
|
||||
try {
|
||||
const url = __search?.worker || config.search
|
||||
const worker = setupSearchWorker(url, index$)
|
||||
const worker$ = setupSearchWorker(config.search, index$)
|
||||
|
||||
/* Retrieve query and result components */
|
||||
const query = getComponentElement("search-query", el)
|
||||
const result = getComponentElement("search-result", el)
|
||||
|
||||
/* Re-emit query when search is ready */
|
||||
const { tx$, rx$ } = worker
|
||||
tx$
|
||||
.pipe(
|
||||
filter(isSearchQueryMessage),
|
||||
sample(rx$.pipe(filter(isSearchReadyMessage))),
|
||||
take(1)
|
||||
)
|
||||
.subscribe(tx$.next.bind(tx$))
|
||||
|
||||
/* Set up search keyboard handlers */
|
||||
keyboard$
|
||||
.pipe(
|
||||
@@ -199,7 +184,7 @@ export function mountSearch(
|
||||
/* Set up global keyboard handlers */
|
||||
keyboard$
|
||||
.pipe(
|
||||
filter(({ mode }) => mode === "global"),
|
||||
filter(({ mode }) => mode === "global")
|
||||
)
|
||||
.subscribe(key => {
|
||||
switch (key.type) {
|
||||
@@ -218,9 +203,11 @@ export function mountSearch(
|
||||
})
|
||||
|
||||
/* Create and return component */
|
||||
const query$ = mountSearchQuery(query, worker)
|
||||
const result$ = mountSearchResult(result, worker, { query$ })
|
||||
return merge(query$, result$)
|
||||
const query$ = mountSearchQuery(query, { worker$ })
|
||||
return merge(
|
||||
query$,
|
||||
mountSearchResult(result, { worker$, query$ })
|
||||
)
|
||||
.pipe(
|
||||
mergeWith(
|
||||
|
||||
@@ -230,7 +217,7 @@ export function mountSearch(
|
||||
|
||||
/* Search suggestions */
|
||||
...getComponentElements("search-suggest", el)
|
||||
.map(child => mountSearchSuggest(child, worker, { keyboard$ }))
|
||||
.map(child => mountSearchSuggest(child, { worker$, keyboard$ }))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ export function mountSearchHiglight(
|
||||
)
|
||||
])
|
||||
.pipe(
|
||||
map(([index, url]) => setupSearchHighlighter(index.config, true)(
|
||||
map(([index, url]) => setupSearchHighlighter(index.config)(
|
||||
url.searchParams.get("h")!
|
||||
)),
|
||||
map(fn => {
|
||||
|
||||
@@ -24,24 +24,20 @@ import {
|
||||
Observable,
|
||||
Subject,
|
||||
combineLatest,
|
||||
delay,
|
||||
distinctUntilChanged,
|
||||
distinctUntilKeyChanged,
|
||||
filter,
|
||||
endWith,
|
||||
finalize,
|
||||
first,
|
||||
fromEvent,
|
||||
ignoreElements,
|
||||
map,
|
||||
merge,
|
||||
share,
|
||||
shareReplay,
|
||||
startWith,
|
||||
take,
|
||||
takeLast,
|
||||
takeUntil,
|
||||
tap
|
||||
} from "rxjs"
|
||||
|
||||
import { translation } from "~/_"
|
||||
import {
|
||||
getLocation,
|
||||
setToggle,
|
||||
@@ -49,10 +45,8 @@ import {
|
||||
watchToggle
|
||||
} from "~/browser"
|
||||
import {
|
||||
SearchMessage,
|
||||
SearchMessageType,
|
||||
SearchQueryMessage,
|
||||
SearchWorker,
|
||||
defaultTransform,
|
||||
isSearchReadyMessage
|
||||
} from "~/integrations"
|
||||
|
||||
@@ -70,6 +64,24 @@ export interface SearchQuery {
|
||||
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
|
||||
* ------------------------------------------------------------------------- */
|
||||
@@ -81,59 +93,45 @@ export interface SearchQuery {
|
||||
* is delayed by `1ms` so the input's empty state is allowed to propagate.
|
||||
*
|
||||
* @param el - Search query element
|
||||
* @param worker - Search worker
|
||||
* @param options - Options
|
||||
*
|
||||
* @returns Search query observable
|
||||
*/
|
||||
export function watchSearchQuery(
|
||||
el: HTMLInputElement, { rx$ }: SearchWorker
|
||||
el: HTMLInputElement, { worker$ }: WatchOptions
|
||||
): Observable<SearchQuery> {
|
||||
const fn = __search?.transform || defaultTransform
|
||||
|
||||
/* Immediately show search dialog */
|
||||
/* Support search deep linking */
|
||||
const { searchParams } = getLocation()
|
||||
if (searchParams.has("q"))
|
||||
if (searchParams.has("q")) {
|
||||
setToggle("search", true)
|
||||
|
||||
/* Intercept query parameter (deep link) */
|
||||
const param$ = rx$
|
||||
.pipe(
|
||||
filter(isSearchReadyMessage),
|
||||
take(1),
|
||||
map(() => searchParams.get("q") || "")
|
||||
)
|
||||
/* Set query from parameter */
|
||||
el.value = searchParams.get("q")!
|
||||
el.focus()
|
||||
|
||||
/* Remove query parameter when search is closed */
|
||||
watchToggle("search")
|
||||
.pipe(
|
||||
filter(active => !active),
|
||||
take(1)
|
||||
)
|
||||
.subscribe(() => {
|
||||
const url = new URL(location.href)
|
||||
url.searchParams.delete("q")
|
||||
history.replaceState({}, "", `${url}`)
|
||||
})
|
||||
|
||||
/* Set query from parameter */
|
||||
param$.subscribe(value => { // TODO: not ideal - find a better way
|
||||
if (value) {
|
||||
el.value = value
|
||||
el.focus()
|
||||
}
|
||||
})
|
||||
/* Remove query parameter on close */
|
||||
watchToggle("search")
|
||||
.pipe(
|
||||
first(active => !active)
|
||||
)
|
||||
.subscribe(() => {
|
||||
const url = new URL(location.href)
|
||||
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"),
|
||||
fromEvent(el, "focus").pipe(delay(1)),
|
||||
param$
|
||||
focus$
|
||||
)
|
||||
.pipe(
|
||||
map(() => fn(el.value)),
|
||||
startWith(""),
|
||||
distinctUntilChanged(),
|
||||
map(() => el.value),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
/* Combine into single observable */
|
||||
@@ -148,39 +146,37 @@ export function watchSearchQuery(
|
||||
* Mount search query
|
||||
*
|
||||
* @param el - Search query element
|
||||
* @param worker - Search worker
|
||||
* @param options - Options
|
||||
*
|
||||
* @returns Search query component observable
|
||||
*/
|
||||
export function mountSearchQuery(
|
||||
el: HTMLInputElement, { tx$, rx$ }: SearchWorker
|
||||
el: HTMLInputElement, { worker$ }: MountOptions
|
||||
): Observable<Component<SearchQuery, HTMLInputElement>> {
|
||||
const push$ = new Subject<SearchQuery>()
|
||||
const done$ = push$.pipe(takeLast(1))
|
||||
const done$ = push$.pipe(ignoreElements(), endWith(true))
|
||||
|
||||
/* Handle value changes */
|
||||
push$
|
||||
/* Handle value change */
|
||||
combineLatest([
|
||||
worker$.pipe(first(isSearchReadyMessage)),
|
||||
push$
|
||||
], (_, query) => query)
|
||||
.pipe(
|
||||
distinctUntilKeyChanged("value"),
|
||||
map(({ value }): SearchQueryMessage => ({
|
||||
distinctUntilKeyChanged("value")
|
||||
)
|
||||
.subscribe(({ value }) => worker$.next({
|
||||
type: SearchMessageType.QUERY,
|
||||
data: value
|
||||
}))
|
||||
)
|
||||
.subscribe(tx$.next.bind(tx$))
|
||||
|
||||
/* Handle focus changes */
|
||||
/* Handle focus change */
|
||||
push$
|
||||
.pipe(
|
||||
distinctUntilKeyChanged("focus")
|
||||
)
|
||||
.subscribe(({ focus }) => {
|
||||
if (focus) {
|
||||
if (focus)
|
||||
setToggle("search", focus)
|
||||
el.placeholder = ""
|
||||
} else {
|
||||
el.placeholder = translation("search.placeholder")
|
||||
}
|
||||
})
|
||||
|
||||
/* Handle reset */
|
||||
@@ -191,11 +187,11 @@ export function mountSearchQuery(
|
||||
.subscribe(() => el.focus())
|
||||
|
||||
/* Create and return component */
|
||||
return watchSearchQuery(el, { tx$, rx$ })
|
||||
return watchSearchQuery(el, { worker$ })
|
||||
.pipe(
|
||||
tap(state => push$.next(state)),
|
||||
finalize(() => push$.complete()),
|
||||
map(state => ({ ref: el, ...state })),
|
||||
share()
|
||||
shareReplay(1)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,17 +21,22 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
EMPTY,
|
||||
Observable,
|
||||
Subject,
|
||||
bufferCount,
|
||||
filter,
|
||||
finalize,
|
||||
first,
|
||||
fromEvent,
|
||||
map,
|
||||
merge,
|
||||
mergeMap,
|
||||
of,
|
||||
share,
|
||||
skipUntil,
|
||||
switchMap,
|
||||
take,
|
||||
takeUntil,
|
||||
tap,
|
||||
withLatestFrom,
|
||||
zipWith
|
||||
@@ -40,11 +45,12 @@ import {
|
||||
import { translation } from "~/_"
|
||||
import {
|
||||
getElement,
|
||||
getOptionalElement,
|
||||
watchElementBoundary
|
||||
} from "~/browser"
|
||||
import {
|
||||
SearchMessage,
|
||||
SearchResult,
|
||||
SearchWorker,
|
||||
isSearchReadyMessage,
|
||||
isSearchResultMessage
|
||||
} from "~/integrations"
|
||||
@@ -63,6 +69,7 @@ import { SearchQuery } from "../query"
|
||||
*/
|
||||
interface MountOptions {
|
||||
query$: Observable<SearchQuery> /* Search query observable */
|
||||
worker$: Subject<SearchMessage> /* Search worker */
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
@@ -76,13 +83,12 @@ interface MountOptions {
|
||||
* the vertical offset of the search result container.
|
||||
*
|
||||
* @param el - Search result list element
|
||||
* @param worker - Search worker
|
||||
* @param options - Options
|
||||
*
|
||||
* @returns Search result list component observable
|
||||
*/
|
||||
export function mountSearchResult(
|
||||
el: HTMLElement, { rx$ }: SearchWorker, { query$ }: MountOptions
|
||||
el: HTMLElement, { worker$, query$ }: MountOptions
|
||||
): Observable<Component<SearchResult>> {
|
||||
const push$ = new Subject<SearchResult>()
|
||||
const boundary$ = watchElementBoundary(el.parentElement!)
|
||||
@@ -90,51 +96,43 @@ export function mountSearchResult(
|
||||
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)
|
||||
|
||||
/* Wait until search is ready */
|
||||
const ready$ = rx$
|
||||
.pipe(
|
||||
filter(isSearchReadyMessage),
|
||||
take(1)
|
||||
)
|
||||
|
||||
/* Update search result metadata */
|
||||
push$
|
||||
.pipe(
|
||||
withLatestFrom(query$),
|
||||
skipUntil(ready$)
|
||||
skipUntil(worker$.pipe(first(isSearchReadyMessage)))
|
||||
)
|
||||
.subscribe(([{ items }, { value }]) => {
|
||||
if (value) {
|
||||
switch (items.length) {
|
||||
switch (items.length) {
|
||||
|
||||
/* No results */
|
||||
case 0:
|
||||
meta.textContent = translation("search.result.none")
|
||||
break
|
||||
/* 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
|
||||
/* One result */
|
||||
case 1:
|
||||
meta.textContent = translation("search.result.one")
|
||||
break
|
||||
|
||||
/* Multiple result */
|
||||
default:
|
||||
meta.textContent = translation(
|
||||
"search.result.other",
|
||||
round(items.length)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
meta.textContent = translation("search.result.placeholder")
|
||||
/* Multiple result */
|
||||
default:
|
||||
const count = round(items.length)
|
||||
meta.textContent = translation("search.result.other", count)
|
||||
}
|
||||
})
|
||||
|
||||
/* Update search result list */
|
||||
push$
|
||||
/* Render search result item */
|
||||
const render$ = push$
|
||||
.pipe(
|
||||
tap(() => list.innerHTML = ""),
|
||||
switchMap(({ items }) => merge(
|
||||
@@ -145,14 +143,38 @@ export function mountSearchResult(
|
||||
zipWith(boundary$),
|
||||
switchMap(([chunk]) => chunk)
|
||||
)
|
||||
))
|
||||
)),
|
||||
map(renderSearchResultItem),
|
||||
share()
|
||||
)
|
||||
.subscribe(result => list.appendChild(
|
||||
renderSearchResultItem(result)
|
||||
))
|
||||
|
||||
/* 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$ = rx$
|
||||
const result$ = worker$
|
||||
.pipe(
|
||||
filter(isSearchResultMessage),
|
||||
map(({ data }) => data)
|
||||
|
||||
@@ -23,9 +23,12 @@
|
||||
import {
|
||||
Observable,
|
||||
Subject,
|
||||
endWith,
|
||||
finalize,
|
||||
fromEvent,
|
||||
ignoreElements,
|
||||
map,
|
||||
takeUntil,
|
||||
tap
|
||||
} from "rxjs"
|
||||
|
||||
@@ -102,6 +105,7 @@ 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}`
|
||||
@@ -109,7 +113,10 @@ export function mountSearchShare(
|
||||
|
||||
/* Prevent following of link */
|
||||
fromEvent(el, "click")
|
||||
.subscribe(ev => ev.preventDefault())
|
||||
.pipe(
|
||||
takeUntil(done$)
|
||||
)
|
||||
.subscribe(ev => ev.preventDefault())
|
||||
|
||||
/* Create and return component */
|
||||
return watchSearchShare(el, options)
|
||||
|
||||
@@ -37,8 +37,8 @@ import {
|
||||
|
||||
import { Keyboard } from "~/browser"
|
||||
import {
|
||||
SearchMessage,
|
||||
SearchResult,
|
||||
SearchWorker,
|
||||
isSearchResultMessage
|
||||
} from "~/integrations"
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface SearchSuggest {}
|
||||
*/
|
||||
interface MountOptions {
|
||||
keyboard$: Observable<Keyboard> /* Keyboard observable */
|
||||
worker$: Subject<SearchMessage> /* Search worker */
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
@@ -75,13 +76,12 @@ interface MountOptions {
|
||||
* on the vertical offset of the search result container.
|
||||
*
|
||||
* @param el - Search result list element
|
||||
* @param worker - Search worker
|
||||
* @param options - Options
|
||||
*
|
||||
* @returns Search result list component observable
|
||||
*/
|
||||
export function mountSearchSuggest(
|
||||
el: HTMLElement, { rx$ }: SearchWorker, { keyboard$ }: MountOptions
|
||||
el: HTMLElement, { worker$, keyboard$ }: MountOptions
|
||||
): Observable<Component<SearchSuggest>> {
|
||||
const push$ = new Subject<SearchResult>()
|
||||
|
||||
@@ -101,10 +101,10 @@ export function mountSearchSuggest(
|
||||
push$
|
||||
.pipe(
|
||||
combineLatestWith(query$),
|
||||
map(([{ suggestions }, value]) => {
|
||||
map(([{ suggest }, value]) => {
|
||||
const words = value.split(/([\s-]+)/)
|
||||
if (suggestions?.length && words[words.length - 1]) {
|
||||
const last = suggestions[suggestions.length - 1]
|
||||
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 {
|
||||
@@ -138,7 +138,7 @@ export function mountSearchSuggest(
|
||||
})
|
||||
|
||||
/* Filter search result message */
|
||||
const result$ = rx$
|
||||
const result$ = worker$
|
||||
.pipe(
|
||||
filter(isSearchResultMessage),
|
||||
map(({ data }) => data)
|
||||
|
||||
@@ -29,8 +29,10 @@ import {
|
||||
defer,
|
||||
distinctUntilChanged,
|
||||
distinctUntilKeyChanged,
|
||||
endWith,
|
||||
filter,
|
||||
finalize,
|
||||
ignoreElements,
|
||||
map,
|
||||
merge,
|
||||
of,
|
||||
@@ -40,7 +42,6 @@ import {
|
||||
skip,
|
||||
startWith,
|
||||
switchMap,
|
||||
takeLast,
|
||||
takeUntil,
|
||||
tap,
|
||||
withLatestFrom
|
||||
@@ -273,7 +274,7 @@ export function mountTableOfContents(
|
||||
): Observable<Component<TableOfContents>> {
|
||||
return defer(() => {
|
||||
const push$ = new Subject<TableOfContents>()
|
||||
const done$ = push$.pipe(takeLast(1))
|
||||
const done$ = push$.pipe(ignoreElements(), endWith(true))
|
||||
push$.subscribe(({ prev, next }) => {
|
||||
|
||||
/* Look forward */
|
||||
|
||||
@@ -29,10 +29,10 @@ import {
|
||||
distinctUntilKeyChanged,
|
||||
endWith,
|
||||
finalize,
|
||||
ignoreElements,
|
||||
map,
|
||||
repeat,
|
||||
skip,
|
||||
takeLast,
|
||||
takeUntil,
|
||||
tap
|
||||
} from "rxjs"
|
||||
@@ -134,7 +134,7 @@ export function mountBackToTop(
|
||||
el: HTMLElement, { viewport$, header$, main$, target$ }: MountOptions
|
||||
): Observable<Component<BackToTop>> {
|
||||
const push$ = new Subject<BackToTop>()
|
||||
const done$ = push$.pipe(takeLast(1))
|
||||
const done$ = push$.pipe(ignoreElements(), endWith(true))
|
||||
push$.subscribe({
|
||||
|
||||
/* Handle emission */
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"no-console": "off"
|
||||
}
|
||||
}
|
||||
@@ -22,18 +22,21 @@
|
||||
|
||||
import {
|
||||
SearchDocument,
|
||||
SearchDocumentMap,
|
||||
SearchIndex,
|
||||
SearchOptions,
|
||||
setupSearchDocumentMap
|
||||
} from "../document"
|
||||
} from "../config"
|
||||
import {
|
||||
SearchHighlightFactoryFn,
|
||||
setupSearchHighlighter
|
||||
} from "../highlighter"
|
||||
import { SearchOptions } from "../options"
|
||||
Position,
|
||||
PositionTable,
|
||||
highlighter,
|
||||
tokenize
|
||||
} from "../internal"
|
||||
import {
|
||||
SearchQueryTerms,
|
||||
getSearchQueryTerms,
|
||||
parseSearchQuery
|
||||
parseSearchQuery,
|
||||
transformSearchQuery
|
||||
} from "../query"
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
@@ -41,74 +44,48 @@ import {
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Search index configuration
|
||||
* Search item
|
||||
*/
|
||||
export interface SearchIndexConfig {
|
||||
lang: string[] /* Search languages */
|
||||
separator: string /* Search separator */
|
||||
}
|
||||
|
||||
/**
|
||||
* Search index document
|
||||
*/
|
||||
export interface SearchIndexDocument {
|
||||
location: string /* Document location */
|
||||
title: string /* Document title */
|
||||
text: string /* Document text */
|
||||
tags?: string[] /* Document tags */
|
||||
boost?: number /* Document boost */
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Search index
|
||||
*
|
||||
* This interfaces describes the format of the `search_index.json` file which
|
||||
* is automatically built by the MkDocs search plugin.
|
||||
*/
|
||||
export interface SearchIndex {
|
||||
config: SearchIndexConfig /* Search index configuration */
|
||||
docs: SearchIndexDocument[] /* Search index documents */
|
||||
options: SearchOptions /* Search options */
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Search metadata
|
||||
*/
|
||||
export interface SearchMetadata {
|
||||
export interface SearchItem extends SearchDocument {
|
||||
score: number /* Score (relevance) */
|
||||
terms: SearchQueryTerms /* Search query terms */
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Search result document
|
||||
*/
|
||||
export type SearchResultDocument = SearchDocument & SearchMetadata
|
||||
|
||||
/**
|
||||
* Search result item
|
||||
*/
|
||||
export type SearchResultItem = SearchResultDocument[]
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Search result
|
||||
*/
|
||||
export interface SearchResult {
|
||||
items: SearchResultItem[] /* Search result items */
|
||||
suggestions?: string[] /* Search suggestions */
|
||||
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 initiable 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
|
||||
*
|
||||
@@ -134,85 +111,78 @@ function difference(a: string[], b: string[]): string[] {
|
||||
export class Search {
|
||||
|
||||
/**
|
||||
* Search document mapping
|
||||
*
|
||||
* A mapping of URLs (including hash fragments) to the actual articles and
|
||||
* sections of the documentation. The search document mapping must be created
|
||||
* regardless of whether the index was prebuilt or not, as Lunr.js itself
|
||||
* only stores the actual index.
|
||||
* Search document map
|
||||
*/
|
||||
protected documents: SearchDocumentMap
|
||||
|
||||
/**
|
||||
* Search highlight factory function
|
||||
*/
|
||||
protected highlight: SearchHighlightFactoryFn
|
||||
|
||||
/**
|
||||
* The underlying Lunr.js search index
|
||||
*/
|
||||
protected index: lunr.Index
|
||||
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 map and highlighter factory */
|
||||
this.documents = setupSearchDocumentMap(docs)
|
||||
this.highlight = setupSearchHighlighter(config, false)
|
||||
|
||||
/* Set separator for tokenizer */
|
||||
lunr.tokenizer.separator = new RegExp(config.separator)
|
||||
|
||||
/* Create search index */
|
||||
/* Set up document index */
|
||||
this.index = lunr(function () {
|
||||
this.metadataWhitelist = ["position"]
|
||||
this.b(0)
|
||||
|
||||
/* Set up multi-language support */
|
||||
/* Set up (multi-)language support */
|
||||
if (config.lang.length === 1 && config.lang[0] !== "en") {
|
||||
this.use((lunr as any)[config.lang[0]])
|
||||
// @ts-expect-error - namespace indexing not supported
|
||||
this.use(lunr[config.lang[0]])
|
||||
} else if (config.lang.length > 1) {
|
||||
this.use((lunr as any).multiLanguage(...config.lang))
|
||||
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)
|
||||
|
||||
/* Compute functions to be removed from the pipeline */
|
||||
const fns = difference([
|
||||
"trimmer", "stopWordFilter", "stemmer"
|
||||
], options.pipeline)
|
||||
], config.pipeline)
|
||||
|
||||
/* Remove functions from the pipeline for registered languages */
|
||||
for (const lang of config.lang.map(language => (
|
||||
language === "en" ? lunr : (lunr as any)[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 reference */
|
||||
/* Set up index reference */
|
||||
this.ref("location")
|
||||
|
||||
/* Set up fields */
|
||||
this.field("title", { boost: 1e3 })
|
||||
this.field("text")
|
||||
this.field("tags", { boost: 1e6, extractor: doc => {
|
||||
const { tags = [] } = doc as SearchDocument
|
||||
return tags.reduce((list, tag) => [
|
||||
...list,
|
||||
...lunr.tokenizer(tag)
|
||||
], [] as lunr.Token[])
|
||||
} })
|
||||
/* 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") })
|
||||
|
||||
/* Index documents */
|
||||
/* Add documents to index */
|
||||
for (const doc of docs)
|
||||
this.add(doc, { boost: doc.boost })
|
||||
})
|
||||
@@ -221,105 +191,129 @@ export class Search {
|
||||
/**
|
||||
* Search for matching documents
|
||||
*
|
||||
* The search index which MkDocs provides is divided up into articles, which
|
||||
* contain the whole content of the individual pages, and sections, which only
|
||||
* contain the contents of the subsections obtained by breaking the individual
|
||||
* pages up at `h1` ... `h6`. As there may be many sections on different pages
|
||||
* with identical titles (for example within this very project, e.g. "Usage"
|
||||
* or "Installation"), they need to be put into the context of the containing
|
||||
* page. For this reason, section results are grouped within their respective
|
||||
* articles which are the top-level results that are returned.
|
||||
* @param query - Search query
|
||||
*
|
||||
* @param query - Query value
|
||||
*
|
||||
* @returns Search results
|
||||
* @returns Search result
|
||||
*/
|
||||
public search(query: string): SearchResult {
|
||||
if (query) {
|
||||
try {
|
||||
const highlight = this.highlight(query)
|
||||
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
|
||||
))
|
||||
/* 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}*`)
|
||||
/* Perform search and post-process results */
|
||||
const groups = this.index.search(query)
|
||||
|
||||
/* Apply post-query boosts based on title and search query terms */
|
||||
.reduce<SearchResultItem>((item, { ref, score, matchData }) => {
|
||||
const document = this.documents.get(ref)
|
||||
if (typeof document !== "undefined") {
|
||||
const { location, title, text, tags, parent } = document
|
||||
/* 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") {
|
||||
doc = { ...doc }
|
||||
if (doc.tags)
|
||||
doc.tags = [...doc.tags]
|
||||
|
||||
/* Compute and analyze search query terms */
|
||||
const terms = getSearchQueryTerms(
|
||||
clauses,
|
||||
Object.keys(matchData.metadata)
|
||||
/* Compute and analyze search query terms */
|
||||
const terms = getSearchQueryTerms(
|
||||
clauses,
|
||||
Object.keys(matchData.metadata)
|
||||
)
|
||||
|
||||
// we must collect all positions for each term!
|
||||
// we now take the keys of the index
|
||||
for (const field of this.index.fields) {
|
||||
if (!(field in doc))
|
||||
continue
|
||||
|
||||
/* Collect matches */
|
||||
const positions: Position[] = []
|
||||
for (const match of Object.values(matchData.metadata))
|
||||
if (field in match)
|
||||
positions.push(...match[field].position)
|
||||
|
||||
// @ts-expect-error - @todo fix typings
|
||||
if (Array.isArray(doc[field])) {
|
||||
// @ts-expect-error - @todo fix typings
|
||||
for (let i = 0; i < doc[field].length; i++) {
|
||||
// @ts-expect-error - @todo fix typings
|
||||
doc[field][i] = highlighter(doc[field][i],
|
||||
this.table.get([doc.location, field].join(":"))!,
|
||||
positions
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// @ts-expect-error - @todo fix typings
|
||||
doc[field] = highlighter(doc[field],
|
||||
this.table.get([doc.location, field].join(":"))!,
|
||||
positions
|
||||
)
|
||||
|
||||
/* Highlight title and text and apply post-query boosts */
|
||||
const boost = +!parent + +Object.values(terms).every(t => t)
|
||||
item.push({
|
||||
location,
|
||||
title: highlight(title),
|
||||
text: highlight(text),
|
||||
...tags && { tags: tags.map(highlight) },
|
||||
score: score * (1 + boost),
|
||||
terms
|
||||
})
|
||||
}
|
||||
return item
|
||||
}, [])
|
||||
}
|
||||
|
||||
/* Sort search results again after applying boosts */
|
||||
.sort((a, b) => b.score - a.score)
|
||||
/* Highlight title and text and apply post-query boosts */
|
||||
const boost = +!doc.parent +
|
||||
Object.values(terms)
|
||||
.filter(t => t).length /
|
||||
Object.keys(terms).length
|
||||
|
||||
/* Group search results by page */
|
||||
.reduce((items, result) => {
|
||||
const document = this.documents.get(result.location)
|
||||
if (typeof document !== "undefined") {
|
||||
const ref = "parent" in document
|
||||
? document.parent!.location
|
||||
: document.location
|
||||
items.set(ref, [...items.get(ref) || [], result])
|
||||
}
|
||||
return items
|
||||
}, new Map<string, SearchResultItem>())
|
||||
|
||||
/* Generate search suggestions, if desired */
|
||||
let suggestions: string[] | undefined
|
||||
if (this.options.suggestions) {
|
||||
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
|
||||
})
|
||||
/* Append item */
|
||||
item.push({
|
||||
...doc,
|
||||
score: score * (1 + boost ** 2),
|
||||
terms
|
||||
})
|
||||
|
||||
/* Retrieve suggestions for best match */
|
||||
suggestions = titles.length
|
||||
? Object.keys(titles[0].matchData.metadata)
|
||||
: []
|
||||
}
|
||||
return item
|
||||
}, [])
|
||||
|
||||
/* Return items and suggestions */
|
||||
return {
|
||||
items: [...groups.values()],
|
||||
...typeof suggestions !== "undefined" && { suggestions }
|
||||
/* 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[]>())
|
||||
|
||||
/* Log errors to console (for now) */
|
||||
} catch {
|
||||
console.warn(`Invalid query: ${query} – see https://bit.ly/2s3ChXG`)
|
||||
/* 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 nothing in case of error or empty query */
|
||||
return { items: [] }
|
||||
/* Return search result */
|
||||
return {
|
||||
items: [...groups.values()],
|
||||
...typeof suggest !== "undefined" && { suggest }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
115
src/assets/javascripts/integrations/search/config/index.ts
Normal file
115
src/assets/javascripts/integrations/search/config/index.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright (c) 2016-2022 Martin Donath <martin.donath@squidfunk.com>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to
|
||||
* deal in the Software without restriction, including without limitation the
|
||||
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
* sell copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION 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
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2016-2022 Martin Donath <martin.donath@squidfunk.com>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to
|
||||
* deal in the Software without restriction, including without limitation the
|
||||
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
* sell copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION 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 { SearchIndexDocument } from "../_"
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Types
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Search document
|
||||
*/
|
||||
export interface SearchDocument extends SearchIndexDocument {
|
||||
parent?: SearchIndexDocument /* Parent article */
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Search document mapping
|
||||
*/
|
||||
export type SearchDocumentMap = Map<string, SearchDocument>
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Functions
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a search document mapping
|
||||
*
|
||||
* @param docs - Search index documents
|
||||
*
|
||||
* @returns Search document map
|
||||
*/
|
||||
export function setupSearchDocumentMap(
|
||||
docs: SearchIndexDocument[]
|
||||
): SearchDocumentMap {
|
||||
const documents = new Map<string, SearchDocument>()
|
||||
const parents = new Set<SearchDocument>()
|
||||
for (const doc of docs) {
|
||||
const [path, hash] = doc.location.split("#")
|
||||
|
||||
/* Extract location, title and tags */
|
||||
const location = doc.location
|
||||
const title = doc.title
|
||||
const tags = doc.tags
|
||||
|
||||
/* Escape and cleanup text */
|
||||
const text = escapeHTML(doc.text)
|
||||
.replace(/\s+(?=[,.:;!?])/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
|
||||
/* Handle section */
|
||||
if (hash) {
|
||||
const parent = documents.get(path)!
|
||||
|
||||
/* Ignore first section, override article */
|
||||
if (!parents.has(parent)) {
|
||||
parent.title = doc.title
|
||||
parent.text = text
|
||||
|
||||
/* Remember that we processed the article */
|
||||
parents.add(parent)
|
||||
|
||||
/* Add subsequent section */
|
||||
} else {
|
||||
documents.set(location, {
|
||||
location,
|
||||
title,
|
||||
text,
|
||||
parent
|
||||
})
|
||||
}
|
||||
|
||||
/* Add article */
|
||||
} else {
|
||||
documents.set(location, {
|
||||
location,
|
||||
title,
|
||||
text,
|
||||
...tags && { tags }
|
||||
})
|
||||
}
|
||||
}
|
||||
return documents
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
import escapeHTML from "escape-html"
|
||||
|
||||
import { SearchIndexConfig } from "../_"
|
||||
import { SearchConfig } from "../config"
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Types
|
||||
@@ -53,15 +53,21 @@ export type SearchHighlightFactoryFn = (query: string) => SearchHighlightFn
|
||||
/**
|
||||
* Create a search highlighter
|
||||
*
|
||||
* @param config - Search index configuration
|
||||
* @param escape - Whether to escape HTML
|
||||
* @param config - Search configuration
|
||||
*
|
||||
* @returns Search highlight factory function
|
||||
*/
|
||||
export function setupSearchHighlighter(
|
||||
config: SearchIndexConfig, escape: boolean
|
||||
config: SearchConfig
|
||||
): SearchHighlightFactoryFn {
|
||||
const separator = new RegExp(config.separator, "img")
|
||||
// Hack: temporarily remove pure lookaheads
|
||||
const regex = config.separator.split("|").map(term => {
|
||||
const temp = term.replace(/(\(\?[!=][^)]+\))/g, "")
|
||||
return temp.length === 0 ? "<22>" : term
|
||||
})
|
||||
.join("|")
|
||||
|
||||
const separator = new RegExp(regex, "img")
|
||||
const highlight = (_: unknown, data: string, term: string) => {
|
||||
return `${data}<mark data-md-highlight>${term}</mark>`
|
||||
}
|
||||
@@ -73,19 +79,15 @@ export function setupSearchHighlighter(
|
||||
.trim()
|
||||
|
||||
/* Create search term match expression */
|
||||
const match = new RegExp(`(^|${config.separator})(${
|
||||
const match = new RegExp(`(^|${config.separator}|)(${
|
||||
query
|
||||
.replace(/[|\\{}()[\]^$+*?.-]/g, "\\$&")
|
||||
.replace(separator, "|")
|
||||
})`, "img")
|
||||
|
||||
/* Highlight string value */
|
||||
return value => (
|
||||
escape
|
||||
? escapeHTML(value)
|
||||
: value
|
||||
)
|
||||
.replace(match, highlight)
|
||||
.replace(/<\/mark>(\s+)<mark[^>]*>/img, "$1")
|
||||
return value => escapeHTML(value)
|
||||
.replace(match, highlight)
|
||||
.replace(/<\/mark>(\s+)<mark[^>]*>/img, "$1")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,7 @@
|
||||
*/
|
||||
|
||||
export * from "./_"
|
||||
export * from "./document"
|
||||
export * from "./config"
|
||||
export * from "./highlighter"
|
||||
export * from "./options"
|
||||
export * from "./query"
|
||||
export * from "./worker"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"rules": {
|
||||
"no-fallthrough": "off",
|
||||
"no-underscore-dangle": "off"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright (c) 2016-2022 Martin Donath <martin.donath@squidfunk.com>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to
|
||||
* deal in the Software without restriction, including without limitation the
|
||||
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
* sell copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION 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
|
||||
*
|
||||
* This function intentionally takes a visitor function contrary to collecting
|
||||
* and returning all ranges, as it's significantly more memory efficient.
|
||||
*
|
||||
* @param value - String value
|
||||
* @param separator - Separator
|
||||
* @param fn - Visitor function
|
||||
*/
|
||||
export function split(
|
||||
value: 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(value)
|
||||
|
||||
/* Emit non-empty range */
|
||||
const until = match?.index ?? value.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)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright (c) 2016-2022 Martin Donath <martin.donath@squidfunk.com>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to
|
||||
* deal in the Software without restriction, including without limitation the
|
||||
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
* sell copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION 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 block - Block index
|
||||
* @param operation - Operation index
|
||||
* @param start - Start offset
|
||||
* @param end - End offset
|
||||
*/
|
||||
type VisitorFn = (
|
||||
block: number, operation: number, start: number, end: number
|
||||
) => void
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Functions
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Extract all non-HTML parts of a string
|
||||
*
|
||||
* This function preprocesses the given string by isolating all non-HTML parts
|
||||
* of a string, in order to ensure that HTML tags are removed before indexing.
|
||||
* This function intentionally takes a visitor function contrary to collecting
|
||||
* and returning all sections, as it's significantly more memory efficient.
|
||||
*
|
||||
* @param value - String value
|
||||
* @param fn - Visitor function
|
||||
*/
|
||||
export function extract(
|
||||
value: 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 < value.length; end++) {
|
||||
|
||||
/* Tag start after non-empty section */
|
||||
if (value.charAt(end) === "<" && end > start) {
|
||||
fn(block, 1, start, start = end)
|
||||
|
||||
/* Tag end */
|
||||
} else if (value.charAt(end) === ">") {
|
||||
if (value.charAt(start + 1) === "/") {
|
||||
if (--stack === 0)
|
||||
fn(block++, 2, start, end + 1)
|
||||
|
||||
/* Tag is not self-closing */
|
||||
} else if (value.charAt(end - 1) !== "/") {
|
||||
if (stack++ === 0)
|
||||
fn(block, 0, start, end + 1)
|
||||
}
|
||||
|
||||
/* New section */
|
||||
start = end + 1
|
||||
}
|
||||
}
|
||||
|
||||
/* Add trailing section */
|
||||
if (end > start)
|
||||
fn(block, 1, start, end)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright (c) 2016-2022 Martin Donath <martin.donath@squidfunk.com>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to
|
||||
* deal in the Software without restriction, including without limitation the
|
||||
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
* sell copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION 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 { Position, PositionTable } from "../tokenizer"
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Functions
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Highlight all occurrences in a string
|
||||
*
|
||||
* @param value - String value
|
||||
* @param table - Table for indexing
|
||||
* @param positions - Occurrences
|
||||
*
|
||||
* @returns Highlighted string value
|
||||
*/
|
||||
export function highlighter(
|
||||
value: string, table: PositionTable, positions: Position[]
|
||||
): string {
|
||||
const slices: string[] = []
|
||||
|
||||
/* Map matches to blocks */
|
||||
const blocks = new Map<number, number[]>()
|
||||
for (const i of positions.sort((a, b) => a - b)) {
|
||||
const block = i >>> 20
|
||||
const index = i & 0xFFFFF
|
||||
|
||||
/* 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)
|
||||
}
|
||||
|
||||
/* Compute slices */
|
||||
for (const [block, indexes] of blocks) {
|
||||
const t = table[block]
|
||||
|
||||
/* Extract start and end positions, and length */
|
||||
const start = t[0] >>> 12
|
||||
const end = t[t.length - 1] >>> 12
|
||||
const length = t[t.length - 1] >>> 2 & 0x3FF
|
||||
|
||||
/* Extract and highlight slice/block */
|
||||
let slice = value.slice(start, end + length)
|
||||
for (const i of indexes.sort((a, b) => b - a)) {
|
||||
|
||||
/* Retrieve offset and length of match */
|
||||
const p = (t[i] >>> 12) - start
|
||||
const q = (t[i] >>> 2 & 0x3FF) + p
|
||||
|
||||
/* Wrap occurrence */
|
||||
slice = [
|
||||
slice.slice(0, p),
|
||||
"<mark>", slice.slice(p, q), "</mark>",
|
||||
slice.slice(q)
|
||||
].join("")
|
||||
}
|
||||
|
||||
/* Append slice and abort if we have two */
|
||||
if (slices.push(slice) === 2)
|
||||
break
|
||||
}
|
||||
|
||||
/* Return highlighted string value */
|
||||
return slices.join("")
|
||||
}
|
||||
@@ -20,29 +20,7 @@
|
||||
* IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Types
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Search pipeline function
|
||||
*/
|
||||
export type SearchPipelineFn =
|
||||
| "trimmer" /* Trimmer */
|
||||
| "stopWordFilter" /* Stop word filter */
|
||||
| "stemmer" /* Stemmer */
|
||||
|
||||
/**
|
||||
* Search pipeline
|
||||
*/
|
||||
export type SearchPipeline = SearchPipelineFn[]
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Search options
|
||||
*/
|
||||
export interface SearchOptions {
|
||||
pipeline: SearchPipeline /* Search pipeline */
|
||||
suggestions: boolean /* Search suggestions */
|
||||
}
|
||||
export * from "./_"
|
||||
export * from "./extractor"
|
||||
export * from "./highlighter"
|
||||
export * from "./tokenizer"
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright (c) 2016-2022 Martin Donath <martin.donath@squidfunk.com>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to
|
||||
* deal in the Software without restriction, including without limitation the
|
||||
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
* sell copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION 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 } from "../extractor"
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Types
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Table for indexing
|
||||
*/
|
||||
export type PositionTable = number[][]
|
||||
|
||||
/**
|
||||
* Position
|
||||
*/
|
||||
export type Position = number
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Functions
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Split a string 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.
|
||||
*
|
||||
* @param input - String value or token
|
||||
*
|
||||
* @returns Tokens
|
||||
*/
|
||||
export function tokenize(
|
||||
input?: string | string[]
|
||||
): lunr.Token[] {
|
||||
const tokens: lunr.Token[] = []
|
||||
|
||||
/**
|
||||
* Initialize segmenter, if loaded
|
||||
*
|
||||
* Note that doing this here is not ideal, but it's okay as we just test it
|
||||
* before bringing the new search implementation in its final shape.
|
||||
*/
|
||||
const segmenter = "TinySegmenter" in lunr
|
||||
? new lunr.TinySegmenter()
|
||||
: undefined
|
||||
|
||||
/* Tokenize an array of string values */
|
||||
if (Array.isArray(input)) {
|
||||
// @todo: handle multi-valued fields (e.g. tags)
|
||||
for (const value of input)
|
||||
tokens.push(...tokenize(value))
|
||||
|
||||
/* Tokenize a string value */
|
||||
} else if (input) {
|
||||
const table = lunr.tokenizer.table
|
||||
|
||||
/* Split string into sections and tokenize content blocks */
|
||||
extract(input, (block, type, start, end) => {
|
||||
if (type & 1) {
|
||||
const section = input.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 segmenter !== "undefined") {
|
||||
const subsection = section.slice(index, until)
|
||||
if (/^[MHIK]$/.test(segmenter.ctype_(subsection))) {
|
||||
const segments = segmenter.segment(subsection)
|
||||
for (let i = 0, l = 0; i < segments.length; i++) {
|
||||
|
||||
/* Add block to table */
|
||||
table[block] ||= []
|
||||
table[block].push(
|
||||
start + index + l << 12 |
|
||||
segments[i].length << 2 |
|
||||
type
|
||||
)
|
||||
|
||||
/* Add block as token */
|
||||
tokens.push(new lunr.Token(
|
||||
segments[i].toLowerCase(), {
|
||||
position: block << 20 | table[block].length - 1
|
||||
}
|
||||
))
|
||||
|
||||
/* Keep track of length */
|
||||
l += segments[i].length
|
||||
}
|
||||
return // combine segmenter with other approach!?
|
||||
}
|
||||
}
|
||||
|
||||
/* Add block to table */
|
||||
table[block] ||= []
|
||||
table[block].push(
|
||||
start + index << 12 |
|
||||
until - index << 2 |
|
||||
type
|
||||
)
|
||||
|
||||
/* Add block as token */
|
||||
tokens.push(new lunr.Token(
|
||||
section.slice(index, until).toLowerCase(), {
|
||||
position: block << 20 | table[block].length - 1
|
||||
}
|
||||
))
|
||||
})
|
||||
|
||||
/* Add non-content block to table */
|
||||
} else {
|
||||
table[block] ||= []
|
||||
table[block].push(
|
||||
start << 12 |
|
||||
end - start << 2 |
|
||||
type
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* Return tokens */
|
||||
return tokens
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"rules": {
|
||||
"no-control-regex": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off"
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@
|
||||
* IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import { split } from "../../internal"
|
||||
import { transform } from "../transform"
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Types
|
||||
* ------------------------------------------------------------------------- */
|
||||
@@ -43,9 +46,54 @@ 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)
|
||||
if (type === "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
|
||||
@@ -53,11 +101,28 @@ export type SearchQueryTerms = Record<string, boolean>
|
||||
export function parseSearchQuery(
|
||||
value: string
|
||||
): SearchQueryClause[] {
|
||||
const query = new (lunr as any).Query(["title", "text"])
|
||||
const parser = new (lunr as any).QueryParser(value, query)
|
||||
const query = new lunr.Query(["title", "text", "tags"])
|
||||
const parser = new lunr.QueryParser(value, query)
|
||||
|
||||
/* Parse and return query clauses */
|
||||
/* 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
|
||||
}
|
||||
|
||||
@@ -85,7 +150,7 @@ export function getSearchQueryTerms(
|
||||
|
||||
/* Annotate unmatched non-stopword query clauses */
|
||||
for (const clause of clauses)
|
||||
if (lunr.stopWordFilter?.(clause.term as any))
|
||||
if (lunr.stopWordFilter?.(clause.term))
|
||||
result[clause.term] = false
|
||||
|
||||
/* Return query terms */
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"rules": {
|
||||
"no-control-regex": "off"
|
||||
}
|
||||
}
|
||||
@@ -21,17 +21,19 @@
|
||||
*/
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Types
|
||||
* Helper types
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Search transformation function
|
||||
* Visitor function
|
||||
*
|
||||
* @param value - Query value
|
||||
* @param value - String value
|
||||
*
|
||||
* @returns Transformed query value
|
||||
* @returns String term(s)
|
||||
*/
|
||||
export type SearchTransformFn = (value: string) => string
|
||||
type VisitorFn = (
|
||||
value: string
|
||||
) => string | string[]
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Functions
|
||||
@@ -40,32 +42,55 @@ export type SearchTransformFn = (value: string) => string
|
||||
/**
|
||||
* Default transformation function
|
||||
*
|
||||
* 1. Search for terms in quotation marks and prepend a `+` modifier to denote
|
||||
* that the resulting document must contain all terms, converting the query
|
||||
* to an `AND` query (as opposed to the default `OR` behavior). While users
|
||||
* may expect terms 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 terms to an `AND` query.
|
||||
* 1. Trim excess whitespace from left and right.
|
||||
*
|
||||
* 2. Replace control characters which are not located at the beginning of the
|
||||
* 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.
|
||||
*
|
||||
* 3. Trim excess whitespace from left and right.
|
||||
* 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 defaultTransform(query: string): string {
|
||||
export function transform(
|
||||
query: string, fn: VisitorFn = term => term
|
||||
): string {
|
||||
return query
|
||||
.split(/"([^"]+)"/g) /* => 1 */
|
||||
.map((terms, index) => index & 1
|
||||
? terms.replace(/^\b|^(?![^\x00-\x7F]|$)|\s+/g, " +")
|
||||
: terms
|
||||
|
||||
/* => 1 */
|
||||
.trim()
|
||||
|
||||
/* => 2 */
|
||||
.split(/"([^"]+)"/g)
|
||||
.map((parts, index) => index & 1
|
||||
? parts.replace(/^\b|^(?![^\x00-\x7F]|$)|\s+/g, " +")
|
||||
: parts
|
||||
)
|
||||
.join("")
|
||||
.replace(/"|(?:^|\s+)[*+\-:^~]+(?=\s+|$)/g, "") /* => 2 */
|
||||
.trim() /* => 3 */
|
||||
|
||||
/* => 3 */
|
||||
.replace(/"|(?:^|\s+)[*+\-:^~]+(?=\s+|$)/g, "")
|
||||
|
||||
/* => 4 */
|
||||
.split(/\s+/g)
|
||||
.flatMap(fn)
|
||||
.map(term => /([~^]$)/.test(term) ? `${term}1` : term)
|
||||
.map(term => /(^[+-]|[~^]\d+$)/.test(term) ? term : `${term}*`)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
@@ -23,73 +23,21 @@
|
||||
import {
|
||||
ObservableInput,
|
||||
Subject,
|
||||
from,
|
||||
map,
|
||||
share
|
||||
first,
|
||||
merge,
|
||||
of,
|
||||
switchMap
|
||||
} from "rxjs"
|
||||
|
||||
import { configuration, feature, translation } from "~/_"
|
||||
import { WorkerHandler, watchWorker } from "~/browser"
|
||||
import { feature } from "~/_"
|
||||
import { watchToggle, watchWorker } from "~/browser"
|
||||
|
||||
import { SearchIndex } from "../../_"
|
||||
import {
|
||||
SearchOptions,
|
||||
SearchPipeline
|
||||
} from "../../options"
|
||||
import { SearchIndex } from "../../config"
|
||||
import {
|
||||
SearchMessage,
|
||||
SearchMessageType,
|
||||
SearchSetupMessage,
|
||||
isSearchResultMessage
|
||||
SearchMessageType
|
||||
} from "../message"
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Types
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Search worker
|
||||
*/
|
||||
export type SearchWorker = WorkerHandler<SearchMessage>
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Helper functions
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Set up search index
|
||||
*
|
||||
* @param data - Search index
|
||||
*
|
||||
* @returns Search index
|
||||
*/
|
||||
function setupSearchIndex({ config, docs }: SearchIndex): SearchIndex {
|
||||
|
||||
/* Override default language with value from translation */
|
||||
if (config.lang.length === 1 && config.lang[0] === "en")
|
||||
config.lang = [
|
||||
translation("search.config.lang")
|
||||
]
|
||||
|
||||
/* Override default separator with value from translation */
|
||||
if (config.separator === "[\\s\\-]+")
|
||||
config.separator = translation("search.config.separator")
|
||||
|
||||
/* Set pipeline from translation */
|
||||
const pipeline = translation("search.config.pipeline")
|
||||
.split(/\s*,\s*/)
|
||||
.filter(Boolean) as SearchPipeline
|
||||
|
||||
/* Determine search options */
|
||||
const options: SearchOptions = {
|
||||
pipeline,
|
||||
suggestions: feature("search.suggest")
|
||||
}
|
||||
|
||||
/* Return search index after defaulting */
|
||||
return { config, docs, options }
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Functions
|
||||
* ------------------------------------------------------------------------- */
|
||||
@@ -97,46 +45,51 @@ function setupSearchIndex({ config, docs }: SearchIndex): SearchIndex {
|
||||
/**
|
||||
* Set up search worker
|
||||
*
|
||||
* This function creates a web worker to set up and query the search index,
|
||||
* which is done using Lunr.js. The index must be passed as an observable to
|
||||
* enable hacks like _localsearch_ via search index embedding as JSON.
|
||||
* 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
|
||||
* @param index$ - Search index observable input
|
||||
*
|
||||
* @returns Search worker
|
||||
*/
|
||||
export function setupSearchWorker(
|
||||
url: string, index: ObservableInput<SearchIndex>
|
||||
): SearchWorker {
|
||||
const config = configuration()
|
||||
const worker = new Worker(url)
|
||||
|
||||
/* Create communication channels and resolve relative links */
|
||||
const tx$ = new Subject<SearchMessage>()
|
||||
const rx$ = watchWorker(worker, { tx$ })
|
||||
url: string, index$: ObservableInput<SearchIndex>
|
||||
): Subject<SearchMessage> {
|
||||
const worker$ = watchWorker<SearchMessage>(url)
|
||||
merge(
|
||||
of(location.protocol !== "file:"),
|
||||
watchToggle("search")
|
||||
)
|
||||
.pipe(
|
||||
map(message => {
|
||||
if (isSearchResultMessage(message)) {
|
||||
for (const result of message.data.items)
|
||||
for (const document of result)
|
||||
document.location = `${new URL(document.location, config.base)}`
|
||||
}
|
||||
return message
|
||||
}),
|
||||
share()
|
||||
first(active => active),
|
||||
switchMap(() => index$)
|
||||
)
|
||||
|
||||
/* Set up search index */
|
||||
from(index)
|
||||
.pipe(
|
||||
map(data => ({
|
||||
.subscribe(({ config, docs }) => worker$.next({
|
||||
type: SearchMessageType.SETUP,
|
||||
data: setupSearchIndex(data)
|
||||
} as SearchSetupMessage))
|
||||
)
|
||||
.subscribe(tx$.next.bind(tx$))
|
||||
data: {
|
||||
config,
|
||||
docs,
|
||||
options: {
|
||||
suggest: feature("search.suggest")
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
/* Return search worker */
|
||||
return { tx$, rx$ }
|
||||
return worker$
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"rules": {
|
||||
"no-console": "off",
|
||||
"@typescript-eslint/no-misused-promises": "off"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,11 @@
|
||||
|
||||
import lunr from "lunr"
|
||||
|
||||
import { getElement } from "~/browser/element/_"
|
||||
import "~/polyfills"
|
||||
|
||||
import { Search, SearchIndexConfig } from "../../_"
|
||||
import { Search } from "../../_"
|
||||
import { SearchConfig } from "../../config"
|
||||
import {
|
||||
SearchMessage,
|
||||
SearchMessageType
|
||||
@@ -35,14 +37,18 @@ import {
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add support for usage with `iframe-worker` polyfill
|
||||
* 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 polyfilled implementation. The
|
||||
* cool thing is that awaiting a non-Promise is a noop, so extending the type
|
||||
* definition to return a `Promise` shouldn't break anything.
|
||||
* 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
|
||||
@@ -65,25 +71,25 @@ let index: Search
|
||||
* Fetch (= import) multi-language support through `lunr-languages`
|
||||
*
|
||||
* This function automatically imports the stemmers necessary to process the
|
||||
* languages, which are defined through the search index configuration.
|
||||
* 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 index configuration
|
||||
* @param config - Search configuration
|
||||
*
|
||||
* @returns Promise resolving with no result
|
||||
*/
|
||||
async function setupSearchLanguages(
|
||||
config: SearchIndexConfig
|
||||
config: SearchConfig
|
||||
): Promise<void> {
|
||||
let base = "../lunr"
|
||||
|
||||
/* Detect `iframe-worker` and fix base URL */
|
||||
if (typeof parent !== "undefined" && "IFrameWorker" in parent) {
|
||||
const worker = document.querySelector<HTMLScriptElement>("script[src]")!
|
||||
const worker = getElement<HTMLScriptElement>("script[src]")!
|
||||
const [path] = worker.src.split("/worker")
|
||||
|
||||
/* Prefix base with path */
|
||||
@@ -150,9 +156,21 @@ export async function handler(
|
||||
|
||||
/* Search query message */
|
||||
case SearchMessageType.QUERY:
|
||||
return {
|
||||
type: SearchMessageType.RESULT,
|
||||
data: index ? index.search(message.data) : { items: [] }
|
||||
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 */
|
||||
@@ -165,7 +183,7 @@ export async function handler(
|
||||
* Worker
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/* @ts-expect-error - expose Lunr.js in global scope, or stemmers won't work */
|
||||
/* Expose Lunr.js in global scope, or stemmers won't work */
|
||||
self.lunr = lunr
|
||||
|
||||
/* Handle messages */
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
* IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import { SearchIndex, SearchResult } from "../../_"
|
||||
import { SearchResult } from "../../_"
|
||||
import { SearchIndex } from "../../config"
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Types
|
||||
@@ -84,19 +85,6 @@ export type SearchMessage =
|
||||
* Functions
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Type guard for search setup messages
|
||||
*
|
||||
* @param message - Search worker message
|
||||
*
|
||||
* @returns Test result
|
||||
*/
|
||||
export function isSearchSetupMessage(
|
||||
message: SearchMessage
|
||||
): message is SearchSetupMessage {
|
||||
return message.type === SearchMessageType.SETUP
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for search ready messages
|
||||
*
|
||||
@@ -110,19 +98,6 @@ export function isSearchReadyMessage(
|
||||
return message.type === SearchMessageType.READY
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for search query messages
|
||||
*
|
||||
* @param message - Search worker message
|
||||
*
|
||||
* @returns Test result
|
||||
*/
|
||||
export function isSearchQueryMessage(
|
||||
message: SearchMessage
|
||||
): message is SearchQueryMessage {
|
||||
return message.type === SearchMessageType.QUERY
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for search result messages
|
||||
*
|
||||
|
||||
@@ -23,12 +23,8 @@
|
||||
import { ComponentChild } from "preact"
|
||||
|
||||
import { configuration, feature, translation } from "~/_"
|
||||
import {
|
||||
SearchDocument,
|
||||
SearchMetadata,
|
||||
SearchResultItem
|
||||
} from "~/integrations/search"
|
||||
import { h, truncate } from "~/utilities"
|
||||
import { SearchItem } from "~/integrations/search"
|
||||
import { h } from "~/utilities"
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Helper types
|
||||
@@ -55,7 +51,7 @@ const enum Flag {
|
||||
* @returns Element
|
||||
*/
|
||||
function renderSearchDocument(
|
||||
document: SearchDocument & SearchMetadata, flag: Flag
|
||||
document: SearchItem, flag: Flag
|
||||
): HTMLElement {
|
||||
const parent = flag & Flag.PARENT
|
||||
const teaser = flag & Flag.TEASER
|
||||
@@ -69,7 +65,8 @@ function renderSearchDocument(
|
||||
.slice(0, -1)
|
||||
|
||||
/* Assemble query string for highlighting */
|
||||
const url = new URL(document.location)
|
||||
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)
|
||||
@@ -81,34 +78,25 @@ function renderSearchDocument(
|
||||
return (
|
||||
<a href={`${url}`} class="md-search-result__link" tabIndex={-1}>
|
||||
<article
|
||||
class={["md-search-result__article", ...parent
|
||||
? ["md-search-result__article--document"]
|
||||
: []
|
||||
].join(" ")}
|
||||
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>}
|
||||
<h1 class="md-search-result__title">{document.title}</h1>
|
||||
{parent > 0 && <h1>{document.title}</h1>}
|
||||
{parent <= 0 && <h2>{document.title}</h2>}
|
||||
{teaser > 0 && document.text.length > 0 &&
|
||||
<p class="md-search-result__teaser">
|
||||
{truncate(document.text, 320)}
|
||||
</p>
|
||||
document.text
|
||||
}
|
||||
{document.tags && (
|
||||
<div class="md-typeset">
|
||||
{document.tags.map(tag => {
|
||||
const id = tag.replace(/<[^>]+>/g, "")
|
||||
const type = tags
|
||||
? id in tags
|
||||
? `md-tag-icon md-tag-icon--${tags[id]}`
|
||||
: "md-tag-icon"
|
||||
: ""
|
||||
return (
|
||||
<span class={`md-tag ${type}`}>{tag}</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{document.tags && document.tags.map(tag => {
|
||||
const type = tags
|
||||
? tag in tags
|
||||
? `md-tag-icon md-tag-icon--${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}
|
||||
@@ -131,13 +119,18 @@ function renderSearchDocument(
|
||||
* @returns Element
|
||||
*/
|
||||
export function renderSearchResultItem(
|
||||
result: SearchResultItem
|
||||
result: SearchItem[]
|
||||
): HTMLElement {
|
||||
const threshold = result[0].score
|
||||
const docs = [...result]
|
||||
|
||||
const config = configuration()
|
||||
|
||||
/* Find and extract parent article */
|
||||
const parent = docs.findIndex(doc => !doc.location.includes("#"))
|
||||
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 */
|
||||
@@ -156,10 +149,12 @@ export function renderSearchResultItem(
|
||||
...more.length ? [
|
||||
<details class="md-search-result__more">
|
||||
<summary tabIndex={-1}>
|
||||
{more.length > 0 && more.length === 1
|
||||
? translation("search.result.more.one")
|
||||
: translation("search.result.more.other", more.length)
|
||||
}
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2016-2021 Martin Donath <martin.donath@squidfunk.com>
|
||||
* Copyright (c) 2016-2022 Martin Donath <martin.donath@squidfunk.com>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to
|
||||
|
||||
@@ -38,6 +38,7 @@ type Attributes =
|
||||
* Child element
|
||||
*/
|
||||
type Child =
|
||||
| ChildNode
|
||||
| HTMLElement
|
||||
| Text
|
||||
| string
|
||||
|
||||
@@ -21,4 +21,4 @@
|
||||
*/
|
||||
|
||||
export * from "./h"
|
||||
export * from "./string"
|
||||
export * from "./round"
|
||||
|
||||
@@ -24,28 +24,6 @@
|
||||
* Functions
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Truncate a string after the given number of characters
|
||||
*
|
||||
* This is not a very reasonable approach, since the summaries kind of suck.
|
||||
* It would be better to create something more intelligent, highlighting the
|
||||
* search occurrences and making a better summary out of it, but this note was
|
||||
* written three years ago, so who knows if we'll ever fix it.
|
||||
*
|
||||
* @param value - Value to be truncated
|
||||
* @param n - Number of characters
|
||||
*
|
||||
* @returns Truncated value
|
||||
*/
|
||||
export function truncate(value: string, n: number): string {
|
||||
let i = n
|
||||
if (value.length > i) {
|
||||
while (value[i] !== " " && --i > 0) { /* keep eating */ }
|
||||
return `${value.substring(0, i)}...`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Round a number for display with repository facts
|
||||
*
|
||||
Reference in New Issue
Block a user