'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 '' + result.doc.breadcrumbs + '' + '' + teaser + ''; } 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 . 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 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(''); } index = word[2] + word[0].length; teaser_split.push(body.substring(word[2], index)); if (word[1] === searchterm_weight) { teaser_split.push(''); } } 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);