Cleaned up search worker integration

This commit is contained in:
squidfunk 2020-03-27 15:29:17 +01:00
parent c738110391
commit 9e74bb7a32
48 changed files with 495 additions and 416 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,10 +1,10 @@
{
"assets/javascripts/bundle.js": "assets/javascripts/bundle.7d91fa9c.min.js",
"assets/javascripts/bundle.js.map": "assets/javascripts/bundle.7d91fa9c.min.js.map",
"assets/javascripts/vendor.js": "assets/javascripts/vendor.0c35f0aa.min.js",
"assets/javascripts/vendor.js.map": "assets/javascripts/vendor.0c35f0aa.min.js.map",
"assets/javascripts/worker/search.js": "assets/javascripts/worker/search.2613054f.min.js",
"assets/javascripts/worker/search.js.map": "assets/javascripts/worker/search.2613054f.min.js.map",
"assets/javascripts/bundle.js": "assets/javascripts/bundle.659deb65.min.js",
"assets/javascripts/bundle.js.map": "assets/javascripts/bundle.659deb65.min.js.map",
"assets/javascripts/vendor.js": "assets/javascripts/vendor.3340e0de.min.js",
"assets/javascripts/vendor.js.map": "assets/javascripts/vendor.3340e0de.min.js.map",
"assets/javascripts/worker/search.js": "assets/javascripts/worker/search.0ec816aa.min.js",
"assets/javascripts/worker/search.js.map": "assets/javascripts/worker/search.0ec816aa.min.js.map",
"assets/stylesheets/main.css": "assets/stylesheets/main.b32d3181.min.css",
"assets/stylesheets/palette.css": "assets/stylesheets/palette.4444686e.min.css"
}

View File

