Funding goal reached: merged back new search UI/UX from Insiders

This commit is contained in:
squidfunk 2020-09-27 09:40:05 +02:00
parent 08318ac179
commit 8f61fd3b56
43 changed files with 696 additions and 315 deletions

View File

@ -34,6 +34,8 @@ assignees: ''
"search.result.none": "No matching documents",
"search.result.one": "1 matching document",
"search.result.other": "# matching documents",
"search.result.more.one": "1 more on this page",
"search.result.more.other": "# more on this page",
"skip.link.title": "Skip to content",
"source.link.title": "Go to repository",
"source.revision.date": "Last update",

View File

@ -213,26 +213,20 @@ following transformations, which can be customized by [extending the theme][16]:
*
* 3. Trim excess whitespace from left and right.
*
* 4. Append a wildcard to the end of every word to make every word a prefix
* query in order to provide a good typeahead experience, by adding an
* asterisk (wildcard) in between terms, which can be denoted by whitespace,
* any non-control character, or a word boundary.
*
* @param query - Query value
*
* @return Transformed query value
*/
function defaultTransform(query: string): string {
export function defaultTransform(query: string): string {
return query
.split(/"([^"]+)"/g) /* => 1 */
.map((terms, i) => i & 1
.map((terms, index) => index & 1
? terms.replace(/^\b|^(?![^\x00-\x7F]|$)|\s+/g, " +")
: terms
)
.join("")
.replace(/"|(?:^|\s+)[*+\-:^~]+(?=\s+|$)/g, "") /* => 2 */
.trim() /* => 3 */
.replace(/\s+|(?![^\x00-\x7F]|^)$|\b$/g, "* ") /* => 4 */
}
```

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,12 +1,12 @@
{
"assets/javascripts/bundle.js": "assets/javascripts/bundle.66a8d459.min.js",
"assets/javascripts/bundle.js.map": "assets/javascripts/bundle.66a8d459.min.js.map",
"assets/javascripts/vendor.js": "assets/javascripts/vendor.581c8fc6.min.js",
"assets/javascripts/vendor.js.map": "assets/javascripts/vendor.581c8fc6.min.js.map",
"assets/javascripts/worker/search.js": "assets/javascripts/worker/search.5eca75d3.min.js",
"assets/javascripts/worker/search.js.map": "assets/javascripts/worker/search.5eca75d3.min.js.map",
"assets/stylesheets/main.css": "assets/stylesheets/main.45ead06e.min.css",
"assets/stylesheets/main.css.map": "assets/stylesheets/main.45ead06e.min.css.map",
"assets/javascripts/bundle.js": "assets/javascripts/bundle.10eaee41.min.js",
"assets/javascripts/bundle.js.map": "assets/javascripts/bundle.10eaee41.min.js.map",
"assets/javascripts/vendor.js": "assets/javascripts/vendor.141042ad.min.js",
"assets/javascripts/vendor.js.map": "assets/javascripts/vendor.141042ad.min.js.map",
"assets/javascripts/worker/search.js": "assets/javascripts/worker/search.cbc634e2.min.js",
"assets/javascripts/worker/search.js.map": "assets/javascripts/worker/search.cbc634e2.min.js.map",
"assets/stylesheets/main.css": "assets/stylesheets/main.b6d72156.min.css",
"assets/stylesheets/main.css.map": "assets/stylesheets/main.b6d72156.min.css.map",
"assets/stylesheets/overrides.css": "assets/stylesheets/overrides.9514a156.min.css",
"assets/stylesheets/overrides.css.map": "assets/stylesheets/overrides.9514a156.min.css.map",
"assets/stylesheets/palette.css": "assets/stylesheets/palette.6b892c47.min.css",

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

@ -2,11 +2,6 @@
This file was automatically generated - do not edit
-#}
{% import "partials/language.html" as lang with context %}
{% set palette = config.theme.palette %}
{% if not palette is mapping %}
{% set palette = palette | first %}
{% endif %}
{% set font = config.theme.font %}
<!doctype html>
<html lang="{{ lang.t('language') }}" class="no-js">
<head>
@ -39,21 +34,23 @@
{% endif %}
{% endblock %}
{% block styles %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.45ead06e.min.css' | url }}">
{% if palette.scheme or palette.primary or palette.accent %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.b6d72156.min.css' | url }}">
{% if config.theme.palette %}
{% set palette = config.theme.palette %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.6b892c47.min.css' | url }}">
{% endif %}
{% if palette.primary %}
{% import "partials/palette.html" as map %}
{% set primary = map.primary(
palette.primary | replace(" ", "-") | lower
) %}
<meta name="theme-color" content="{{ primary }}">
{% if palette.primary %}
{% import "partials/palette.html" as map %}
{% set primary = map.primary(
palette.primary | replace(" ", "-") | lower
) %}
<meta name="theme-color" content="{{ primary }}">
{% endif %}
{% endif %}
{% endblock %}
{% block libs %}{% endblock %}
{% block fonts %}
{% if font != false %}
{% if config.theme.font != false %}
{% set font = config.theme.font %}
<link href="https://fonts.gstatic.com" rel="preconnect" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family={{
font.text | replace(' ', '+') + ':300,400,400i,700%7C' +
@ -76,17 +73,21 @@
{% block extrahead %}{% endblock %}
</head>
{% set direction = config.theme.direction or lang.t('direction') %}
{% if palette.scheme or palette.primary or palette.accent %}
{% set scheme = palette.scheme | lower %}
{% if config.theme.palette %}
{% set palette = config.theme.palette %}
{% if not palette is mapping %}
{% set palette = palette | first %}
{% endif %}
{% set scheme = palette.scheme | replace(" ", "-") | lower %}
{% set primary = palette.primary | replace(" ", "-") | lower %}
{% set accent = palette.accent | replace(" ", "-") | lower %}
<body dir="{{ direction }}" data-md-color-scheme="{{ scheme }}" data-md-color-primary="{{ primary }}" data-md-color-accent="{{ accent }}">
{% if "preference" == scheme %}
<script>matchMedia("(prefers-color-scheme: dark)").matches&&document.body.setAttribute("data-md-color-scheme","slate")</script>
{% endif %}
{% else %}
<body dir="{{ direction }}">
{% endif %}
{% if "preference" == palette.scheme %}
<script>matchMedia("(prefers-color-scheme: dark)").matches&&document.body.setAttribute("data-md-color-scheme","slate")</script>
{% endif %}
<input class="md-toggle" data-md-toggle="drawer" type="checkbox" id="__drawer" autocomplete="off">
<input class="md-toggle" data-md-toggle="search" type="checkbox" id="__search" autocomplete="off">
<label class="md-overlay" for="__drawer"></label>
@ -171,8 +172,8 @@
{% endblock %}
</div>
{% block scripts %}
<script src="{{ 'assets/javascripts/vendor.581c8fc6.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.66a8d459.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/vendor.141042ad.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.10eaee41.min.js' | url }}"></script>
{%- set translations = {} -%}
{%- for key in [
"clipboard.copy",
@ -183,7 +184,10 @@
"search.result.placeholder",
"search.result.none",
"search.result.one",
"search.result.other"
"search.result.other",
"search.result.more.one",
"search.result.more.other",
"search.result.term.missing"
] -%}
{%- set _ = translations.update({ key: lang.t(key) }) -%}
{%- endfor -%}
@ -196,7 +200,7 @@
base: "{{ base_url }}",
features: {{ config.theme.features or [] | tojson }},
search: Object.assign({
worker: "{{ 'assets/javascripts/worker/search.5eca75d3.min.js' | url }}"
worker: "{{ 'assets/javascripts/worker/search.cbc634e2.min.js' | url }}"
}, typeof search !== "undefined" && search)
})
</script>

View File

@ -17,6 +17,9 @@
"search.result.none": "Keine Suchergebnisse",
"search.result.one": "1 Suchergebnis",
"search.result.other": "# Suchergebnisse",
"search.result.more.one": "1 weiteres Suchergebnis auf dieser Seite",
"search.result.more.other": "# weitere Suchergebnisse auf dieser Seite",
"search.result.term.missing": "Es fehlt",
"skip.link.title": "Zum Inhalt",
"source.link.title": "Quellcode",
"source.revision.date": "Letztes Update",

View File

@ -24,6 +24,9 @@
"search.result.none": "No matching documents",
"search.result.one": "1 matching document",
"search.result.other": "# matching documents",
"search.result.more.one": "1 more on this page",
"search.result.more.other": "# more on this page",
"search.result.term.missing": "Missing",
"skip.link.title": "Skip to content",
"source.link.title": "Go to repository",
"source.revision.date": "Last update",

View File

@ -16,6 +16,9 @@
"search.result.none": "Inga sökresultat",
"search.result.one": "1 sökresultat",
"search.result.other": "# sökresultat",
"search.result.more.one": "1 till på denna sidan",
"search.result.more.other": "# till på denna sidan",
"search.result.term.missing": "Saknas",
"skip.link.title": "Gå till innehållet",
"source.link.title": "Gå till datakatalog",
"source.revision.date": "Senaste uppdateringen",

View File

@ -96,29 +96,34 @@ export function applySearchResult(
}),
/* Apply search result list */
switchMap(result => fetch$
.pipe(
switchMap(result => {
const thresholds = [...result.map(([best]) => best.score), 0]
return fetch$
.pipe(
/* Defer repaint to next animation frame */
observeOn(animationFrameScheduler),
scan(index => {
const container = el.parentElement!
while (index < result.length) {
addToSearchResultList(list, renderSearchResult(result[index++]))
if (container.scrollHeight - container.offsetHeight > 16)
break
}
return index
}, 0),
/* Defer repaint to next animation frame */
observeOn(animationFrameScheduler),
scan(index => {
const container = el.parentElement!
while (index < result.length) {
addToSearchResultList(list, renderSearchResult(
result[index++], thresholds[index]
))
if (container.scrollHeight - container.offsetHeight > 16)
break
}
return index
}, 0),
/* Re-map to search result */
mapTo(result),
/* Re-map to search result */
mapTo(result),
/* Reset on complete or error */
finalize(() => {
resetSearchResultList(list)
})
)
/* Reset on complete or error */
finalize(() => {
resetSearchResultList(list)
})
)
}
)
)
}

View File

@ -49,7 +49,7 @@ export function setSearchResultMeta(
/* Multiple result */
default:
el.textContent = translate("search.result.other", value.toString())
el.textContent = translate("search.result.other", value)
}
}

View File

@ -26,7 +26,7 @@
import "focus-visible"
import { sortBy, prop, values } from "ramda"
import { sortBy, prop, values, identity } from "ramda"
import {
merge,
combineLatest,
@ -85,7 +85,7 @@ import {
setupKeyboard,
setupInstantLoading,
setupSearchWorker,
SearchIndex
SearchIndex, SearchIndexPipeline
} from "integrations"
import {
patchCodeBlocks,
@ -95,7 +95,7 @@ import {
patchSource,
patchScripts
} from "patches"
import { isConfig } from "utilities"
import { isConfig, translate } from "utilities"
/* ------------------------------------------------------------------------- */
@ -135,6 +135,38 @@ export function resetScrollLock(
window.scrollTo(0, value)
}
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Set up search index
*
* @param data - Search index
*
* @return Search index
*/
function setupSearchIndex( // Hack: move this outside here, temporarily...
{ 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 }
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
@ -248,20 +280,26 @@ export function initialize(config: unknown) {
: undefined
/* 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")
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")
)
)
)
)
)
.pipe(
map(setupSearchIndex),
shareReplay(1)
)
return of(setupSearchWorker(config.search.worker, {
base$, index$

View File

@ -135,7 +135,10 @@ export function setupKeyboard(): Observable<Keyboard> {
if (typeof active === "undefined") {
setElementFocus(query)
} else {
const els = [query, ...getElements("[href]", result)]
const els = [query, ...getElements(
":not(details) > [href], summary, details[open] [href]",
result
)]
const i = Math.max(0, (
Math.max(0, els.indexOf(active)) + els.length + (
key.type === "ArrowUp" ? -1 : +1

View File

@ -21,15 +21,19 @@
*/
import {
ArticleDocument,
SearchDocument,
SearchDocumentMap,
SectionDocument,
setupSearchDocumentMap
} from "../document"
import {
SearchHighlightFactoryFn,
setupSearchHighlighter
} from "../highlighter"
import {
SearchQueryTerms,
getSearchQueryTerms,
parseSearchQuery
} from "../query"
/* ----------------------------------------------------------------------------
* Types
@ -84,13 +88,22 @@ export interface SearchIndex {
/* ------------------------------------------------------------------------- */
/**
* Search metadata
*/
export interface SearchMetadata {
score: number /* Score (relevance) */
terms: SearchQueryTerms /* Search query terms */
}
/* ------------------------------------------------------------------------- */
/**
* Search result
*/
export interface SearchResult {
article: ArticleDocument /* Article document */
sections: SectionDocument[] /* Section documents */
}
export type SearchResult = Array<
SearchDocument & SearchMetadata
> // tslint:disable-line
/* ----------------------------------------------------------------------------
* Functions
@ -116,7 +129,7 @@ function difference(a: string[], b: string[]): string[] {
* ------------------------------------------------------------------------- */
/**
* Search
* Search index
*
* Note that `lunr` is injected via Webpack, as it will otherwise also be
* bundled in the application bundle.
@ -139,7 +152,7 @@ export class Search {
protected highlight: SearchHighlightFactoryFn
/**
* The `lunr` search index
* The underlying `lunr` search index
*/
protected index: lunr.Index
@ -159,7 +172,7 @@ export class Search {
if (typeof index === "undefined") {
this.index = lunr(function() {
/* Set up alternate search languages */
/* Set up multi-language support */
if (config.lang.length === 1 && config.lang[0] !== "en") {
this.use((lunr as any)[config.lang[0]])
} else if (config.lang.length > 1) {
@ -171,7 +184,7 @@ export class Search {
"trimmer", "stopWordFilter", "stemmer"
], pipeline!)
/* Remove functions from the pipeline for every language */
/* Remove functions from the pipeline for registered languages */
for (const lang of config.lang.map(language => (
language === "en" ? lunr : (lunr as any)[language]
))) {
@ -191,7 +204,7 @@ export class Search {
this.add(doc)
})
/* Prebuilt or serialized index */
/* Handle prebuilt or serialized index */
} else {
this.index = lunr.Index.load(
typeof index === "string"
@ -213,45 +226,71 @@ export class Search {
* page. For this reason, section results are grouped within their respective
* articles which are the top-level results that are returned.
*
* @param value - Query value
* @param query - Query value
*
* @return Search results
*/
public query(value: string): SearchResult[] {
if (value) {
public search(query: string): SearchResult[] {
if (query) {
try {
const highlight = this.highlight(query)
/* Group sections by containing article */
const groups = this.index.search(value)
.reduce((results, result) => {
const document = this.documents.get(result.ref)
/* Parse query to extract clauses for analysis */
const clauses = parseSearchQuery(query)
.filter(clause => (
clause.presence !== lunr.Query.presence.PROHIBITED
))
/* Perform search and post-process results */
const groups = this.index.search(`${query}*`)
/* Apply post-query boosts based on title and search query terms */
.reduce<SearchResult>((results, { ref, score, matchData }) => {
const document = this.documents.get(ref)
if (typeof document !== "undefined") {
if ("parent" in document) {
const ref = document.parent.location
results.set(ref, [...results.get(ref) || [], result])
} else {
const ref = document.location
results.set(ref, results.get(ref) || [])
}
const { location, title, text, parent } = document
/* Compute and analyze search query terms */
const terms = getSearchQueryTerms(
clauses,
Object.keys(matchData.metadata)
)
/* Highlight title and text and apply post-query boosts */
const boost = +!parent + +Object.values(terms).every(t => t)
results.push({
location,
title: highlight(title),
text: highlight(text),
score: score * (1 + boost),
terms
})
}
return results
}, new Map<string, lunr.Index.Result[]>())
}, [])
/* Create highlighter for query */
const fn = this.highlight(value)
/* Sort search results again after applying boosts */
.sort((a, b) => b.score - a.score)
/* Map groups to search documents */
return [...groups].map(([ref, sections]) => ({
article: fn(this.documents.get(ref) as ArticleDocument),
sections: sections.map(section => {
return fn(this.documents.get(section.ref) as SectionDocument)
})
}))
/* Group search results by page */
.reduce((results, result) => {
const document = this.documents.get(result.location)
if (typeof document !== "undefined") {
const ref = "parent" in document
? document.parent!.location
: document.location
results.set(ref, [...results.get(ref) || [], result])
}
return results
}, new Map<string, SearchResult>())
/* Expand grouped search results */
return [...groups.values()]
/* Log errors to console (for now) */
} catch (err) {
} catch {
// tslint:disable-next-line no-console
console.warn(`Invalid query: ${value} see https://bit.ly/2s3ChXG`)
console.warn(`Invalid query: ${query} see https://bit.ly/2s3ChXG`)
}
}

