searcher.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. "use strict";
  2. window.search = window.search || {};
  3. (function search(search) {
  4. // Search functionality
  5. //
  6. // You can use !hasFocus() to prevent keyhandling in your key
  7. // event handlers while the user is typing their search.
  8. if (!Mark || !elasticlunr) {
  9. return;
  10. }
  11. //IE 11 Compatibility from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith
  12. if (!String.prototype.startsWith) {
  13. String.prototype.startsWith = function(search, pos) {
  14. return this.substr(!pos || pos < 0 ? 0 : +pos, search.length) === search;
  15. };
  16. }
  17. var search_wrap = document.getElementById('search-wrapper'),
  18. searchbar = document.getElementById('searchbar'),
  19. searchbar_outer = document.getElementById('searchbar-outer'),
  20. searchresults = document.getElementById('searchresults'),
  21. searchresults_outer = document.getElementById('searchresults-outer'),
  22. searchresults_header = document.getElementById('searchresults-header'),
  23. searchicon = document.getElementById('search-toggle'),
  24. content = document.getElementById('content'),
  25. searchindex = null,
  26. doc_urls = [],
  27. results_options = {
  28. teaser_word_count: 30,
  29. limit_results: 30,
  30. },
  31. search_options = {
  32. bool: "AND",
  33. expand: true,
  34. fields: {
  35. title: {boost: 1},
  36. body: {boost: 1},
  37. breadcrumbs: {boost: 0}
  38. }
  39. },
  40. mark_exclude = [],
  41. marker = new Mark(content),
  42. current_searchterm = "",
  43. URL_SEARCH_PARAM = 'search',
  44. URL_MARK_PARAM = 'highlight',
  45. teaser_count = 0,
  46. SEARCH_HOTKEY_KEYCODE = 83,
  47. ESCAPE_KEYCODE = 27,
  48. DOWN_KEYCODE = 40,
  49. UP_KEYCODE = 38,
  50. SELECT_KEYCODE = 13;
  51. function hasFocus() {
  52. return searchbar === document.activeElement;
  53. }
  54. function removeChildren(elem) {
  55. while (elem.firstChild) {
  56. elem.removeChild(elem.firstChild);
  57. }
  58. }
  59. // Helper to parse a url into its building blocks.
  60. function parseURL(url) {
  61. var a = document.createElement('a');
  62. a.href = url;
  63. return {
  64. source: url,
  65. protocol: a.protocol.replace(':',''),
  66. host: a.hostname,
  67. port: a.port,
  68. params: (function(){
  69. var ret = {};
  70. var seg = a.search.replace(/^\?/,'').split('&');
  71. var len = seg.length, i = 0, s;
  72. for (;i<len;i++) {
  73. if (!seg[i]) { continue; }
  74. s = seg[i].split('=');
  75. ret[s[0]] = s[1];
  76. }
  77. return ret;
  78. })(),
  79. file: (a.pathname.match(/\/([^/?#]+)$/i) || [,''])[1],
  80. hash: a.hash.replace('#',''),
  81. path: a.pathname.replace(/^([^/])/,'/$1')
  82. };
  83. }
  84. // Helper to recreate a url string from its building blocks.
  85. function renderURL(urlobject) {
  86. var url = urlobject.protocol + "://" + urlobject.host;
  87. if (urlobject.port != "") {
  88. url += ":" + urlobject.port;
  89. }
  90. url += urlobject.path;
  91. var joiner = "?";
  92. for(var prop in urlobject.params) {
  93. if(urlobject.params.hasOwnProperty(prop)) {
  94. url += joiner + prop + "=" + urlobject.params[prop];
  95. joiner = "&";
  96. }
  97. }
  98. if (urlobject.hash != "") {
  99. url += "#" + urlobject.hash;
  100. }
  101. return url;
  102. }
  103. // Helper to escape html special chars for displaying the teasers
  104. var escapeHTML = (function() {
  105. var MAP = {
  106. '&': '&amp;',
  107. '<': '&lt;',
  108. '>': '&gt;',
  109. '"': '&#34;',
  110. "'": '&#39;'
  111. };
  112. var repl = function(c) { return MAP[c]; };
  113. return function(s) {
  114. return s.replace(/[&<>'"]/g, repl);
  115. };
  116. })();
  117. function formatSearchMetric(count, searchterm) {
  118. if (count == 1) {
  119. return count + " search result for '" + searchterm + "':";
  120. } else if (count == 0) {
  121. return "No search results for '" + searchterm + "'.";
  122. } else {
  123. return count + " search results for '" + searchterm + "':";
  124. }
  125. }
  126. function formatSearchResult(result, searchterms) {
  127. var teaser = makeTeaser(escapeHTML(result.doc.body), searchterms);
  128. teaser_count++;
  129. // The ?URL_MARK_PARAM= parameter belongs inbetween the page and the #heading-anchor
  130. var url = doc_urls[result.ref].split("#");
  131. if (url.length == 1) { // no anchor found
  132. url.push("");
  133. }
  134. // encodeURIComponent escapes all chars that could allow an XSS except
  135. // for '. Due to that we also manually replace ' with its url-encoded
  136. // representation (%27).
  137. var searchterms = encodeURIComponent(searchterms.join(" ")).replace(/\'/g, "%27");
  138. return '<a href="' + path_to_root + url[0] + '?' + URL_MARK_PARAM + '=' + searchterms + '#' + url[1]
  139. + '" aria-details="teaser_' + teaser_count + '">' + result.doc.breadcrumbs + '</a>'
  140. + '<span class="teaser" id="teaser_' + teaser_count + '" aria-label="Search Result Teaser">'
  141. + teaser + '</span>';
  142. }
  143. function makeTeaser(body, searchterms) {
  144. // The strategy is as follows:
  145. // First, assign a value to each word in the document:
  146. // Words that correspond to search terms (stemmer aware): 40
  147. // Normal words: 2
  148. // First word in a sentence: 8
  149. // Then use a sliding window with a constant number of words and count the
  150. // sum of the values of the words within the window. Then use the window that got the
  151. // maximum sum. If there are multiple maximas, then get the last one.
  152. // Enclose the terms in <em>.
  153. var stemmed_searchterms = searchterms.map(function(w) {
  154. return elasticlunr.stemmer(w.toLowerCase());
  155. });
  156. var searchterm_weight = 40;
  157. var weighted = []; // contains elements of ["word", weight, index_in_document]
  158. // split in sentences, then words
  159. var sentences = body.toLowerCase().split('. ');
  160. var index = 0;
  161. var value = 0;
  162. var searchterm_found = false;
  163. for (var sentenceindex in sentences) {
  164. var words = sentences[sentenceindex].split(' ');
  165. value = 8;
  166. for (var wordindex in words) {
  167. var word = words[wordindex];
  168. if (word.length > 0) {
  169. for (var searchtermindex in stemmed_searchterms) {
  170. if (elasticlunr.stemmer(word).startsWith(stemmed_searchterms[searchtermindex])) {
  171. value = searchterm_weight;
  172. searchterm_found = true;
  173. }
  174. };
  175. weighted.push([word, value, index]);
  176. value = 2;
  177. }
  178. index += word.length;
  179. index += 1; // ' ' or '.' if last word in sentence
  180. };
  181. index += 1; // because we split at a two-char boundary '. '
  182. };
  183. if (weighted.length == 0) {
  184. return body;
  185. }
  186. var window_weight = [];
  187. var window_size = Math.min(weighted.length, results_options.teaser_word_count);
  188. var cur_sum = 0;
  189. for (var wordindex = 0; wordindex < window_size; wordindex++) {
  190. cur_sum += weighted[wordindex][1];
  191. };
  192. window_weight.push(cur_sum);
  193. for (var wordindex = 0; wordindex < weighted.length - window_size; wordindex++) {
  194. cur_sum -= weighted[wordindex][1];
  195. cur_sum += weighted[wordindex + window_size][1];
  196. window_weight.push(cur_sum);
  197. };
  198. if (searchterm_found) {
  199. var max_sum = 0;
  200. var max_sum_window_index = 0;
  201. // backwards
  202. for (var i = window_weight.length - 1; i >= 0; i--) {
  203. if (window_weight[i] > max_sum) {
  204. max_sum = window_weight[i];
  205. max_sum_window_index = i;
  206. }
  207. };
  208. } else {
  209. max_sum_window_index = 0;
  210. }
  211. // add <em/> around searchterms
  212. var teaser_split = [];
  213. var index = weighted[max_sum_window_index][2];
  214. for (var i = max_sum_window_index; i < max_sum_window_index+window_size; i++) {
  215. var word = weighted[i];
  216. if (index < word[2]) {
  217. // missing text from index to start of `word`
  218. teaser_split.push(body.substring(index, word[2]));
  219. index = word[2];
  220. }
  221. if (word[1] == searchterm_weight) {
  222. teaser_split.push("<em>")
  223. }
  224. index = word[2] + word[0].length;
  225. teaser_split.push(body.substring(word[2], index));
  226. if (word[1] == searchterm_weight) {
  227. teaser_split.push("</em>")
  228. }
  229. };
  230. return teaser_split.join('');
  231. }
  232. function init(config) {
  233. results_options = config.results_options;
  234. search_options = config.search_options;
  235. searchbar_outer = config.searchbar_outer;
  236. doc_urls = config.doc_urls;
  237. searchindex = elasticlunr.Index.load(config.index);
  238. // Set up events
  239. searchicon.addEventListener('click', function(e) { searchIconClickHandler(); }, false);
  240. searchbar.addEventListener('keyup', function(e) { searchbarKeyUpHandler(); }, false);
  241. document.addEventListener('keydown', function(e) { globalKeyHandler(e); }, false);
  242. // If the user uses the browser buttons, do the same as if a reload happened
  243. window.onpopstate = function(e) { doSearchOrMarkFromUrl(); };
  244. // Suppress "submit" events so the page doesn't reload when the user presses Enter
  245. document.addEventListener('submit', function(e) { e.preventDefault(); }, false);
  246. // If reloaded, do the search or mark again, depending on the current url parameters
  247. doSearchOrMarkFromUrl();
  248. }
  249. function unfocusSearchbar() {
  250. // hacky, but just focusing a div only works once
  251. var tmp = document.createElement('input');
  252. tmp.setAttribute('style', 'position: absolute; opacity: 0;');
  253. searchicon.appendChild(tmp);
  254. tmp.focus();
  255. tmp.remove();
  256. }
  257. // On reload or browser history backwards/forwards events, parse the url and do search or mark
  258. function doSearchOrMarkFromUrl() {
  259. // Check current URL for search request
  260. var url = parseURL(window.location.href);
  261. if (url.params.hasOwnProperty(URL_SEARCH_PARAM)
  262. && url.params[URL_SEARCH_PARAM] != "") {
  263. showSearch(true);
  264. searchbar.value = decodeURIComponent(
  265. (url.params[URL_SEARCH_PARAM]+'').replace(/\+/g, '%20'));
  266. searchbarKeyUpHandler(); // -> doSearch()
  267. } else {
  268. showSearch(false);
  269. }
  270. if (url.params.hasOwnProperty(URL_MARK_PARAM)) {
  271. var words = decodeURIComponent(url.params[URL_MARK_PARAM]).split(' ');
  272. marker.mark(words, {
  273. exclude: mark_exclude
  274. });
  275. var markers = document.querySelectorAll("mark");
  276. function hide() {
  277. for (var i = 0; i < markers.length; i++) {
  278. markers[i].classList.add("fade-out");
  279. window.setTimeout(function(e) { marker.unmark(); }, 300);
  280. }
  281. }
  282. for (var i = 0; i < markers.length; i++) {
  283. markers[i].addEventListener('click', hide);
  284. }
  285. }
  286. }
  287. // Eventhandler for keyevents on `document`
  288. function globalKeyHandler(e) {
  289. if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || e.target.type === 'textarea' || e.target.type === 'text') { return; }
  290. if (e.keyCode === ESCAPE_KEYCODE) {
  291. e.preventDefault();
  292. searchbar.classList.remove("active");
  293. setSearchUrlParameters("",
  294. (searchbar.value.trim() !== "") ? "push" : "replace");
  295. if (hasFocus()) {
  296. unfocusSearchbar();
  297. }
  298. showSearch(false);
  299. marker.unmark();
  300. } else if (!hasFocus() && e.keyCode === SEARCH_HOTKEY_KEYCODE) {
  301. e.preventDefault();
  302. showSearch(true);
  303. window.scrollTo(0, 0);
  304. searchbar.select();
  305. } else if (hasFocus() && e.keyCode === DOWN_KEYCODE) {
  306. e.preventDefault();
  307. unfocusSearchbar();
  308. searchresults.firstElementChild.classList.add("focus");
  309. } else if (!hasFocus() && (e.keyCode === DOWN_KEYCODE
  310. || e.keyCode === UP_KEYCODE
  311. || e.keyCode === SELECT_KEYCODE)) {
  312. // not `:focus` because browser does annoying scrolling
  313. var focused = searchresults.querySelector("li.focus");
  314. if (!focused) return;
  315. e.preventDefault();
  316. if (e.keyCode === DOWN_KEYCODE) {
  317. var next = focused.nextElementSibling;
  318. if (next) {
  319. focused.classList.remove("focus");
  320. next.classList.add("focus");
  321. }
  322. } else if (e.keyCode === UP_KEYCODE) {
  323. focused.classList.remove("focus");
  324. var prev = focused.previousElementSibling;
  325. if (prev) {
  326. prev.classList.add("focus");
  327. } else {
  328. searchbar.select();
  329. }
  330. } else { // SELECT_KEYCODE
  331. window.location.assign(focused.querySelector('a'));
  332. }
  333. }
  334. }
  335. function showSearch(yes) {
  336. if (yes) {
  337. search_wrap.classList.remove('hidden');
  338. searchicon.setAttribute('aria-expanded', 'true');
  339. } else {
  340. search_wrap.classList.add('hidden');
  341. searchicon.setAttribute('aria-expanded', 'false');
  342. var results = searchresults.children;
  343. for (var i = 0; i < results.length; i++) {
  344. results[i].classList.remove("focus");
  345. }
  346. }
  347. }
  348. function showResults(yes) {
  349. if (yes) {
  350. searchresults_outer.classList.remove('hidden');
  351. } else {
  352. searchresults_outer.classList.add('hidden');
  353. }
  354. }
  355. // Eventhandler for search icon
  356. function searchIconClickHandler() {
  357. if (search_wrap.classList.contains('hidden')) {
  358. showSearch(true);
  359. window.scrollTo(0, 0);
  360. searchbar.select();
  361. } else {
  362. showSearch(false);
  363. }
  364. }
  365. // Eventhandler for keyevents while the searchbar is focused
  366. function searchbarKeyUpHandler() {
  367. var searchterm = searchbar.value.trim();
  368. if (searchterm != "") {
  369. searchbar.classList.add("active");
  370. doSearch(searchterm);
  371. } else {
  372. searchbar.classList.remove("active");
  373. showResults(false);
  374. removeChildren(searchresults);
  375. }
  376. setSearchUrlParameters(searchterm, "push_if_new_search_else_replace");
  377. // Remove marks
  378. marker.unmark();
  379. }
  380. // Update current url with ?URL_SEARCH_PARAM= parameter, remove ?URL_MARK_PARAM and #heading-anchor .
  381. // `action` can be one of "push", "replace", "push_if_new_search_else_replace"
  382. // and replaces or pushes a new browser history item.
  383. // "push_if_new_search_else_replace" pushes if there is no `?URL_SEARCH_PARAM=abc` yet.
  384. function setSearchUrlParameters(searchterm, action) {
  385. var url = parseURL(window.location.href);
  386. var first_search = ! url.params.hasOwnProperty(URL_SEARCH_PARAM);
  387. if (searchterm != "" || action == "push_if_new_search_else_replace") {
  388. url.params[URL_SEARCH_PARAM] = searchterm;
  389. delete url.params[URL_MARK_PARAM];
  390. url.hash = "";
  391. } else {
  392. delete url.params[URL_MARK_PARAM];
  393. delete url.params[URL_SEARCH_PARAM];
  394. }
  395. // A new search will also add a new history item, so the user can go back
  396. // to the page prior to searching. A updated search term will only replace
  397. // the url.
  398. if (action == "push" || (action == "push_if_new_search_else_replace" && first_search) ) {
  399. history.pushState({}, document.title, renderURL(url));
  400. } else if (action == "replace" || (action == "push_if_new_search_else_replace" && !first_search) ) {
  401. history.replaceState({}, document.title, renderURL(url));
  402. }
  403. }
  404. function doSearch(searchterm) {
  405. // Don't search the same twice
  406. if (current_searchterm == searchterm) { return; }
  407. else { current_searchterm = searchterm; }
  408. if (searchindex == null) { return; }
  409. // Do the actual search
  410. var results = searchindex.search(searchterm, search_options);
  411. var resultcount = Math.min(results.length, results_options.limit_results);
  412. // Display search metrics
  413. searchresults_header.innerText = formatSearchMetric(resultcount, searchterm);
  414. // Clear and insert results
  415. var searchterms = searchterm.split(' ');
  416. removeChildren(searchresults);
  417. for(var i = 0; i < resultcount ; i++){
  418. var resultElem = document.createElement('li');
  419. resultElem.innerHTML = formatSearchResult(results[i], searchterms);
  420. searchresults.appendChild(resultElem);
  421. }
  422. // Display results
  423. showResults(true);
  424. }
  425. fetch(path_to_root + 'searchindex.json')
  426. .then(response => response.json())
  427. .then(json => init(json))
  428. .catch(error => { // Try to load searchindex.js if fetch failed
  429. var script = document.createElement('script');
  430. script.src = path_to_root + 'searchindex.js';
  431. script.onload = () => init(window.search);
  432. document.head.appendChild(script);
  433. });
  434. // Exported functions
  435. search.hasFocus = hasFocus;
  436. })(window.search);