diff options
author | johannst <johannst@users.noreply.github.com> | 2025-04-09 22:28:27 +0000 |
---|---|---|
committer | johannst <johannst@users.noreply.github.com> | 2025-04-09 22:28:27 +0000 |
commit | 4a9214d09d6a526bd029a1f92a01a5f451313c9a (patch) | |
tree | 2a4134ffb9b7f1d4cf6eee7e9305125cc878ce1c /searcher.js | |
parent | 2cad8341019659a65fc6e94992165b3d7b7a37db (diff) | |
download | notes-4a9214d09d6a526bd029a1f92a01a5f451313c9a.tar.gz notes-4a9214d09d6a526bd029a1f92a01a5f451313c9a.zip |
deploy: 773d9b46ee3b1b88a94e69f42ea42654c63c48ec
Diffstat (limited to 'searcher.js')
-rw-r--r-- | searcher.js | 401 |
1 files changed, 222 insertions, 179 deletions
diff --git a/searcher.js b/searcher.js index dc03e0a..7ee7bae 100644 --- a/searcher.js +++ b/searcher.js @@ -1,4 +1,7 @@ -"use strict"; +'use strict'; + +/* global Mark, elasticlunr, path_to_root */ + window.search = window.search || {}; (function search(search) { // Search functionality @@ -10,43 +13,26 @@ window.search = window.search || {}; return; } - //IE 11 Compatibility from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith + // 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; }; } - var search_wrap = document.getElementById('search-wrapper'), + const 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, @@ -54,6 +40,24 @@ window.search = window.search || {}; 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; } @@ -66,96 +70,99 @@ window.search = window.search || {}; // Helper to parse a url into its building blocks. function parseURL(url) { - var a = document.createElement('a'); + const a = document.createElement('a'); a.href = url; return { source: url, - protocol: a.protocol.replace(':',''), + 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('='); + 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') + 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; + let 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 = "&"; + 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; + if (urlobject.hash !== '') { + url += '#' + urlobject.hash; } return url; } - + // Helper to escape html special chars for displaying the teasers - var escapeHTML = (function() { - var MAP = { + const escapeHTML = (function() { + const MAP = { '&': '&', '<': '<', '>': '>', '"': '"', - "'": ''' + '\'': ''', + }; + const repl = function(c) { + return MAP[c]; }; - 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 + "'."; + 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 + "':"; + return count + ' search results for \'' + searchterm + '\':'; } } - + function formatSearchResult(result, searchterms) { - var teaser = makeTeaser(escapeHTML(result.doc.body), searchterms); + const 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(""); + 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). - var searchterms = encodeURIComponent(searchterms.join(" ")).replace(/\'/g, "%27"); + const encoded_search = 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>'; + 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: @@ -166,88 +173,90 @@ window.search = window.search || {}; // 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) { + const 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] + const searchterm_weight = 40; + const 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(' '); + 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 (var wordindex in words) { - var word = words[wordindex]; + for (const wordindex in words) { + const word = words[wordindex]; if (word.length > 0) { - for (var searchtermindex in stemmed_searchterms) { - if (elasticlunr.stemmer(word).startsWith(stemmed_searchterms[searchtermindex])) { + 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) { + if (weighted.length === 0) { return body; } - var window_weight = []; - var window_size = Math.min(weighted.length, results_options.teaser_word_count); + const window_weight = []; + const window_size = Math.min(weighted.length, results_options.teaser_word_count); - var cur_sum = 0; - for (var wordindex = 0; wordindex < window_size; wordindex++) { + let cur_sum = 0; + for (let 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++) { + 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) { - var max_sum = 0; - var max_sum_window_index = 0; + let max_sum = 0; // backwards - for (var i = window_weight.length - 1; i >= 0; i--) { + 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 - 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]; + 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>") + 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>") + if (word[1] === searchterm_weight) { + teaser_split.push('</em>'); } - }; + } return teaser_split.join(''); } @@ -255,74 +264,95 @@ window.search = window.search || {}; 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); + 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 = function(e) { doSearchOrMarkFromUrl(); }; + window.onpopstate = () => { + doSearchOrMarkFromUrl(); + }; // Suppress "submit" events so the page doesn't reload when the user presses Enter - document.addEventListener('submit', function(e) { e.preventDefault(); }, false); + 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 - var tmp = document.createElement('input'); + 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 - var url = parseURL(window.location.href); - if (url.params.hasOwnProperty(URL_SEARCH_PARAM) - && url.params[URL_SEARCH_PARAM] != "") { + 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')); + (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(' '); + 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 + 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); + 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 (var i = 0; i < markers.length; i++) { + }; + + 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.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"); + searchbar.classList.remove('active'); + setSearchUrlParameters('', + searchbar.value.trim() !== '' ? 'push' : 'replace'); if (hasFocus()) { unfocusSearchbar(); } @@ -336,25 +366,27 @@ window.search = window.search || {}; } else if (hasFocus() && e.keyCode === DOWN_KEYCODE) { e.preventDefault(); unfocusSearchbar(); - searchresults.firstElementChild.classList.add("focus"); + 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; + const focused = searchresults.querySelector('li.focus'); + if (!focused) { + return; + } e.preventDefault(); if (e.keyCode === DOWN_KEYCODE) { - var next = focused.nextElementSibling; + const next = focused.nextElementSibling; if (next) { - focused.classList.remove("focus"); - next.classList.add("focus"); + focused.classList.remove('focus'); + next.classList.add('focus'); } } else if (e.keyCode === UP_KEYCODE) { - focused.classList.remove("focus"); - var prev = focused.previousElementSibling; + focused.classList.remove('focus'); + const prev = focused.previousElementSibling; if (prev) { - prev.classList.add("focus"); + prev.classList.add('focus'); } else { searchbar.select(); } @@ -363,7 +395,7 @@ window.search = window.search || {}; } } } - + function showSearch(yes) { if (yes) { search_wrap.classList.remove('hidden'); @@ -371,9 +403,9 @@ window.search = window.search || {}; } 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"); + const results = searchresults.children; + for (let i = 0; i < results.length; i++) { + results[i].classList.remove('focus'); } } } @@ -396,36 +428,37 @@ window.search = window.search || {}; showSearch(false); } } - + // Eventhandler for keyevents while the searchbar is focused function searchbarKeyUpHandler() { - var searchterm = searchbar.value.trim(); - if (searchterm != "") { - searchbar.classList.add("active"); + const searchterm = searchbar.value.trim(); + if (searchterm !== '') { + searchbar.classList.add('active'); doSearch(searchterm); } else { - searchbar.classList.remove("active"); + searchbar.classList.remove('active'); showResults(false); removeChildren(searchresults); } - setSearchUrlParameters(searchterm, "push_if_new_search_else_replace"); + 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. + + // 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") { + 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 = ""; + url.hash = ''; } else { delete url.params[URL_MARK_PARAM]; delete url.params[URL_SEARCH_PARAM]; @@ -433,33 +466,40 @@ window.search = window.search || {}; // 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) ) { + 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) ) { + } else if (action === 'replace' || + action === 'push_if_new_search_else_replace' && + !first_search + ) { history.replaceState({}, document.title, renderURL(url)); } } - - function doSearch(searchterm) { + function doSearch(searchterm) { // Don't search the same twice - if (current_searchterm == searchterm) { return; } - else { current_searchterm = searchterm; } + if (current_searchterm === searchterm) { + return; + } else { + current_searchterm = searchterm; + } - if (searchindex == null) { return; } + 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); + 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 - var searchterms = searchterm.split(' '); + const searchterms = searchterm.split(' '); removeChildren(searchresults); - for(var i = 0; i < resultcount ; i++){ - var resultElem = document.createElement('li'); + for (let i = 0; i < resultcount ; i++) { + const resultElem = document.createElement('li'); resultElem.innerHTML = formatSearchResult(results[i], searchterms); searchresults.appendChild(resultElem); } @@ -468,15 +508,18 @@ window.search = window.search || {}; 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); - }); + 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; |