View File

@ -29,28 +29,14 @@ import { SearchIndexDocument } from "../_"
* ------------------------------------------------------------------------- */
/**
* A top-level article
* Search document
*/
export interface ArticleDocument extends SearchIndexDocument {
linked: boolean /* Whether the section was linked */
}
/**
* A section of an article
*/
export interface SectionDocument extends SearchIndexDocument {
parent: ArticleDocument /* Parent article */
export interface SearchDocument extends SearchIndexDocument {
parent?: SearchIndexDocument /* Parent article */
}
/* ------------------------------------------------------------------------- */
/**
* Search document
*/
export type SearchDocument =
| ArticleDocument
| SectionDocument
/**
* Search document mapping
*/
@ -71,6 +57,7 @@ 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("#")
@ -85,13 +72,15 @@ export function setupSearchDocumentMap(
/* Handle section */
if (hash) {
const parent = documents.get(path) as ArticleDocument
const parent = documents.get(path)!
/* Ignore first section, override article */
if (!parent.linked) {
parent.title = doc.title
parent.text = text
parent.linked = true
if (!parents.has(parent)) {
parent.title = doc.title
parent.text = text
/* Remember that we processed the article */
parents.add(parent)
/* Add subsequent section */
} else {
@ -108,8 +97,7 @@ export function setupSearchDocumentMap(
documents.set(location, {
location,
title,
text,
linked: false
text
})
}
}

View File

@ -21,7 +21,6 @@
*/
import { SearchIndexConfig } from "../_"
import { SearchDocument } from "../document"
/* ----------------------------------------------------------------------------
* Types
@ -30,24 +29,20 @@ import { SearchDocument } from "../document"
/**
* Search highlight function
*
* @template T - Search document type
* @param value - Value
*
* @param document - Search document
*
* @return Highlighted document
* @return Highlighted value
*/
export type SearchHighlightFn = <
T extends SearchDocument
>(document: Readonly<T>) => T
export type SearchHighlightFn = (value: string) => string
/**
* Search highlight factory function
*
* @param value - Query value
* @param query - Query value
*
* @return Search highlight function
*/
export type SearchHighlightFactoryFn = (value: string) => SearchHighlightFn
export type SearchHighlightFactoryFn = (query: string) => SearchHighlightFn
/* ----------------------------------------------------------------------------
* Functions
@ -65,27 +60,25 @@ export function setupSearchHighlighter(
): SearchHighlightFactoryFn {
const separator = new RegExp(config.separator, "img")
const highlight = (_: unknown, data: string, term: string) => {
return `${data}<em>${term}</em>`
return `${data}<mark data-md-highlight>${term}</mark>`
}
/* Return factory function */
return (value: string) => {
value = value
return (query: string) => {
query = query
.replace(/[\s*+\-:~^]+/g, " ")
.trim()
/* Create search term match expression */
const match = new RegExp(`(^|${config.separator})(${
value
query
.replace(/[|\\{}()[\]^$+*?.-]/g, "\\$&")
.replace(separator, "|")
})`, "img")
/* Highlight document */
return document => ({
...document,
title: document.title.replace(match, highlight),
text: document.text.replace(match, highlight)
})
/* Highlight string value */
return value => value
.replace(match, highlight)
.replace(/<\/mark>(\s+)<mark[^>]*>/img, "\$1")
}
}

View File

@ -23,5 +23,5 @@
export * from "./_"
export * from "./document"
export * from "./highlighter"
export * from "./transform"
export * from "./query"
export * from "./worker"

View File

@ -0,0 +1,92 @@
/*
* 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 query clause
*/
export interface SearchQueryClause {
presence: lunr.Query.presence /* Clause presence */
term: string /* Clause term */
}
/* ------------------------------------------------------------------------- */
/**
* Search query terms
*/
export type SearchQueryTerms = Record<string, boolean>
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Parse a search query for analysis
*
* @param value - Query value
*
* @return Search query clauses
*/
export function parseSearchQuery(
value: string
): SearchQueryClause[] {
const query = new (lunr as any).Query(["title", "text"])
const parser = new (lunr as any).QueryParser(value, query)
/* Parse and return query clauses */
parser.parse()
return query.clauses
}
/**
* Analyze the search query clauses in regard to the search terms found
*
* @param query - Search query clauses
* @param terms - Search terms
*
* @return Search query terms
*/
export function getSearchQueryTerms(
query: SearchQueryClause[], terms: string[]
): SearchQueryTerms {
const clauses = new Set<SearchQueryClause>(query)
/* Match query clauses against terms */
const result: SearchQueryTerms = {}
for (let t = 0; t < terms.length; t++)
for (const clause of clauses)
if (terms[t].startsWith(clause.term)) {
result[clause.term] = true
clauses.delete(clause)
}
/* Annotate unmatched query clauses */
for (const clause of clauses)
result[clause.term] = false
/* Return query terms */
return result
}

View File

@ -0,0 +1,24 @@
/*
* 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.
*/
export * from "./_"
export * from "./transform"

View File

@ -54,24 +54,18 @@ export type SearchTransformFn = (value: string) => string
*
* 3. Trim excess whitespace from left and right.
*
* 4. Append a wildcard to the end of every word to make every word a prefix
* query in order to provide a good typeahead experience, by adding an
* asterisk (wildcard) in between terms, which can be denoted by whitespace,
* any non-control character, or a word boundary.
*
* @param value - Query value
* @param query - Query value
*
* @return Transformed query value
*/
export function defaultTransform(value: string): string {
return value
export function defaultTransform(query: string): string {
return query
.split(/"([^"]+)"/g) /* => 1 */
.map((terms, i) => i & 1
.map((terms, index) => index & 1
? terms.replace(/^\b|^(?![^\x00-\x7F]|$)|\s+/g, " +")
: terms
)
.join("")
.replace(/"|(?:^|\s+)[*+\-:^~]+(?=\s+|$)/g, "") /* => 2 */
.trim() /* => 3 */
.replace(/\s+|(?![^\x00-\x7F]|^)$|\b$/g, "* ") /* => 4 */
}

View File

@ -20,7 +20,6 @@
* IN THE SOFTWARE.
*/
import { identity } from "ramda"
import { Observable, Subject, asyncScheduler } from "rxjs"
import {
map,
@ -30,9 +29,8 @@ import {
} from "rxjs/operators"
import { WorkerHandler, watchWorker } from "browser"
import { translate } from "utilities"
import { SearchIndex, SearchIndexPipeline } from "../../_"
import { SearchIndex } from "../../_"
import {
SearchMessage,
SearchMessageType,
@ -52,38 +50,6 @@ interface SetupOptions {
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 }
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
@ -112,11 +78,9 @@ export function setupSearchWorker(
withLatestFrom(base$),
map(([message, base]) => {
if (isSearchResultMessage(message)) {
for (const { article, sections } of message.data) {
article.location = `${base}/${article.location}`
for (const section of sections)
section.location = `${base}/${section.location}`
}
for (const result of message.data)
for (const document of result)
document.location = `${base}/${document.location}`
}
return message
}),
@ -126,9 +90,9 @@ export function setupSearchWorker(
/* Set up search index */
index$
.pipe(
map<SearchIndex, SearchSetupMessage>(index => ({
map<SearchIndex, SearchSetupMessage>(data => ({
type: SearchMessageType.SETUP,
data: setupSearchIndex(index)
data
})),
observeOn(asyncScheduler)
)

View File

@ -22,31 +22,74 @@
import "lunr"
import { Search, SearchIndexConfig } from "../../_"
import { SearchMessage, SearchMessageType } from "../message"
import {
Search,
SearchIndex,
SearchIndexConfig
} from "../../_"
import {
SearchMessage,
SearchMessageType
} from "../message"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Add support for usage with `iframe-worker` polyfill
*
* While `importScripts` is synchronous when executed inside of a webworker,
* 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.
*
* @see https://bit.ly/2PjDnXi - GitHub comment
*/
declare global {
function importScripts(...urls: string[]): Promise<void> | void
}
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Search
* Search index
*/
let search: Search
let index: Search
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Set up multi-language support through `lunr-languages`
* Fetch search index from given URL
*
* @param url - Search index URL
*
* @return Promise resolving with search index
*/
async function fetchSearchIndex(url: string): Promise<SearchIndex> {
return fetch(url, {
credentials: "same-origin"
})
.then(res => res.json())
}
/**
* Fetch (= import) 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.
*
* @param config - Search index configuration
*
* @return Promise resolving with no result
*/
function setupLunrLanguages(config: SearchIndexConfig): void {
async function setupSearchLanguages(
config: SearchIndexConfig
): Promise<void> {
const base = "../lunr"
/* Add scripts for languages */
@ -62,7 +105,7 @@ function setupLunrLanguages(config: SearchIndexConfig): void {
/* Load scripts synchronously */
if (scripts.length)
importScripts(
await importScripts(
`${base}/min/lunr.stemmer.support.min.js`,
...scripts
)
@ -79,13 +122,20 @@ function setupLunrLanguages(config: SearchIndexConfig): void {
*
* @return Target message
*/
export function handler(message: SearchMessage): SearchMessage {
export async function handler(
message: SearchMessage
): Promise<SearchMessage> {
switch (message.type) {
/* Search setup message */
case SearchMessageType.SETUP:
setupLunrLanguages(message.data.config)
search = new Search(message.data)
const data = typeof message.data === "string"
? await fetchSearchIndex(message.data)
: message.data
/* Set up search index with multi-language support */
await setupSearchLanguages(data.config)
index = new Search(data)
return {
type: SearchMessageType.READY
}
@ -94,7 +144,7 @@ export function handler(message: SearchMessage): SearchMessage {
case SearchMessageType.QUERY:
return {
type: SearchMessageType.RESULT,
data: search ? search.query(message.data) : []
data: index ? index.search(message.data) : []
}
/* All other messages */
@ -107,6 +157,6 @@ export function handler(message: SearchMessage): SearchMessage {
* Worker
* ------------------------------------------------------------------------- */
addEventListener("message", ev => {
postMessage(handler(ev.data))
addEventListener("message", async ev => {
postMessage(await handler(ev.data))
})

View File

@ -43,7 +43,7 @@ export const enum SearchMessageType {
*/
export interface SearchSetupMessage {
type: SearchMessageType.SETUP /* Message type */
data: SearchIndex /* Message data */
data: SearchIndex | string /* Message data */
}
/**

View File

@ -20,8 +20,12 @@
* IN THE SOFTWARE.
*/
import { SearchResult } from "integrations/search"
import { h, truncate } from "utilities"
import {
SearchDocument,
SearchMetadata,
SearchResult
} from "integrations/search"
import { h, translate, truncate } from "utilities"
/* ----------------------------------------------------------------------------
* Data
@ -33,10 +37,12 @@ import { h, truncate } from "utilities"
const css = {
item: "md-search-result__item",
link: "md-search-result__link",
more: "md-search-result__more",
article: "md-search-result__article md-search-result__article--document",
section: "md-search-result__article",
title: "md-search-result__title",
teaser: "md-search-result__teaser"
teaser: "md-search-result__teaser",
terms: "md-search-result__terms"
}
/* ------------------------------------------------------------------------- */
@ -53,6 +59,99 @@ const path =
"18.88,20.32L22,23.39L23.39,22L20.31,18.9M16.5,19A2.5,2.5 0 0,1 " +
"14,16.5A2.5,2.5 0 0,1 16.5,14A2.5,2.5 0 0,1 19,16.5A2.5,2.5 0 0,1 16.5,19Z"
/* ----------------------------------------------------------------------------
* Helper function
* ------------------------------------------------------------------------- */
/**
* Render an article document
*
* @param document - Article document
* @param teaser - Whether to render the teaser
*
* @return Element
*/
function renderArticleDocument(
{ location, title, text, terms, score }: SearchDocument & SearchMetadata,
teaser: boolean
) {
const highlight: string[] = []
for (const [value, found] of Object.entries(terms))
if (found)
highlight.push(value)
const url = new URL(location)
url.searchParams.append("h", highlight.join(" "))
const miss = Object.keys(terms)
// tslint:disable-next-line: array-type
.reduce<Array<Element | string>>((list, key) => [
...list, ...!terms[key] ? [<del>{key}</del>, " "] : []
], [])
return (
<a href={url.toString().replace(/%20/g, "+")} class={css.link} tabIndex={-1}>
<article class={css.article} data-md-score={score.toFixed(2)}>
<div class="md-search-result__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d={path}></path>
</svg>
</div>
<h1 class={css.title}>{title}</h1>
{teaser && text.length > 0 &&
<p class={css.teaser}>{truncate(text, 320)}</p>
}
{teaser && miss.length > 0 &&
<p class={css.terms}>
{translate("search.result.term.missing")}: {...miss.slice(0, -1)}
</p>
}
</article>
</a>
)
}
/**
* Render a search document
*
* @param section - Search document
*
* @return Element
*/
function renderSection(
{ location, title, text, terms, score }: SearchDocument & SearchMetadata
) {
const highlight: string[] = []
for (const [value, found] of Object.entries(terms))
if (found)
highlight.push(value)
const url = new URL(location)
url.searchParams.append("h", highlight.join(" "))
const miss = Object.keys(terms)
// tslint:disable-next-line: array-type
.reduce<Array<Element | string>>((list, key) => [
...list, ...!terms[key] ? [<del>{key}</del>, " "] : []
], [])
return (
<a href={url.toString().replace(/%20/g, "+")} class={css.link} tabIndex={-1}>
<article class={css.section} data-md-score={score.toFixed(2)}>
<h1 class={css.title}>{title}</h1>
{text.length > 0 &&
<p class={css.teaser}>{truncate(text, 320)}</p>
}
{miss.length > 0 &&
<p class={css.terms}>
{translate("search.result.term.missing")}: {...miss.slice(0, -1)}
</p>
}
</article>
</a>
)
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
@ -65,31 +164,39 @@ const path =
* @return Element
*/
export function renderSearchResult(
{ article, sections }: SearchResult
result: SearchResult, threshold: number = Infinity
) {
const docs = [...result]
/* Render icon */
const icon = (
<div class="md-search-result__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d={path}></path>
</svg>
</div>
)
/* Find and extract parent article */
const parent = docs.findIndex(doc => !doc.location.includes("#"))
const [article] = docs.splice(parent, 1)
/* Render article and sections */
const children = [article, ...sections].map(document => {
const { location, title, text } = document
return (
<a href={location} class={css.link} tabIndex={-1}>
<article class={"parent" in document ? css.section : css.article}>
{!("parent" in document) && icon}
<h1 class={css.title}>{title}</h1>
{text.length > 0 && <p class={css.teaser}>{truncate(text, 320)}</p>}
</article>
</a>
)
})
/* Determine last index above threshold */
let index = docs.findIndex(doc => doc.score < threshold)
if (index === -1)
index = docs.length
/* Partition sections */
const best = docs.slice(0, index)
const more = docs.slice(index)
/* Render children */
const children = [
renderArticleDocument(article, !parent && index === 0),
...best.map(renderSection),
...more.length ? [
<details class={css.more}>
<summary>
{more.length > 0 && more.length === 1
? translate("search.result.more.one")
: translate("search.result.more.other", more.length)
}
</summary>
{...more.map(renderSection)}
</details>
] : []
]
/* Render search result */
return (

View File

@ -39,6 +39,9 @@ type TranslateKey =
| "search.result.none" /* No matching documents */
| "search.result.one" /* 1 matching document */
| "search.result.other" /* # matching documents */
| "search.result.more.one" /* 1 more on this page */
| "search.result.more.other" /* # more on this page */
| "search.result.term.missing" /* Missing */
/* ----------------------------------------------------------------------------
* Data
@ -61,7 +64,9 @@ let lang: Record<string, string>
*
* @return Translation
*/
export function translate(key: TranslateKey, value?: string): string {
export function translate(
key: TranslateKey, value?: string | number
): string {
if (typeof lang === "undefined") {
const el = getElementOrThrow("#__lang")
lang = JSON.parse(el.textContent!)
@ -70,7 +75,7 @@ export function translate(key: TranslateKey, value?: string): string {
throw new ReferenceError(`Invalid translation: ${key}`)
}
return typeof value !== "undefined"
? lang[key].replace("#", value)
? lang[key].replace("#", value.toString())
: lang[key]
}
@ -131,10 +136,10 @@ export function round(value: number): string {
* @return Hash as 32bit integer
*/
export function hash(value: string): number {
let h = 0
for (let i = 0, len = value.length; i < len; i++) {
h = ((h << 5) - h) + value.charCodeAt(i)
h |= 0 // Convert to 32bit integer
}
return h
let h = 0
for (let i = 0, len = value.length; i < len; i++) {
h = ((h << 5) - h) + value.charCodeAt(i)
h |= 0 // Convert to 32bit integer
}
return h
}

View File

@ -66,6 +66,8 @@
// Hide radio buttons
> input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
// Active tab label

View File

@ -560,12 +560,57 @@ $md-toggle__search--checked:
}
}
// Add a little spacing on the teaser of the last link
&:last-child .md-search-result__teaser {
// Add a little spacing on the last element of the last link
&:last-child p:last-child {
margin-bottom: px2rem(12px);
}
}
// Search result container
&__more summary {
padding: px2em(12px) px2rem(16px);
color: var(--md-typeset-a-color);
font-size: px2rem(12.8px);
outline: 0;
cursor: pointer;
transition:
color 250ms,
background-color 250ms;
scroll-snap-align: start;
// Focused or hovered button
&:focus,
&:hover {
color: var(--md-accent-fg-color);
background-color: var(--md-accent-fg-color--transparent);
}
// [tablet landscape +]: Increase left indent
@include break-from-device(tablet landscape) {
padding-left: px2rem(44px);
// Adjust for right-to-left languages
[dir="rtl"] & {
padding-right: px2rem(44px);
padding-left: px2rem(16px);
}
}
// Remove default details marker
&::-webkit-details-marker {
display: none;
}
// All following elements
& ~ * {
// Make less relevant terms more transparent
> * {
opacity: 0.65;
}
}
}
// Article - document or section
&__article {
position: relative;
@ -626,39 +671,50 @@ $md-toggle__search--checked:
margin: 0.5em 0;
font-weight: 700;
font-size: px2rem(12.8px);
line-height: 1.4;
line-height: 1.6;
}
// Teaser
&__teaser {
display: -webkit-box;
max-height: px2rem(33px);
max-height: px2rem(40px);
margin: 0.5em 0;
overflow: hidden;
color: var(--md-default-fg-color--light);
font-size: px2rem(12.8px);
line-height: 1.4;
line-height: 1.6;
text-overflow: ellipsis;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
// [mobile -]: Increase number of lines
@include break-to-device(mobile) {
max-height: px2rem(50px);
max-height: px2rem(60px);
-webkit-line-clamp: 3;
}
// [tablet landscape]: Increase number of lines
@include break-at-device(tablet landscape) {
max-height: px2rem(50px);
max-height: px2rem(60px);
-webkit-line-clamp: 3;
}
// Search term highlighting
mark {
background-color: transparent;
border-bottom: px2rem(1px) solid var(--md-accent-fg-color);
}
}
// Terms
&__terms {
margin: 0.5em 0;
font-size: px2rem(12.8px);
font-style: italic;
}
// Search term highlighting
em {
font-weight: 700;
font-style: normal;
text-decoration: underline;
mark {
color: var(--md-accent-fg-color);
}
}

View File

@ -22,13 +22,6 @@
{% import "partials/language.html" as lang with context %}
<!-- Theme options -->
{% set palette = config.theme.palette %}
{% if not palette is mapping %}
{% set palette = palette | first %}
{% endif %}
{% set font = config.theme.font %}
<!doctype html>
<html lang="{{ lang.t('language') }}" class="no-js">
<head>
@ -83,20 +76,21 @@
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.css' | url }}" />
<!-- Extra color palette -->
{% if palette.scheme or palette.primary or palette.accent %}
{% if config.theme.palette %}
{% set palette = config.theme.palette %}
<link
rel="stylesheet"
href="{{ 'assets/stylesheets/palette.css' | url }}"
/>
{% endif %}
<!-- Theme-color meta tag for Android -->
{% if palette.primary %}
{% import "partials/palette.html" as map %}
{% set primary = map.primary(
palette.primary | replace(" ", "-") | lower
) %}
<meta name="theme-color" content="{{ primary }}" />
<!-- Theme-color meta tag for Android -->
{% if palette.primary %}
{% import "partials/palette.html" as map %}
{% set primary = map.primary(
palette.primary | replace(" ", "-") | lower
) %}
<meta name="theme-color" content="{{ primary }}" />
{% endif %}
{% endif %}
{% endblock %}
@ -107,7 +101,8 @@
{% block fonts %}
<!-- Load fonts from Google -->
{% if font != false %}
{% if config.theme.font != false %}
{% set font = config.theme.font %}
<link href="https://fonts.gstatic.com" rel="preconnect" crossorigin />
<link
rel="stylesheet"
@ -156,8 +151,12 @@
<!-- Text direction and color palette, if defined -->
{% set direction = config.theme.direction or lang.t('direction') %}
{% if palette.scheme or palette.primary or palette.accent %}
{% set scheme = palette.scheme | lower %}
{% if config.theme.palette %}
{% set palette = config.theme.palette %}
{% if not palette is mapping %}
{% set palette = palette | first %}
{% endif %}
{% set scheme = palette.scheme | replace(" ", "-") | lower %}
{% set primary = palette.primary | replace(" ", "-") | lower %}
{% set accent = palette.accent | replace(" ", "-") | lower %}
<body
@ -166,18 +165,19 @@
data-md-color-primary="{{ primary }}"
data-md-color-accent="{{ accent }}"
>
<!-- Experimental: set color scheme based on preference -->
{% if "preference" == scheme %}
<script>
if (matchMedia("(prefers-color-scheme: dark)").matches)
document.body.setAttribute("data-md-color-scheme", "slate")
</script>
{% endif %}
{% else %}
<body dir="{{ direction }}">
{% endif %}
<!-- Experimental: set color scheme based on preference -->
{% if "preference" == palette.scheme %}
<script>
if (matchMedia("(prefers-color-scheme: dark)").matches)
document.body.setAttribute("data-md-color-scheme", "slate")
</script>
{% endif %}
<!--
State toggles - we need to set autocomplete="off" in order to reset the
drawer on back button invocation in some browsers
@ -346,7 +346,10 @@
"search.result.placeholder",
"search.result.none",
"search.result.one",
"search.result.other"
"search.result.other",
"search.result.more.one",
"search.result.more.other",
"search.result.term.missing"
] -%}
{%- set _ = translations.update({ key: lang.t(key) }) -%}
{%- endfor -%}

View File

@ -37,6 +37,9 @@
"search.result.none": "Keine Suchergebnisse",
"search.result.one": "1 Suchergebnis",
"search.result.other": "# Suchergebnisse",
"search.result.more.one": "1 weiteres Suchergebnis auf dieser Seite",
"search.result.more.other": "# weitere Suchergebnisse auf dieser Seite",
"search.result.term.missing": "Es fehlt",
"skip.link.title": "Zum Inhalt",
"source.link.title": "Quellcode",
"source.revision.date": "Letztes Update",

View File

@ -44,6 +44,9 @@
"search.result.none": "No matching documents",
"search.result.one": "1 matching document",
"search.result.other": "# matching documents",
"search.result.more.one": "1 more on this page",
"search.result.more.other": "# more on this page",
"search.result.term.missing": "Missing",
"skip.link.title": "Skip to content",
"source.link.title": "Go to repository",
"source.revision.date": "Last update",

View File

@ -36,6 +36,9 @@
"search.result.none": "Inga sökresultat",
"search.result.one": "1 sökresultat",
"search.result.other": "# sökresultat",
"search.result.more.one": "1 till på denna sidan",
"search.result.more.other": "# till på denna sidan",
"search.result.term.missing": "Saknas",
"skip.link.title": "Gå till innehållet",
"source.link.title": "Gå till datakatalog",
"source.revision.date": "Senaste uppdateringen",