Merged features tied to Carolina Reaper funding goal

This commit is contained in:
squidfunk
2022-12-07 11:11:02 +01:00
parent e0dce6cc1d
commit b550b1a532
118 changed files with 2544 additions and 1042 deletions

View File

@@ -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>
/* ------------------------------------------------------------------------- */

View File

@@ -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

View File

@@ -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)
)
}

View File

@@ -60,6 +60,8 @@ export function request(
)
}
/* ------------------------------------------------------------------------- */
/**
* Fetch JSON from the given URL
*

View File

@@ -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
*

View File

@@ -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>
}

View File

@@ -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 */

View File

@@ -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 */

View File

@@ -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

View File

@@ -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),

View File

@@ -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"),

View File

@@ -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$ }))
)
)

View File

@@ -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 => {

View File

@@ -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)
)
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 */

View File

@@ -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 */

View File

@@ -1,6 +0,0 @@
{
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"no-console": "off"
}
}

View File

@@ -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 }
}
}
}

View 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
}

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -21,8 +21,7 @@
*/
export * from "./_"
export * from "./document"
export * from "./config"
export * from "./highlighter"
export * from "./options"
export * from "./query"
export * from "./worker"

View File

@@ -0,0 +1,6 @@
{
"rules": {
"no-fallthrough": "off",
"no-underscore-dangle": "off"
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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("")
}

View File

@@ -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"

View File

@@ -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
}

View File

@@ -1,5 +1,6 @@
{
"rules": {
"no-control-regex": "off",
"@typescript-eslint/no-explicit-any": "off"
}
}

View File

@@ -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 */

View File

@@ -1,5 +0,0 @@
{
"rules": {
"no-control-regex": "off"
}
}

View File

@@ -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(" ")
}

View File

@@ -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$
}

View File

@@ -1,5 +1,6 @@
{
"rules": {
"no-console": "off",
"@typescript-eslint/no-misused-promises": "off"
}
}

View File

@@ -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 */

View File

@@ -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
*

View File

@@ -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>

View File

@@ -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

View File

@@ -38,6 +38,7 @@ type Attributes =
* Child element
*/
type Child =
| ChildNode
| HTMLElement
| Text
| string

View File

@@ -21,4 +21,4 @@
*/
export * from "./h"
export * from "./string"
export * from "./round"

View File

@@ -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
*