mirror of https://github.com/sunface/rust-course
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
527 lines
19 KiB
527 lines
19 KiB
4 weeks ago
|
'use strict';
|
||
|
|
||
|
/* global Mark, elasticlunr, path_to_root */
|
||
|
|
||
|
window.search = window.search || {};
|
||
|
(function search(search) {
|
||
|
// Search functionality
|
||
|
//
|
||
|
// You can use !hasFocus() to prevent keyhandling in your key
|
||
|
// event handlers while the user is typing their search.
|
||
|
|
||
|
if (!Mark || !elasticlunr) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// eslint-disable-next-line max-len
|
||
|
// IE 11 Compatibility from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith
|
||
|
if (!String.prototype.startsWith) {
|
||
|
String.prototype.startsWith = function(search, pos) {
|
||
|
return this.substr(!pos || pos < 0 ? 0 : +pos, search.length) === search;
|
||
|
};
|
||
|
}
|
||
|
|
||
|
const search_wrap = document.getElementById('search-wrapper'),
|
||
|
searchbar = document.getElementById('searchbar'),
|
||
|
searchresults = document.getElementById('searchresults'),
|
||
|
searchresults_outer = document.getElementById('searchresults-outer'),
|
||
|
searchresults_header = document.getElementById('searchresults-header'),
|
||
|
searchicon = document.getElementById('search-toggle'),
|
||
|
content = document.getElementById('content'),
|
||
|
|
||
|
mark_exclude = [],
|
||
|
marker = new Mark(content),
|
||
|
URL_SEARCH_PARAM = 'search',
|
||
|
URL_MARK_PARAM = 'highlight',
|
||
|
|
||
|
SEARCH_HOTKEY_KEYCODE = 83,
|
||
|
ESCAPE_KEYCODE = 27,
|
||
|
DOWN_KEYCODE = 40,
|
||
|
UP_KEYCODE = 38,
|
||
|
SELECT_KEYCODE = 13;
|
||
|
|
||
|
let current_searchterm = '',
|
||
|
doc_urls = [],
|
||
|
search_options = {
|
||
|
bool: 'AND',
|
||
|
expand: true,
|
||
|
fields: {
|
||
|
title: {boost: 1},
|
||
|
body: {boost: 1},
|
||
|
breadcrumbs: {boost: 0},
|
||
|
},
|
||
|
},
|
||
|
searchindex = null,
|
||
|
results_options = {
|
||
|
teaser_word_count: 30,
|
||
|
limit_results: 30,
|
||
|
},
|
||
|
teaser_count = 0;
|
||
|
|
||
|
function hasFocus() {
|
||
|
return searchbar === document.activeElement;
|
||
|
}
|
||
|
|
||
|
function removeChildren(elem) {
|
||
|
while (elem.firstChild) {
|
||
|
elem.removeChild(elem.firstChild);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Helper to parse a url into its building blocks.
|
||
|
function parseURL(url) {
|
||
|
const a = document.createElement('a');
|
||
|
a.href = url;
|
||
|
return {
|
||
|
source: url,
|
||
|
protocol: a.protocol.replace(':', ''),
|
||
|
host: a.hostname,
|
||
|
port: a.port,
|
||
|
params: (function() {
|
||
|
const ret = {};
|
||
|
const seg = a.search.replace(/^\?/, '').split('&');
|
||
|
for (const part of seg) {
|
||
|
if (!part) {
|
||
|
continue;
|
||
|
}
|
||
|
const s = part.split('=');
|
||
|
ret[s[0]] = s[1];
|
||
|
}
|
||
|
return ret;
|
||
|
})(),
|
||
|
file: (a.pathname.match(/\/([^/?#]+)$/i) || ['', ''])[1],
|
||
|
hash: a.hash.replace('#', ''),
|
||
|
path: a.pathname.replace(/^([^/])/, '/$1'),
|
||
|
};
|
||
|
}
|
||
|
|
||
|
// Helper to recreate a url string from its building blocks.
|
||
|
function renderURL(urlobject) {
|
||
|
let url = urlobject.protocol + '://' + urlobject.host;
|
||
|
if (urlobject.port !== '') {
|
||
|
url += ':' + urlobject.port;
|
||
|
}
|
||
|
url += urlobject.path;
|
||
|
let joiner = '?';
|
||
|
for (const prop in urlobject.params) {
|
||
|
if (Object.prototype.hasOwnProperty.call(urlobject.params, prop)) {
|
||
|
url += joiner + prop + '=' + urlobject.params[prop];
|
||
|
joiner = '&';
|
||
|
}
|
||
|
}
|
||
|
if (urlobject.hash !== '') {
|
||
|
url += '#' + urlobject.hash;
|
||
|
}
|
||
|
return url;
|
||
|
}
|
||
|
|
||
|
// Helper to escape html special chars for displaying the teasers
|
||
|
const escapeHTML = (function() {
|
||
|
const MAP = {
|
||
|
'&': '&',
|
||
|
'<': '<',
|
||
|
'>': '>',
|
||
|
'"': '"',
|
||
|
'\'': ''',
|
||
|
};
|
||
|
const repl = function(c) {
|
||
|
return MAP[c];
|
||
|
};
|
||
|
return function(s) {
|
||
|
return s.replace(/[&<>'"]/g, repl);
|
||
|
};
|
||
|
})();
|
||
|
|
||
|
function formatSearchMetric(count, searchterm) {
|
||
|
if (count === 1) {
|
||
|
return count + ' search result for \'' + searchterm + '\':';
|
||
|
} else if (count === 0) {
|
||
|
return 'No search results for \'' + searchterm + '\'.';
|
||
|
} else {
|
||
|
return count + ' search results for \'' + searchterm + '\':';
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function formatSearchResult(result, searchterms) {
|
||
|
const teaser = makeTeaser(escapeHTML(result.doc.body), searchterms);
|
||
|
teaser_count++;
|
||
|
|
||
|
// The ?URL_MARK_PARAM= parameter belongs inbetween the page and the #heading-anchor
|
||
|
const url = doc_urls[result.ref].split('#');
|
||
|
if (url.length === 1) { // no anchor found
|
||
|
url.push('');
|
||
|
}
|
||
|
|
||
|
// encodeURIComponent escapes all chars that could allow an XSS except
|
||
|
// for '. Due to that we also manually replace ' with its url-encoded
|
||
|
// representation (%27).
|
||
|
const encoded_search = encodeURIComponent(searchterms.join(' ')).replace(/'/g, '%27');
|
||
|
|
||
|
return '<a href="' + path_to_root + url[0] + '?' + URL_MARK_PARAM + '=' + encoded_search
|
||
|
+ '#' + url[1] + '" aria-details="teaser_' + teaser_count + '">'
|
||
|
+ result.doc.breadcrumbs + '</a>' + '<span class="teaser" id="teaser_' + teaser_count
|
||
|
+ '" aria-label="Search Result Teaser">' + teaser + '</span>';
|
||
|
}
|
||
|
|
||
|
function makeTeaser(body, searchterms) {
|
||
|
// The strategy is as follows:
|
||
|
// First, assign a value to each word in the document:
|
||
|
// Words that correspond to search terms (stemmer aware): 40
|
||
|
// Normal words: 2
|
||
|
// First word in a sentence: 8
|
||
|
// Then use a sliding window with a constant number of words and count the
|
||
|
// sum of the values of the words within the window. Then use the window that got the
|
||
|
// maximum sum. If there are multiple maximas, then get the last one.
|
||
|
// Enclose the terms in <em>.
|
||
|
const stemmed_searchterms = searchterms.map(function(w) {
|
||
|
return elasticlunr.stemmer(w.toLowerCase());
|
||
|
});
|
||
|
const searchterm_weight = 40;
|
||
|
const weighted = []; // contains elements of ["word", weight, index_in_document]
|
||
|
// split in sentences, then words
|
||
|
const sentences = body.toLowerCase().split('. ');
|
||
|
let index = 0;
|
||
|
let value = 0;
|
||
|
let searchterm_found = false;
|
||
|
for (const sentenceindex in sentences) {
|
||
|
const words = sentences[sentenceindex].split(' ');
|
||
|
value = 8;
|
||
|
for (const wordindex in words) {
|
||
|
const word = words[wordindex];
|
||
|
if (word.length > 0) {
|
||
|
for (const searchtermindex in stemmed_searchterms) {
|
||
|
if (elasticlunr.stemmer(word).startsWith(
|
||
|
stemmed_searchterms[searchtermindex])
|
||
|
) {
|
||
|
value = searchterm_weight;
|
||
|
searchterm_found = true;
|
||
|
}
|
||
|
}
|
||
|
weighted.push([word, value, index]);
|
||
|
value = 2;
|
||
|
}
|
||
|
index += word.length;
|
||
|
index += 1; // ' ' or '.' if last word in sentence
|
||
|
}
|
||
|
index += 1; // because we split at a two-char boundary '. '
|
||
|
}
|
||
|
|
||
|
if (weighted.length === 0) {
|
||
|
return body;
|
||
|
}
|
||
|
|
||
|
const window_weight = [];
|
||
|
const window_size = Math.min(weighted.length, results_options.teaser_word_count);
|
||
|
|
||
|
let cur_sum = 0;
|
||
|
for (let wordindex = 0; wordindex < window_size; wordindex++) {
|
||
|
cur_sum += weighted[wordindex][1];
|
||
|
}
|
||
|
window_weight.push(cur_sum);
|
||
|
for (let wordindex = 0; wordindex < weighted.length - window_size; wordindex++) {
|
||
|
cur_sum -= weighted[wordindex][1];
|
||
|
cur_sum += weighted[wordindex + window_size][1];
|
||
|
window_weight.push(cur_sum);
|
||
|
}
|
||
|
|
||
|
let max_sum_window_index = 0;
|
||
|
if (searchterm_found) {
|
||
|
let max_sum = 0;
|
||
|
// backwards
|
||
|
for (let i = window_weight.length - 1; i >= 0; i--) {
|
||
|
if (window_weight[i] > max_sum) {
|
||
|
max_sum = window_weight[i];
|
||
|
max_sum_window_index = i;
|
||
|
}
|
||
|
}
|
||
|
} else {
|
||
|
max_sum_window_index = 0;
|
||
|
}
|
||
|
|
||
|
// add <em/> around searchterms
|
||
|
const teaser_split = [];
|
||
|
index = weighted[max_sum_window_index][2];
|
||
|
for (let i = max_sum_window_index; i < max_sum_window_index + window_size; i++) {
|
||
|
const word = weighted[i];
|
||
|
if (index < word[2]) {
|
||
|
// missing text from index to start of `word`
|
||
|
teaser_split.push(body.substring(index, word[2]));
|
||
|
index = word[2];
|
||
|
}
|
||
|
if (word[1] === searchterm_weight) {
|
||
|
teaser_split.push('<em>');
|
||
|
}
|
||
|
index = word[2] + word[0].length;
|
||
|
teaser_split.push(body.substring(word[2], index));
|
||
|
if (word[1] === searchterm_weight) {
|
||
|
teaser_split.push('</em>');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return teaser_split.join('');
|
||
|
}
|
||
|
|
||
|
function init(config) {
|
||
|
results_options = config.results_options;
|
||
|
search_options = config.search_options;
|
||
|
doc_urls = config.doc_urls;
|
||
|
searchindex = elasticlunr.Index.load(config.index);
|
||
|
|
||
|
// Set up events
|
||
|
searchicon.addEventListener('click', () => {
|
||
|
searchIconClickHandler();
|
||
|
}, false);
|
||
|
searchbar.addEventListener('keyup', () => {
|
||
|
searchbarKeyUpHandler();
|
||
|
}, false);
|
||
|
document.addEventListener('keydown', e => {
|
||
|
globalKeyHandler(e);
|
||
|
}, false);
|
||
|
// If the user uses the browser buttons, do the same as if a reload happened
|
||
|
window.onpopstate = () => {
|
||
|
doSearchOrMarkFromUrl();
|
||
|
};
|
||
|
// Suppress "submit" events so the page doesn't reload when the user presses Enter
|
||
|
document.addEventListener('submit', e => {
|
||
|
e.preventDefault();
|
||
|
}, false);
|
||
|
|
||
|
// If reloaded, do the search or mark again, depending on the current url parameters
|
||
|
doSearchOrMarkFromUrl();
|
||
|
}
|
||
|
|
||
|
function unfocusSearchbar() {
|
||
|
// hacky, but just focusing a div only works once
|
||
|
const tmp = document.createElement('input');
|
||
|
tmp.setAttribute('style', 'position: absolute; opacity: 0;');
|
||
|
searchicon.appendChild(tmp);
|
||
|
tmp.focus();
|
||
|
tmp.remove();
|
||
|
}
|
||
|
|
||
|
// On reload or browser history backwards/forwards events, parse the url and do search or mark
|
||
|
function doSearchOrMarkFromUrl() {
|
||
|
// Check current URL for search request
|
||
|
const url = parseURL(window.location.href);
|
||
|
if (Object.prototype.hasOwnProperty.call(url.params, URL_SEARCH_PARAM)
|
||
|
&& url.params[URL_SEARCH_PARAM] !== '') {
|
||
|
showSearch(true);
|
||
|
searchbar.value = decodeURIComponent(
|
||
|
(url.params[URL_SEARCH_PARAM] + '').replace(/\+/g, '%20'));
|
||
|
searchbarKeyUpHandler(); // -> doSearch()
|
||
|
} else {
|
||
|
showSearch(false);
|
||
|
}
|
||
|
|
||
|
if (Object.prototype.hasOwnProperty.call(url.params, URL_MARK_PARAM)) {
|
||
|
const words = decodeURIComponent(url.params[URL_MARK_PARAM]).split(' ');
|
||
|
marker.mark(words, {
|
||
|
exclude: mark_exclude,
|
||
|
});
|
||
|
|
||
|
const markers = document.querySelectorAll('mark');
|
||
|
const hide = () => {
|
||
|
for (let i = 0; i < markers.length; i++) {
|
||
|
markers[i].classList.add('fade-out');
|
||
|
window.setTimeout(() => {
|
||
|
marker.unmark();
|
||
|
}, 300);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
for (let i = 0; i < markers.length; i++) {
|
||
|
markers[i].addEventListener('click', hide);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Eventhandler for keyevents on `document`
|
||
|
function globalKeyHandler(e) {
|
||
|
if (e.altKey ||
|
||
|
e.ctrlKey ||
|
||
|
e.metaKey ||
|
||
|
e.shiftKey ||
|
||
|
e.target.type === 'textarea' ||
|
||
|
e.target.type === 'text' ||
|
||
|
!hasFocus() && /^(?:input|select|textarea)$/i.test(e.target.nodeName)
|
||
|
) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (e.keyCode === ESCAPE_KEYCODE) {
|
||
|
e.preventDefault();
|
||
|
searchbar.classList.remove('active');
|
||
|
setSearchUrlParameters('',
|
||
|
searchbar.value.trim() !== '' ? 'push' : 'replace');
|
||
|
if (hasFocus()) {
|
||
|
unfocusSearchbar();
|
||
|
}
|
||
|
showSearch(false);
|
||
|
marker.unmark();
|
||
|
} else if (!hasFocus() && e.keyCode === SEARCH_HOTKEY_KEYCODE) {
|
||
|
e.preventDefault();
|
||
|
showSearch(true);
|
||
|
window.scrollTo(0, 0);
|
||
|
searchbar.select();
|
||
|
} else if (hasFocus() && e.keyCode === DOWN_KEYCODE) {
|
||
|
e.preventDefault();
|
||
|
unfocusSearchbar();
|
||
|
searchresults.firstElementChild.classList.add('focus');
|
||
|
} else if (!hasFocus() && (e.keyCode === DOWN_KEYCODE
|
||
|
|| e.keyCode === UP_KEYCODE
|
||
|
|| e.keyCode === SELECT_KEYCODE)) {
|
||
|
// not `:focus` because browser does annoying scrolling
|
||
|
const focused = searchresults.querySelector('li.focus');
|
||
|
if (!focused) {
|
||
|
return;
|
||
|
}
|
||
|
e.preventDefault();
|
||
|
if (e.keyCode === DOWN_KEYCODE) {
|
||
|
const next = focused.nextElementSibling;
|
||
|
if (next) {
|
||
|
focused.classList.remove('focus');
|
||
|
next.classList.add('focus');
|
||
|
}
|
||
|
} else if (e.keyCode === UP_KEYCODE) {
|
||
|
focused.classList.remove('focus');
|
||
|
const prev = focused.previousElementSibling;
|
||
|
if (prev) {
|
||
|
prev.classList.add('focus');
|
||
|
} else {
|
||
|
searchbar.select();
|
||
|
}
|
||
|
} else { // SELECT_KEYCODE
|
||
|
window.location.assign(focused.querySelector('a'));
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function showSearch(yes) {
|
||
|
if (yes) {
|
||
|
search_wrap.classList.remove('hidden');
|
||
|
searchicon.setAttribute('aria-expanded', 'true');
|
||
|
} else {
|
||
|
search_wrap.classList.add('hidden');
|
||
|
searchicon.setAttribute('aria-expanded', 'false');
|
||
|
const results = searchresults.children;
|
||
|
for (let i = 0; i < results.length; i++) {
|
||
|
results[i].classList.remove('focus');
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function showResults(yes) {
|
||
|
if (yes) {
|
||
|
searchresults_outer.classList.remove('hidden');
|
||
|
} else {
|
||
|
searchresults_outer.classList.add('hidden');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Eventhandler for search icon
|
||
|
function searchIconClickHandler() {
|
||
|
if (search_wrap.classList.contains('hidden')) {
|
||
|
showSearch(true);
|
||
|
window.scrollTo(0, 0);
|
||
|
searchbar.select();
|
||
|
} else {
|
||
|
showSearch(false);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Eventhandler for keyevents while the searchbar is focused
|
||
|
function searchbarKeyUpHandler() {
|
||
|
const searchterm = searchbar.value.trim();
|
||
|
if (searchterm !== '') {
|
||
|
searchbar.classList.add('active');
|
||
|
doSearch(searchterm);
|
||
|
} else {
|
||
|
searchbar.classList.remove('active');
|
||
|
showResults(false);
|
||
|
removeChildren(searchresults);
|
||
|
}
|
||
|
|
||
|
setSearchUrlParameters(searchterm, 'push_if_new_search_else_replace');
|
||
|
|
||
|
// Remove marks
|
||
|
marker.unmark();
|
||
|
}
|
||
|
|
||
|
// Update current url with ?URL_SEARCH_PARAM= parameter, remove ?URL_MARK_PARAM and
|
||
|
// `#heading-anchor`. `action` can be one of "push", "replace",
|
||
|
// "push_if_new_search_else_replace" and replaces or pushes a new browser history item.
|
||
|
// "push_if_new_search_else_replace" pushes if there is no `?URL_SEARCH_PARAM=abc` yet.
|
||
|
function setSearchUrlParameters(searchterm, action) {
|
||
|
const url = parseURL(window.location.href);
|
||
|
const first_search = !Object.prototype.hasOwnProperty.call(url.params, URL_SEARCH_PARAM);
|
||
|
|
||
|
if (searchterm !== '' || action === 'push_if_new_search_else_replace') {
|
||
|
url.params[URL_SEARCH_PARAM] = searchterm;
|
||
|
delete url.params[URL_MARK_PARAM];
|
||
|
url.hash = '';
|
||
|
} else {
|
||
|
delete url.params[URL_MARK_PARAM];
|
||
|
delete url.params[URL_SEARCH_PARAM];
|
||
|
}
|
||
|
// A new search will also add a new history item, so the user can go back
|
||
|
// to the page prior to searching. A updated search term will only replace
|
||
|
// the url.
|
||
|
if (action === 'push' || action === 'push_if_new_search_else_replace' && first_search ) {
|
||
|
history.pushState({}, document.title, renderURL(url));
|
||
|
} else if (action === 'replace' ||
|
||
|
action === 'push_if_new_search_else_replace' &&
|
||
|
!first_search
|
||
|
) {
|
||
|
history.replaceState({}, document.title, renderURL(url));
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function doSearch(searchterm) {
|
||
|
// Don't search the same twice
|
||
|
if (current_searchterm === searchterm) {
|
||
|
return;
|
||
|
} else {
|
||
|
current_searchterm = searchterm;
|
||
|
}
|
||
|
|
||
|
if (searchindex === null) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Do the actual search
|
||
|
const results = searchindex.search(searchterm, search_options);
|
||
|
const resultcount = Math.min(results.length, results_options.limit_results);
|
||
|
|
||
|
// Display search metrics
|
||
|
searchresults_header.innerText = formatSearchMetric(resultcount, searchterm);
|
||
|
|
||
|
// Clear and insert results
|
||
|
const searchterms = searchterm.split(' ');
|
||
|
removeChildren(searchresults);
|
||
|
for (let i = 0; i < resultcount ; i++) {
|
||
|
const resultElem = document.createElement('li');
|
||
|
resultElem.innerHTML = formatSearchResult(results[i], searchterms);
|
||
|
searchresults.appendChild(resultElem);
|
||
|
}
|
||
|
|
||
|
// Display results
|
||
|
showResults(true);
|
||
|
}
|
||
|
|
||
|
function loadScript(url, id) {
|
||
|
const script = document.createElement('script');
|
||
|
script.src = url;
|
||
|
script.id = id;
|
||
|
script.onload = () => init(window.search);
|
||
|
script.onerror = error => {
|
||
|
console.error(`Failed to load \`${url}\`: ${error}`);
|
||
|
};
|
||
|
document.head.append(script);
|
||
|
}
|
||
|
|
||
|
loadScript(path_to_root + 'searchindex.js', 'search-index');
|
||
|
|
||
|
// Exported functions
|
||
|
search.hasFocus = hasFocus;
|
||
|
})(window.search);
|