@ -177,8 +177,8 @@
<script>var __config={}</script>
{% endblock %}
{% block scripts %}
<script src="{{ 'assets/javascripts/vendor.0c35f0aa.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.7d91fa9c.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/vendor.3340e0de.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.659deb65.min.js' | url }}"></script>
{%- set translations = {} -%}
{%- for key in [
"clipboard.copy",
@ -194,18 +194,16 @@
{%- set _ = translations.update({ key: lang.t(key) }) -%}
{%- endfor -%}
<script id="__lang" type="application/json">
{{ translations | tojson }}
{{- translations | tojson -}}
</script>
<script>
__material = initialize(Object.assign({
url: {
base: "{{ base_url }}",
worker: {
search: "{{ 'assets/javascripts/worker/search.2613054f.min.js' | url }}"
}
},
features: {{ config.theme.features | tojson }}
}, typeof __config !== "undefined" ? __config : {}))
app = initialize({
base: "{{ base_url }}",
features: {{ config.theme.features | tojson }},
search: {
worker: "{{ 'assets/javascripts/worker/search.0ec816aa.min.js' | url }}"
}
})
</script>
{% for path in config["extra_javascript"] %}
<script src="{{ path | url }}"></script>

View File

@ -51,7 +51,7 @@ interface WatchOptions {
/**
* Watch document switch
*
* This function returns an observables that fetches a document if the provided
* This function returns an observable that fetches a document if the provided
* location observable emits a new value (i.e. URL). If the emitted URL points
* to the same page, the request is effectively ignored (i.e. when only the
* fragment identifier changes).

View File

@ -87,3 +87,17 @@ export function getElements<T extends HTMLElement>(
): T[] {
return Array.from(node.querySelectorAll<T>(selector))
}
/* ------------------------------------------------------------------------- */
/**
* Replace an element with another element
*
* @param source - Source element
* @param target - Target element
*/
export function replaceElement(
source: HTMLElement, target: Node
): void {
source.replaceWith(target)
}

View File

@ -21,7 +21,7 @@
*/
import { Observable, fromEvent, merge } from "rxjs"
import { mapTo, shareReplay, startWith } from "rxjs/operators"
import { map, shareReplay, startWith } from "rxjs/operators"
import { getActiveElement } from "../_"
@ -56,15 +56,12 @@ el: HTMLElement, value: boolean = true
export function watchElementFocus(
el: HTMLElement
): Observable<boolean> {
const focus$ = fromEvent(el, "focus")
const blur$ = fromEvent(el, "blur")
/* Map events to boolean state */
return merge(
focus$.pipe(mapTo(true)),
blur$.pipe(mapTo(false))
fromEvent<FocusEvent>(el, "focus"),
fromEvent<FocusEvent>(el, "blur")
)
.pipe(
map(({ type }) => type === "focus"),
startWith(el === getActiveElement()),
shareReplay(1)
)

View File

@ -66,8 +66,8 @@ export function watchElementOffset(
el: HTMLElement
): Observable<ElementOffset> {
return merge(
fromEvent<UIEvent>(el, "scroll"),
fromEvent<UIEvent>(window, "resize")
fromEvent(el, "scroll"),
fromEvent(window, "resize")
)
.pipe(
map(() => getElementOffset(el)),

View File

@ -20,7 +20,7 @@
* IN THE SOFTWARE.
*/
import { BehaviorSubject } from "rxjs"
import { BehaviorSubject, Subject } from "rxjs"
/* ----------------------------------------------------------------------------
* Functions
@ -52,14 +52,14 @@ export function setLocation(url: URL): void {
/* ------------------------------------------------------------------------- */
/**
* Check whether a URL is an internal link or a file (except `.html`)
* Check whether a URL is a local link or a file (except `.html`)
*
* @param url - URL or HTML anchor element
* @param ref - Reference URL
*
* @return Test result
*/
export function isLocationInternal(
export function isLocalLocation(
url: URL | HTMLAnchorElement,
ref: URL | Location = location
): boolean {
@ -75,7 +75,7 @@ export function isLocationInternal(
*
* @return Test result
*/
export function isLocationAnchor(
export function isAnchorLocation(
url: URL | HTMLAnchorElement,
ref: URL | Location = location
): boolean {
@ -90,6 +90,6 @@ export function isLocationAnchor(
*
* @return Location subject
*/
export function watchLocation(): BehaviorSubject<URL> {
export function watchLocation(): Subject<URL> {
return new BehaviorSubject<URL>(getLocation())
}

View File

@ -0,0 +1,56 @@
/*
* Copyright (c) 2016-2020 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import { Observable } from "rxjs"
import { map, shareReplay, take } from "rxjs/operators"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
location$: Observable<URL> /* Location observable */
}
/* ------------------------------------------------------------------------- */
/**
* Watch location base
*
* @return Location base observable
*/
export function watchLocationBase(
base: string, { location$ }: WatchOptions
): Observable<string> {
return location$
.pipe(
take(1),
map(({ href }) => new URL(base, href)
.toString()
.replace(/\/$/, "")
),
shareReplay(1)
)
}

View File

@ -40,9 +40,9 @@ export function getLocationHash(): string {
* Set location hash
*
* Setting a new fragment identifier via `location.hash` will have no effect
* if the value doesn't change. However, when a new fragment identifier is set,
* we want the browser to target the respective element at all times, which is
* why we use this dirty little trick.
* if the value doesn't change. When a new fragment identifier is set, we want
* the browser to target the respective element at all times, which is why we
* use this dirty little trick.
*
* @param hash - Location hash
*/

View File

@ -21,4 +21,5 @@
*/
export * from "./_"
export * from "./base"
export * from "./hash"

View File

@ -74,8 +74,8 @@ export function setViewportOffset(
*/
export function watchViewportOffset(): Observable<ViewportOffset> {
return merge(
fromEvent<UIEvent>(window, "scroll", { passive: true }),
fromEvent<UIEvent>(window, "resize", { passive: true })
fromEvent(window, "scroll", { passive: true }),
fromEvent(window, "resize", { passive: true })
)
.pipe(
map(getViewportOffset),

View File

@ -59,7 +59,7 @@ export function getViewportSize(): ViewportSize {
* @return Viewport size observable
*/
export function watchViewportSize(): Observable<ViewportSize> {
return fromEvent<UIEvent>(window, "resize")
return fromEvent(window, "resize")
.pipe(
map(getViewportSize),
startWith(getViewportSize())

View File

@ -38,7 +38,7 @@ import {
*/
export interface WorkerMessage {
type: unknown /* Message type */
data: unknown /* Message data */
data?: unknown /* Message data */
}
/**

View File

@ -29,7 +29,7 @@ import {
switchMap
} from "rxjs/operators"
import { getElement } from "browser"
import { getElement, replaceElement } from "browser"
/* ----------------------------------------------------------------------------
* Types
@ -85,7 +85,7 @@ let components$: Observable<ComponentMap>
* ------------------------------------------------------------------------- */
/**
* Setup bindings to components with given names
* Set up bindings to components with given names
*
* This function will maintain bindings to the elements identified by the given
* names in-between document switches and update the elements in-place.
@ -118,7 +118,7 @@ export function setupComponents(
case "container":
case "skip":
if (name in prev && typeof prev[name] !== "undefined") {
prev[name]!.replaceWith(next[name]!)
replaceElement(prev[name]!, next[name]!)
prev[name] = next[name]
}
break

View File

@ -31,8 +31,9 @@ import { WorkerHandler, setToggle } from "browser"
import {
SearchMessage,
SearchMessageType,
SearchQueryMessage
} from "workers"
SearchQueryMessage,
SearchTransformFn
} from "integrations"
import { watchSearchQuery } from "../react"
@ -56,7 +57,7 @@ export interface SearchQuery {
* Mount options
*/
interface MountOptions {
transform?(value: string): string /* Transformation function */
transform?: SearchTransformFn /* Transformation function */
}
/* ----------------------------------------------------------------------------

View File

@ -29,6 +29,7 @@ import {
} from "rxjs/operators"
import { watchElementFocus } from "browser"
import { SearchTransformFn, defaultTransform } from "integrations"
import { SearchQuery } from "../_"
@ -40,28 +41,7 @@ import { SearchQuery } from "../_"
* Watch options
*/
interface WatchOptions {
transform?(value: string): string /* Transformation function */
}
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Default transformation function
*
* Rogue control characters are filtered before handing the query to the
* search index, as `lunr` will throw otherwise.
*
* @param value - Query value
*
* @return Transformed query value
*/
function defaultTransform(value: string): string {
return value
.replace(/(?:^|\s+)[*+-:^~]+(?=\s+|$)/g, "")
.trim()
.replace(/\s+|\b$/g, "* ")
transform?: SearchTransformFn /* Transformation function */
}
/* ----------------------------------------------------------------------------

View File

@ -31,11 +31,11 @@ import {
} from "rxjs/operators"
import { WorkerHandler, watchElementOffset } from "browser"
import { SearchResult } from "integrations/search"
import {
SearchMessage,
SearchResult,
isSearchResultMessage
} from "workers"
} from "integrations"
import { SearchQuery } from "../../query"
import { applySearchResult } from "../react"

View File

@ -73,7 +73,7 @@ export function resetSearchResultMeta(
* @param child - Search result element
*/
export function addToSearchResultList(
el: HTMLElement, child: HTMLElement
el: HTMLElement, child: Element
): void {
el.appendChild(child)
}

View File

@ -33,8 +33,10 @@ import {
animationFrameScheduler,
fromEvent,
of,
NEVER
NEVER,
from
} from "rxjs"
import { ajax } from "rxjs/ajax"
import {
delay,
switchMap,
@ -44,7 +46,8 @@ import {
observeOn,
take,
shareReplay,
share
share,
pluck
} from "rxjs/operators"
import {
@ -56,12 +59,11 @@ import {
watchLocation,
watchLocationHash,
watchViewport,
isLocationInternal,
isLocationAnchor,
setLocationHash
} from "./browser"
import { setupSearchWorker } from "./workers"
isLocalLocation,
isAnchorLocation,
setLocationHash,
watchLocationBase
} from "browser"
import {
mountHeader,
mountHero,
@ -76,10 +78,14 @@ import {
mountSearchReset,
mountSearchResult
} from "components"
import { setupClipboard } from "./integrations/clipboard"
import { setupDialog } from "integrations/dialog"
import { setupKeyboard } from "./integrations/keyboard"
import { setupInstantLoading } from "integrations/instant"
import {
setupClipboard,
setupDialog,
setupKeyboard,
setupInstantLoading,
setupSearchWorker,
SearchIndex
} from "integrations"
import {
patchTables,
patchDetails,
@ -91,6 +97,7 @@ import { isConfig } from "utilities"
/* ------------------------------------------------------------------------- */
/* Denote that JavaScript is available */
document.documentElement.classList.remove("no-js")
document.documentElement.classList.add("js")
@ -139,19 +146,22 @@ export function initialize(config: unknown) {
if (!isConfig(config))
throw new SyntaxError(`Invalid configuration: ${JSON.stringify(config)}`)
/* Setup user interface observables */
/* Set up user interface observables */
const location$ = watchLocation()
const base$ = watchLocationBase(config.base, { location$ })
const hash$ = watchLocationHash()
const viewport$ = watchViewport()
const tablet$ = watchMedia("(min-width: 960px)")
const screen$ = watchMedia("(min-width: 1220px)")
/* Setup document observable */
/* Set up document observable */
const document$ = config.features.includes("instant")
? watchDocument({ location$ })
: watchDocument()
/* Setup component bindings */
/* ----------------------------------------------------------------------- */
/* Set up component bindings */
setupComponents([
"container", /* Container */
"header", /* Header */
@ -168,17 +178,19 @@ export function initialize(config: unknown) {
"toc" /* Table of contents */
], { document$ })
/* ----------------------------------------------------------------------- */
const keyboard$ = setupKeyboard()
// External index
const index = config.search && config.search.index
? config.search.index
: undefined
patchDetails({ document$, hash$ })
patchScripts({ document$ })
patchSource({ document$ })
patchTables({ document$ })
// TODO: pass URL config as first parameter, options as second
const worker = setupSearchWorker(config.url.worker.search, {
base: config.url.base, index, location$
})
/* Force 1px scroll offset to trigger overflow scrolling */
patchScrollfix({ document$ })
/* Set up clipboard and dialog */
const dialog$ = setupDialog()
const clipboard$ = setupClipboard({ document$, dialog$ })
/* ----------------------------------------------------------------------- */
@ -197,37 +209,6 @@ export function initialize(config: unknown) {
/* ----------------------------------------------------------------------- */
/* Mount search query */
const query$ = useComponent("search-query")
.pipe(
mountSearchQuery(worker),
shareReplay(1)
)
/* Mount search reset */
const reset$ = useComponent("search-reset")
.pipe(
mountSearchReset(),
shareReplay(1)
)
/* Mount search result */
const result$ = useComponent("search-result")
.pipe(
mountSearchResult(worker, { query$ }),
shareReplay(1)
)
/* ----------------------------------------------------------------------- */
const search$ = useComponent("search")
.pipe(
mountSearch({ query$, reset$, result$ }),
shareReplay(1)
)
/* ----------------------------------------------------------------------- */
const navigation$ = useComponent("navigation")
.pipe(
mountNavigation({ header$, main$, viewport$, screen$ }),
@ -254,19 +235,62 @@ export function initialize(config: unknown) {
/* ----------------------------------------------------------------------- */
const keyboard$ = setupKeyboard()
// External index
const index = config.search && config.search.index
? config.search.index
: undefined
patchDetails({ document$, hash$ })
patchScripts({ document$ })
patchSource({ document$ })
patchTables({ document$ })
/* Fetch index if it wasn't passed explicitly */
const index$ = typeof index !== "undefined"
? from(index)
: base$
.pipe(
switchMap(base => ajax({
url: `${base}/search/search_index.json`,
responseType: "json",
withCredentials: true
})
.pipe<SearchIndex>(
pluck("response")
)
)
)
/* Force 1px scroll offset to trigger overflow scrolling */
patchScrollfix({ document$ })
/* Setup clipboard and dialog */
const dialog$ = setupDialog()
const clipboard$ = setupClipboard({ document$, dialog$ })
const worker = setupSearchWorker(config.search.worker, {
base$, index$
})
/* ----------------------------------------------------------------------- */
/* Mount search query */
const query$ = useComponent("search-query")
.pipe(
mountSearchQuery(worker),
shareReplay(1)
)
/* Mount search reset */
const reset$ = useComponent("search-reset")
.pipe(
mountSearchReset(),
shareReplay(1)
)
/* Mount search result */
const result$ = useComponent("search-result")
.pipe(
mountSearchResult(worker, { query$ }),
shareReplay(1)
)
/* ----------------------------------------------------------------------- */
const search$ = useComponent("search")
.pipe(
mountSearch({ query$, reset$, result$ }),
shareReplay(1)
)
/* ----------------------------------------------------------------------- */
@ -310,8 +334,8 @@ export function initialize(config: unknown) {
switchMap(ev => {
if (ev.target instanceof HTMLElement) {
const el = ev.target.closest("a") // TODO: abstract as link click?
if (el && isLocationInternal(el)) {
if (!isLocationAnchor(el) && config.features.includes("instant"))
if (el && isLocalLocation(el)) {
if (!isAnchorLocation(el) && config.features.includes("instant"))
ev.preventDefault()
return of(el)
}
@ -326,8 +350,6 @@ export function initialize(config: unknown) {
setToggle("drawer", false)
})
// somehow call this setupNavigation ?
// instant loading
if (config.features.includes("instant")) {

View File

@ -45,7 +45,7 @@ interface SetupOptions {
* ------------------------------------------------------------------------- */
/**
* Setup clipboard
* Set up clipboard
*
* This function implements the Clipboard.js integration and injects a button
* into all code blocks when the document changes.
@ -70,7 +70,7 @@ export function setupClipboard(
})
})
/* Initialize and setup clipboard */
/* Initialize clipboard */
const clipboard$ = fromEventPattern<ClipboardJS.Event>(next => {
new ClipboardJS(".md-clipboard").on("success", next)
})

View File

@ -45,7 +45,7 @@ interface SetupOptions {
* ------------------------------------------------------------------------- */
/**
* Setup dialog
* Set up dialog
*
* @param options - Options
*

View File

@ -20,4 +20,8 @@
* IN THE SOFTWARE.
*/
export * from "./clipboard"
export * from "./dialog"
export * from "./instant"
export * from "./keyboard"
export * from "./search"

View File

@ -38,7 +38,7 @@ import {
Viewport,
ViewportOffset,
getElement,
isLocationAnchor,
isAnchorLocation,
setLocationHash,
setViewportOffset
} from "browser"
@ -72,7 +72,7 @@ interface SetupOptions {
* ------------------------------------------------------------------------- */
/**
* Setup instant loading
* Set up instant loading
*
* @param options - Options
*
@ -91,7 +91,7 @@ export function setupInstantLoading(
const push$ = state$
.pipe(
distinctUntilChanged((prev, next) => prev.url.href === next.url.href),
filter(({ url }) => !isLocationAnchor(url)),
filter(({ url }) => !isAnchorLocation(url)),
share()
)
@ -129,7 +129,7 @@ export function setupInstantLoading(
bufferCount(2, 1),
filter(([prev, next]) => {
return prev.url.pathname === next.url.pathname
&& !isLocationAnchor(next.url)
&& !isAnchorLocation(next.url)
}),
map(([, state]) => state)
)

View File

@ -67,9 +67,9 @@ export interface Keyboard extends Key {
* ------------------------------------------------------------------------- */
/**
* Setup keyboard
* Set up keyboard
*
* This function will setup the keyboard handlers and ensure that keys are
* This function will set up the keyboard handlers and ensure that keys are
* correctly propagated. Currently there are two modes:
*
* - `global`: This mode is active when the search is closed. It is intended
@ -103,7 +103,7 @@ export function setupKeyboard(): Observable<Keyboard> {
share()
)
/* Setup search keyboard handlers */
/* Set up search keyboard handlers */
keyboard$
.pipe(
filter(({ mode }) => mode === "search"),
@ -155,7 +155,7 @@ export function setupKeyboard(): Observable<Keyboard> {
}
})
/* Setup global keyboard handlers */
/* Set up global keyboard handlers */
keyboard$
.pipe(
filter(({ mode }) => mode === "global"),

View File

@ -29,7 +29,7 @@ import {
import {
SearchHighlightFactoryFn,
setupSearchHighlighter
} from "../highlight"
} from "../highlighter"
/* ----------------------------------------------------------------------------
* Types
@ -70,16 +70,16 @@ export type SearchIndexPipeline = SearchIndexPipelineFn[]
/* ------------------------------------------------------------------------- */
/**
* Search index options
* Search index
*
* This interfaces describes the format of the `search_index.json` file which
* is automatically built by the MkDocs search plugin.
*/
export interface SearchIndexOptions {
export interface SearchIndex {
config: SearchIndexConfig /* Search index configuration */
docs: SearchIndexDocument[] /* Search index documents */
pipeline?: SearchIndexPipeline /* Search index pipeline */
index?: object | string /* Prebuilt or serialized index */
pipeline?: SearchIndexPipeline /* Search index pipeline */
}
/* ------------------------------------------------------------------------- */
@ -97,12 +97,12 @@ export interface SearchResult {
* ------------------------------------------------------------------------- */
/**
* Search index
* Search
*
* Note that `lunr` is injected via Webpack, as it will otherwise also be
* bundled in the application bundle.
*/
export class SearchIndex {
export class Search {
/**
* Search document mapping
@ -125,11 +125,11 @@ export class SearchIndex {
protected index: lunr.Index
/**
* Create a search index
* Create the search integration
*
* @param options - Options
* @param data - Search index
*/
public constructor({ config, docs, pipeline, index }: SearchIndexOptions) {
public constructor({ config, docs, pipeline, index }: SearchIndex) {
this.documents = setupSearchDocumentMap(docs)
this.highlight = setupSearchHighlighter(config)
@ -150,7 +150,7 @@ export class SearchIndex {
this.use((lunr as any).multiLanguage(...config.lang))
}
/* Setup fields and reference */
/* Set up fields and reference */
this.field("title", { boost: 1000 })
this.field("text")
this.ref("location")
@ -182,16 +182,16 @@ export class SearchIndex {
* page. For this reason, section results are grouped within their respective
* articles which are the top-level results that are returned.
*
* @param query - Query string
* @param value - Query value
*
* @return Search results
*/
public search(query: string): SearchResult[] {
if (query) {
public query(value: string): SearchResult[] {
if (value) {
try {
/* Group sections by containing article */
const groups = this.index.search(query)
const groups = this.index.search(value)
.reduce((results, result) => {
const document = this.documents.get(result.ref)
if (typeof document !== "undefined") {
@ -207,7 +207,7 @@ export class SearchIndex {
}, new Map<string, lunr.Index.Result[]>())
/* Create highlighter for query */
const fn = this.highlight(query)
const fn = this.highlight(value)
/* Map groups to search documents */
return [...groups].map(([ref, sections]) => ({
@ -220,20 +220,11 @@ export class SearchIndex {
/* Log errors to console (for now) */
} catch (err) {
// tslint:disable-next-line no-console
console.warn(`Invalid query: ${query} see https://bit.ly/2s3ChXG`)
console.warn(`Invalid query: ${value} see https://bit.ly/2s3ChXG`)
}
}
/* Return nothing in case of error or empty query */
return []
}
/**
* Serialize search index
*
* @return String representation
*/
public toString(): string {
return JSON.stringify(this.index)
}
}

View File

@ -36,18 +36,18 @@ import { SearchDocument } from "../document"
*
* @return Highlighted document
*/
export type SearchHighlightFn =
<T extends SearchDocument>(document: Readonly<T>) => T
export type SearchHighlightFn = <
T extends SearchDocument
>(document: Readonly<T>) => T
/**
* Search highlight factory function
*
* @param query - Query string
* @param value - Query value
*
* @return Search highlight function
*/
export type SearchHighlightFactoryFn =
(query: string) => SearchHighlightFn
export type SearchHighlightFactoryFn = (value: string) => SearchHighlightFn
/* ----------------------------------------------------------------------------
* Functions
@ -69,15 +69,15 @@ export function setupSearchHighlighter(
}
/* Return factory function */
return (query: string) => {
query = query
return (value: string) => {
value = value
.replace(/[\s*+-:~^]+/g, " ")
.trim()
/* Create search term match expression */
const match = new RegExp(`(^|${config.separator})(${
query
.replace(/[|\\{}()[\]^$+*?.-]/g, "\\$&") // TODO: taken from escape-string-regexp
value
.replace(/[|\\{}()[\]^$+*?.-]/g, "\\$&")
.replace(separator, "|")
})`, "img")

View File

@ -21,8 +21,7 @@
*/
export * from "./_"
export {
ArticleDocument,
SearchDocument,
SectionDocument
} from "./document"
export * from "./document"
export * from "./highlighter"
export * from "./transform"
export * from "./worker"

View File

@ -0,0 +1,55 @@
/*
* Copyright (c) 2016-2020 Martin Donath <martin.donath@squidfunk.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 transformation function
*
* @param value - Query value
*
* @return Transformed query value
*/
export type SearchTransformFn = (value: string) => string
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Default transformation function
*
* Rogue control characters are filtered before handing the query to the
* search index, as `lunr` will throw otherwise.
*
* @param value - Query value
*
* @return Transformed query value
*/
export function defaultTransform(value: string): string {
return value
.replace(/(?:^|\s+)[*+-:^~]+(?=\s+|$)/g, "")
.trim()
.replace(/\s+|\b$/g, "* ")
}

View File

@ -20,22 +20,19 @@
* IN THE SOFTWARE.
*/
import { Observable, Subject, asyncScheduler, from } from "rxjs"
import { ajax } from "rxjs/ajax"
import { identity } from "ramda"
import { Observable, Subject, asyncScheduler } from "rxjs"
import {
map,
observeOn,
pluck,
shareReplay,
switchMap,
take,
withLatestFrom
} from "rxjs/operators"
import { WorkerHandler, watchWorker } from "browser"
import { SearchIndexConfig, SearchIndexOptions } from "integrations/search"
import { translate } from "utilities"
import { SearchIndex, SearchIndexPipeline } from "../../_"
import {
SearchMessage,
SearchMessageType,
@ -51,9 +48,40 @@ import {
* Setup options
*/
interface SetupOptions {
base: string /* Base url */
index?: Promise<SearchIndexOptions> /* Promise resolving with index */
location$: Observable<URL> /* Location observable */
index$: Observable<SearchIndex> /* Search index observable */
base$: Observable<string> /* Location base observable */
}
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Set up search index
*
* @param data - Search index
*
* @return Search index
*/
function setupSearchIndex(
{ config, docs, index }: SearchIndex
): SearchIndex {
/* Override default language with value from translation */
if (config.lang.length === 1 && config.lang[0] === "en")
config.lang = [translate("search.config.lang")]
/* Override default separator with value from translation */
if (config.separator === "[\s\-]+")
config.separator = translate("search.config.separator")
/* Set pipeline from translation */
const pipeline = translate("search.config.pipeline")
.split(/\s*,\s*/)
.filter(identity) as SearchIndexPipeline
/* Return search index after defaulting */
return { config, docs, index, pipeline }
}
/* ----------------------------------------------------------------------------
@ -61,44 +89,33 @@ interface SetupOptions {
* ------------------------------------------------------------------------- */
/**
* Setup search web worker
* Set up search web worker
*
* This function will create a web worker to setup and query the search index
* which is done using `lunr`. The index can be passed explicitly in order to
* enable hacks like _localsearch_ via search index embedding as JSON. If no
* index is given, this function will load it from the default location.
* This function will create a web worker to set up and query the search index
* which is done using `lunr`. The index must be passed as an observable to
* enable hacks like _localsearch_ via search index embedding as JSON.
*
* @param url - Worker url
* @param url - Worker URL
* @param options - Options
*
* @return Worker handler
*/
export function setupSearchWorker(
url: string, { base, index, location$ }: SetupOptions
url: string, { index$, base$ }: SetupOptions
): WorkerHandler<SearchMessage> {
const worker = new Worker(url)
/* Ensure stable base URL */
const origin$ = location$
.pipe(
take(1),
map(({ href }) => new URL(base, href)
.toString()
.replace(/\/$/, "")
)
)
/* Create communication channels and resolve relative links */
const tx$ = new Subject<SearchMessage>()
const rx$ = watchWorker(worker, { tx$ })
.pipe(
withLatestFrom(origin$),
map(([message, origin]) => {
withLatestFrom(base$),
map(([message, base]) => {
if (isSearchResultMessage(message)) {
for (const { article, sections } of message.data) {
article.location = `${origin}/${article.location}`
article.location = `${base}/${article.location}`
for (const section of sections)
section.location = `${origin}/${section.location}`
section.location = `${base}/${section.location}`
}
}
return message
@ -106,57 +123,14 @@ export function setupSearchWorker(
shareReplay(1)
)
/* Fetch index if it wasn't passed explicitly */
const index$ = typeof index !== "undefined"
? from(index)
: origin$
.pipe(
switchMap(origin => ajax({
url: `${origin}/search/search_index.json`,
responseType: "json",
withCredentials: true
})
.pipe<SearchIndexOptions>(
pluck("response")
)
)
)
function isConfigDefaultLang(config: SearchIndexConfig) {
return config.lang.length === 1 && config.lang[0] === "en"
}
function isConfigDefaultSeparator(config: SearchIndexConfig) {
return config.separator === "[\s\-]+"
}
/* Set up search index */
index$
.pipe(
map(({ config, ...rest }) => ({
config: {
lang: isConfigDefaultLang(config)
? [translate("search.config.lang")]
: config.lang,
separator: isConfigDefaultSeparator(config)
? translate("search.config.separator")
: config.separator
},
pipeline: translate("search.config.pipeline")
.split(/\s*,\s*/)
.filter(Boolean) as any, // Hack
...rest
}))
)
// .subscribe(console.log)
// /* Send index to worker */
// index$
.pipe(
map((data): SearchSetupMessage => ({
map<SearchIndex, SearchSetupMessage>(index => ({
type: SearchMessageType.SETUP,
data
data: setupSearchIndex(index)
})),
observeOn(asyncScheduler) // make sure it runs on the next tick
observeOn(asyncScheduler)
)
.subscribe(tx$.next.bind(tx$))

View File

@ -22,8 +22,7 @@
import "expose-loader?lunr!lunr"
import { SearchIndex, SearchIndexConfig } from "integrations/search"
import { Search, SearchIndexConfig } from "../../_"
import { SearchMessage, SearchMessageType } from "../message"
/* ----------------------------------------------------------------------------
@ -31,16 +30,16 @@ import { SearchMessage, SearchMessageType } from "../message"
* ------------------------------------------------------------------------- */
/**
* Search index
* Search
*/
let index: SearchIndex
let search: Search
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Setup multi-language support through `lunr-languages`
* Set up multi-language support through `lunr-languages`
*
* This function will automatically import the stemmers necessary to process
* the languages which were given through the search index configuration.
@ -83,20 +82,19 @@ function setupLunrLanguages(config: SearchIndexConfig): void {
export function handler(message: SearchMessage): SearchMessage {
switch (message.type) {
/* Setup search index */
/* Search setup message */
case SearchMessageType.SETUP:
setupLunrLanguages(message.data.config)
index = new SearchIndex(message.data)
search = new Search(message.data)
return {
type: SearchMessageType.DUMP,
data: index.toString()
type: SearchMessageType.READY
}
/* Query search index */
/* Search query message */
case SearchMessageType.QUERY:
return {
type: SearchMessageType.RESULT,
data: index ? index.search(message.data) : []
data: search ? search.query(message.data) : []
}
/* All other messages */

View File

@ -20,7 +20,7 @@
* IN THE SOFTWARE.
*/
import { SearchIndexOptions, SearchResult } from "integrations/search"
import { SearchIndex, SearchResult } from "../../_"
/* ----------------------------------------------------------------------------
* Types
@ -31,7 +31,7 @@ import { SearchIndexOptions, SearchResult } from "integrations/search"
*/
export const enum SearchMessageType {
SETUP, /* Search index setup */
DUMP, /* Search index dump */
READY, /* Search index ready */
QUERY, /* Search query */
RESULT /* Search results */
}
@ -43,15 +43,14 @@ export const enum SearchMessageType {
*/
export interface SearchSetupMessage {
type: SearchMessageType.SETUP /* Message type */
data: SearchIndexOptions /* Message data */
data: SearchIndex /* Message data */
}
/**
* A message containing the a dump of the search index
* A message indicating the search index is ready
*/
export interface SearchDumpMessage {
type: SearchMessageType.DUMP /* Message type */
data: string /* Message data */
export interface SearchReadyMessage {
type: SearchMessageType.READY /* Message type */
}
/**
@ -77,7 +76,7 @@ export interface SearchResultMessage {
*/
export type SearchMessage =
| SearchSetupMessage
| SearchDumpMessage
| SearchReadyMessage
| SearchQueryMessage
| SearchResultMessage
@ -99,16 +98,16 @@ export function isSearchSetupMessage(
}
/**
* Type guard for search dump messages
* Type guard for search ready messages
*
* @param message - Search worker message
*
* @return Test result
*/
export function isSearchDumpMessage(
export function isSearchReadyMessage(
message: SearchMessage
): message is SearchDumpMessage {
return message.type === SearchMessageType.DUMP
): message is SearchReadyMessage {
return message.type === SearchMessageType.READY
}
/**

View File

@ -20,7 +20,7 @@
* IN THE SOFTWARE.
*/
import { SearchIndexConfig, SearchIndexOptions } from "integrations/search"
import { SearchIndex, SearchTransformFn } from "integrations"
/* ----------------------------------------------------------------------------
* Types
@ -35,31 +35,17 @@ export type Feature =
/* ------------------------------------------------------------------------- */
/**
* URL configuration
*/
export interface UrlConfig {
base: string /* Base URL */
worker: {
search: string /* Search worker URL */
}
}
/**
* Search configuration
*/
export interface SearchConfig {
index?: Promise<SearchIndexOptions>
query?: (value: string) => string
}
/**
* Configuration
*/
export interface Config {
url: UrlConfig
base: string /* Base URL */
features: Feature[] /* Feature flags */
search?: SearchConfig
search: {
worker: string /* Worker URL */
index?: Promise<SearchIndex> /* Promise resolving with index */
transform?: SearchTransformFn /* Transformation function */
}
}
/* ----------------------------------------------------------------------------
@ -78,8 +64,7 @@ export interface Config {
*/
export function isConfig(config: any): config is Config {
return typeof config === "object"
&& typeof config.url === "object"
&& typeof config.url.base === "string"
&& typeof config.url.worker === "object"
&& typeof config.url.worker.search === "string"
&& typeof config.base === "string"
&& typeof config.features === "object"
&& typeof config.search === "object"
}

View File

@ -365,20 +365,18 @@
{%- set _ = translations.update({ key: lang.t(key) }) -%}
{%- endfor -%}
<script id="__lang" type="application/json">
{{ translations | tojson }}
{{- translations | tojson -}}
</script>
<!-- Application initialization -->
<script>
__material = initialize(Object.assign({
url: {
base: "{{ base_url }}",
worker: {
search: "{{ 'assets/javascripts/worker/search.js' | url }}"
}
},
features: {{ config.theme.features | tojson }}
}, typeof __config !== "undefined" ? __config : {}))
app = initialize({
base: "{{ base_url }}",
features: {{ config.theme.features | tojson }},
search: {
worker: "{{ 'assets/javascripts/worker/search.js' | url }}"
}
})
</script>
<!-- Custom JavaScript -->

View File

@ -30,7 +30,7 @@
}
ga.l = +new Date
/* Setup integration and send page view */
/* Set up integration and send page view */
ga("create", "{{ analytics[0] }}", "{{ analytics[1] }}")
ga("set", "anonymizeIp", true)
ga("send", "pageview")

View File

@ -350,7 +350,7 @@ export default (_env: never, args: Configuration): Configuration[] => {
...base,
entry: {
"assets/javascripts/worker/search":
"src/assets/javascripts/workers/search/main"
"src/assets/javascripts/integrations/search/worker/main"
},
output: {
path: path.resolve(__dirname, "material"),