mirror of
https://github.com/squidfunk/mkdocs-material.git
synced 2024-06-14 11:52:32 +03:00
Funding goal reached: merged back new search UI/UX from Insiders
This commit is contained in:
parent
08318ac179
commit
8f61fd3b56
2
.github/ISSUE_TEMPLATE/translate.md
vendored
2
.github/ISSUE_TEMPLATE/translate.md
vendored
@ -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",
|
||||
|
@ -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 */
|
||||
}
|
||||
```
|
||||
|
||||
|
2
material/assets/javascripts/bundle.10eaee41.min.js
vendored
Normal file
2
material/assets/javascripts/bundle.10eaee41.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
material/assets/javascripts/bundle.10eaee41.min.js.map
Normal file
1
material/assets/javascripts/bundle.10eaee41.min.js.map
Normal file
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
1
material/assets/javascripts/vendor.141042ad.min.js.map
Normal file
1
material/assets/javascripts/vendor.141042ad.min.js.map
Normal file
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
@ -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
3
material/assets/stylesheets/main.b6d72156.min.css
vendored
Normal file
3
material/assets/stylesheets/main.b6d72156.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
material/assets/stylesheets/main.b6d72156.min.css.map
Normal file
1
material/assets/stylesheets/main.b6d72156.min.css.map
Normal file
File diff suppressed because one or more lines are too long
@ -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>
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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)
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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$
|
||||
|
@ -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
|
||||
|
@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
@ -23,5 +23,5 @@
|
||||
export * from "./_"
|
||||
export * from "./document"
|
||||
export * from "./highlighter"
|
||||
export * from "./transform"
|
||||
export * from "./query"
|
||||
export * from "./worker"
|
||||
|
92
src/assets/javascripts/integrations/search/query/_/index.ts
Normal file
92
src/assets/javascripts/integrations/search/query/_/index.ts
Normal 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
|
||||
}
|
24
src/assets/javascripts/integrations/search/query/index.ts
Normal file
24
src/assets/javascripts/integrations/search/query/index.ts
Normal 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"
|
@ -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 */
|
||||
}
|
@ -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)
|
||||
)
|
||||
|
@ -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))
|
||||
})
|
||||
|
@ -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 */
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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 (
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -66,6 +66,8 @@
|
||||
// Hide radio buttons
|
||||
> input {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
|
||||
// Active tab label
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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 -%}
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user