"use strict"; 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; } //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; }; } var search_wrap = document.getElementById('search-wrapper'), searchbar = document.getElementById('searchbar'), searchbar_outer = document.getElementById('searchbar-outer'), 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'), searchindex = null, doc_urls = [], results_options = { teaser_word_count: 30, limit_results: 30, }, search_options = { bool: "AND", expand: true, fields: { title: {boost: 1}, body: {boost: 1}, breadcrumbs: {boost: 0} } }, mark_exclude = [], marker = new Mark(content), current_searchterm = "", URL_SEARCH_PARAM = 'search', URL_MARK_PARAM = 'highlight', teaser_count = 0, SEARCH_HOTKEY_KEYCODE = 83, ESCAPE_KEYCODE = 27, DOWN_KEYCODE = 40, UP_KEYCODE = 38, SELECT_KEYCODE = 13; 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) { var a = document.createElement('a'); a.href = url; return { source: url, protocol: a.protocol.replace(':',''), host: a.hostname, port: a.port, params: (function(){ var ret = {}; var seg = a.search.replace(/^\?/,'').split('&'); var len = seg.length, i = 0, s; for (;i<len;i++) { if (!seg[i]) { continue; } s = seg[i].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) { var url = urlobject.protocol + "://" + urlobject.host; if (urlobject.port != "") { url += ":" + urlobject.port; } url += urlobject.path; var joiner = "?"; for(var prop in urlobject.params) { if(urlobject.params.hasOwnProperty(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 var escapeHTML = (function() { var MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; var 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) { var teaser = makeTeaser(escapeHTML(result.doc.body), searchterms); teaser_count++; // The ?URL_MARK_PARAM= parameter belongs inbetween the page and the #heading-anchor var 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). var searchterms = encodeURIComponent(searchterms.join(" ")).replace(/\'/g, "%27"); return '<a href="' + path_to_root + url[0] + '?' + URL_MARK_PARAM + '=' + searchterms + '#' + 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>. var stemmed_searchterms = searchterms.map(function(w) { return elasticlunr.stemmer(w.toLowerCase()); }); var searchterm_weight = 40; var weighted = []; // contains elements of ["word", weight, index_in_document] // split in sentences, then words var sentences = body.toLowerCase().split('. '); var index = 0; var value = 0; var searchterm_found = false; for (var sentenceindex in sentences) { var words = sentences[sentenceindex].split(' '); value = 8; for (var wordindex in words) { var word = words[wordindex]; if (word.length > 0) { for (var 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; } var window_weight = []; var window_size = Math.min(weighted.length, results_options.teaser_word_count); var cur_sum = 0; for (var wordindex = 0; wordindex < window_size; wordindex++) { cur_sum += weighted[wordindex][1]; }; window_weight.push(cur_sum); for (var 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); }; if (searchterm_found) { var max_sum = 0; var max_sum_window_index = 0; // backwards for (var 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 var teaser_split = []; var index = weighted[max_sum_window_index][2]; for (var i = max_sum_window_index; i < max_sum_window_index+window_size; i++) { var 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; searchbar_outer = config.searchbar_outer; doc_urls = config.doc_urls; searchindex = elasticlunr.Index.load(config.index); // Set up events searchicon.addEventListener('click', function(e) { searchIconClickHandler(); }, false); searchbar.addEventListener('keyup', function(e) { searchbarKeyUpHandler(); }, false); document.addEventListener('keydown', function(e) { globalKeyHandler(e); }, false); // If the user uses the browser buttons, do the same as if a reload happened window.onpopstate = function(e) { doSearchOrMarkFromUrl(); }; // Suppress "submit" events so the page doesn't reload when the user presses Enter document.addEventListener('submit', function(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 var 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 var url = parseURL(window.location.href); if (url.params.hasOwnProperty(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 (url.params.hasOwnProperty(URL_MARK_PARAM)) { var words = decodeURIComponent(url.params[URL_MARK_PARAM]).split(' '); marker.mark(words, { exclude: mark_exclude }); var markers = document.querySelectorAll("mark"); function hide() { for (var i = 0; i < markers.length; i++) { markers[i].classList.add("fade-out"); window.setTimeout(function(e) { marker.unmark(); }, 300); } } for (var 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') { 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 var focused = searchresults.querySelector("li.focus"); if (!focused) return; e.preventDefault(); if (e.keyCode === DOWN_KEYCODE) { var next = focused.nextElementSibling; if (next) { focused.classList.remove("focus"); next.classList.add("focus"); } } else if (e.keyCode === UP_KEYCODE) { focused.classList.remove("focus"); var 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'); var results = searchresults.children; for (var 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() { var 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) { var url = parseURL(window.location.href); var first_search = ! url.params.hasOwnProperty(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 var results = searchindex.search(searchterm, search_options); var resultcount = Math.min(results.length, results_options.limit_results); // Display search metrics searchresults_header.innerText = formatSearchMetric(resultcount, searchterm); // Clear and insert results var searchterms = searchterm.split(' '); removeChildren(searchresults); for(var i = 0; i < resultcount ; i++){ var resultElem = document.createElement('li'); resultElem.innerHTML = formatSearchResult(results[i], searchterms); searchresults.appendChild(resultElem); } // Display results showResults(true); } fetch(path_to_root + 'searchindex.json') .then(response => response.json()) .then(json => init(json)) .catch(error => { // Try to load searchindex.js if fetch failed var script = document.createElement('script'); script.src = path_to_root + 'searchindex.js'; script.onload = () => init(window.search); document.head.appendChild(script); }); // Exported functions search.hasFocus = hasFocus; })(window.search);