searcher.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  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. return '<a href="' + path_to_root + url[0] + '?' + URL_MARK_PARAM + '=' + searchterms + '#' + url[1]
  135. + '" aria-details="teaser_' + teaser_count + '">' + result.doc.breadcrumbs + '</a>'
  136. + '<span class="teaser" id="teaser_' + teaser_count + '" aria-label="Search Result Teaser">'
  137. + teaser + '</span>';
  138. }
  139. function makeTeaser(body, searchterms) {
  140. // The strategy is as follows:
  141. // First, assign a value to each word in the document:
  142. // Words that correspond to search terms (stemmer aware): 40
  143. // Normal words: 2
  144. // First word in a sentence: 8
  145. // Then use a sliding window with a constant number of words and count the
  146. // sum of the values of the words within the window. Then use the window that got the
  147. // maximum sum. If there are multiple maximas, then get the last one.
  148. // Enclose the terms in <em>.
  149. var stemmed_searchterms = searchterms.map(function(w) {
  150. return elasticlunr.stemmer(w.toLowerCase());
  151. });
  152. var searchterm_weight = 40;
  153. var weighted = []; // contains elements of ["word", weight, index_in_document]
  154. // split in sentences, then words
  155. var sentences = body.toLowerCase().split('. ');
  156. var index = 0;
  157. var value = 0;
  158. var searchterm_found = false;
  159. for (var sentenceindex in sentences) {
  160. var words = sentences[sentenceindex].split(' ');
  161. value = 8;
  162. for (var wordindex in words) {
  163. var word = words[wordindex];
  164. if (word.length > 0) {
  165. for (var searchtermindex in stemmed_searchterms) {
  166. if (elasticlunr.stemmer(word).startsWith(stemmed_searchterms[searchtermindex])) {
  167. value = searchterm_weight;
  168. searchterm_found = true;
  169. }
  170. };
  171. weighted.push([word, value, index]);
  172. value = 2;
  173. }
  174. index += word.length;
  175. index += 1; // ' ' or '.' if last word in sentence
  176. };
  177. index += 1; // because we split at a two-char boundary '. '
  178. };
  179. if (weighted.length == 0) {
  180. return body;
  181. }
  182. var window_weight = [];
  183. var window_size = Math.min(weighted.length, results_options.teaser_word_count);
  184. var cur_sum = 0;
  185. for (var wordindex = 0; wordindex < window_size; wordindex++) {
  186. cur_sum += weighted[wordindex][1];
  187. };
  188. window_weight.push(cur_sum);
  189. for (var wordindex = 0; wordindex < weighted.length - window_size; wordindex++) {
  190. cur_sum -= weighted[wordindex][1];
  191. cur_sum += weighted[wordindex + window_size][1];
  192. window_weight.push(cur_sum);
  193. };
  194. if (searchterm_found) {
  195. var max_sum = 0;
  196. var max_sum_window_index = 0;
  197. // backwards
  198. for (var i = window_weight.length - 1; i >= 0; i--) {
  199. if (window_weight[i] > max_sum) {
  200. max_sum = window_weight[i];
  201. max_sum_window_index = i;
  202. }
  203. };
  204. } else {
  205. max_sum_window_index = 0;
  206. }
  207. // add <em/> around searchterms
  208. var teaser_split = [];
  209. var index = weighted[max_sum_window_index][2];
  210. for (var i = max_sum_window_index; i < max_sum_window_index+window_size; i++) {
  211. var word = weighted[i];
  212. if (index < word[2]) {
  213. // missing text from index to start of `word`
  214. teaser_split.push(body.substring(index, word[2]));
  215. index = word[2];
  216. }
  217. if (word[1] == searchterm_weight) {
  218. teaser_split.push("<em>")
  219. }
  220. index = word[2] + word[0].length;
  221. teaser_split.push(body.substring(word[2], index));
  222. if (word[1] == searchterm_weight) {
  223. teaser_split.push("</em>")
  224. }
  225. };
  226. return teaser_split.join('');
  227. }
  228. function init(config) {
  229. results_options = config.results_options;
  230. search_options = config.search_options;
  231. searchbar_outer = config.searchbar_outer;
  232. doc_urls = config.doc_urls;
  233. searchindex = elasticlunr.Index.load(config.index);
  234. // Set up events
  235. searchicon.addEventListener('click', function(e) { searchIconClickHandler(); }, false);
  236. searchbar.addEventListener('keyup', function(e) { searchbarKeyUpHandler(); }, false);
  237. document.addEventListener('keydown', function(e) { globalKeyHandler(e); }, false);
  238. // If the user uses the browser buttons, do the same as if a reload happened
  239. window.onpopstate = function(e) { doSearchOrMarkFromUrl(); };
  240. // Suppress "submit" events so the page doesn't reload when the user presses Enter
  241. document.addEventListener('submit', function(e) { e.preventDefault(); }, false);
  242. // If reloaded, do the search or mark again, depending on the current url parameters
  243. doSearchOrMarkFromUrl();
  244. }
  245. function unfocusSearchbar() {
  246. // hacky, but just focusing a div only works once
  247. var tmp = document.createElement('input');
  248. tmp.setAttribute('style', 'position: absolute; opacity: 0;');
  249. searchicon.appendChild(tmp);
  250. tmp.focus();
  251. tmp.remove();
  252. }
  253. // On reload or browser history backwards/forwards events, parse the url and do search or mark
  254. function doSearchOrMarkFromUrl() {
  255. // Check current URL for search request
  256. var url = parseURL(window.location.href);
  257. if (url.params.hasOwnProperty(URL_SEARCH_PARAM)
  258. && url.params[URL_SEARCH_PARAM] != "") {
  259. showSearch(true);
  260. searchbar.value = decodeURIComponent(
  261. (url.params[URL_SEARCH_PARAM]+'').replace(/\+/g, '%20'));
  262. searchbarKeyUpHandler(); // -> doSearch()
  263. } else {
  264. showSearch(false);
  265. }
  266. if (url.params.hasOwnProperty(URL_MARK_PARAM)) {
  267. var words = url.params[URL_MARK_PARAM].split(' ');
  268. marker.mark(words, {
  269. exclude: mark_exclude
  270. });
  271. var markers = document.querySelectorAll("mark");
  272. function hide() {
  273. for (var i = 0; i < markers.length; i++) {
  274. markers[i].classList.add("fade-out");
  275. window.setTimeout(function(e) { marker.unmark(); }, 300);
  276. }
  277. }
  278. for (var i = 0; i < markers.length; i++) {
  279. markers[i].addEventListener('click', hide);
  280. }
  281. }
  282. }
  283. // Eventhandler for keyevents on `document`
  284. function globalKeyHandler(e) {
  285. if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || e.target.type === 'textarea') { return; }
  286. if (e.keyCode === ESCAPE_KEYCODE) {
  287. e.preventDefault();
  288. searchbar.classList.remove("active");
  289. setSearchUrlParameters("",
  290. (searchbar.value.trim() !== "") ? "push" : "replace");
  291. if (hasFocus()) {
  292. unfocusSearchbar();
  293. }
  294. showSearch(false);
  295. marker.unmark();
  296. } else if (!hasFocus() && e.keyCode === SEARCH_HOTKEY_KEYCODE) {
  297. e.preventDefault();
  298. showSearch(true);
  299. window.scrollTo(0, 0);
  300. searchbar.select();
  301. } else if (hasFocus() && e.keyCode === DOWN_KEYCODE) {
  302. e.preventDefault();
  303. unfocusSearchbar();
  304. searchresults.firstElementChild.classList.add("focus");
  305. } else if (!hasFocus() && (e.keyCode === DOWN_KEYCODE
  306. || e.keyCode === UP_KEYCODE
  307. || e.keyCode === SELECT_KEYCODE)) {
  308. // not `:focus` because browser does annoying scrolling
  309. var focused = searchresults.querySelector("li.focus");
  310. if (!focused) return;
  311. e.preventDefault();
  312. if (e.keyCode === DOWN_KEYCODE) {
  313. var next = focused.nextElementSibling;
  314. if (next) {
  315. focused.classList.remove("focus");
  316. next.classList.add("focus");
  317. }
  318. } else if (e.keyCode === UP_KEYCODE) {
  319. focused.classList.remove("focus");
  320. var prev = focused.previousElementSibling;
  321. if (prev) {
  322. prev.classList.add("focus");
  323. } else {
  324. searchbar.select();
  325. }
  326. } else { // SELECT_KEYCODE
  327. window.location.assign(focused.querySelector('a'));
  328. }
  329. }
  330. }
  331. function showSearch(yes) {
  332. if (yes) {
  333. search_wrap.classList.remove('hidden');
  334. searchicon.setAttribute('aria-expanded', 'true');
  335. } else {
  336. search_wrap.classList.add('hidden');
  337. searchicon.setAttribute('aria-expanded', 'false');
  338. var results = searchresults.children;
  339. for (var i = 0; i < results.length; i++) {
  340. results[i].classList.remove("focus");
  341. }
  342. }
  343. }
  344. function showResults(yes) {
  345. if (yes) {
  346. searchresults_outer.classList.remove('hidden');
  347. } else {
  348. searchresults_outer.classList.add('hidden');
  349. }
  350. }
  351. // Eventhandler for search icon
  352. function searchIconClickHandler() {
  353. if (search_wrap.classList.contains('hidden')) {
  354. showSearch(true);
  355. window.scrollTo(0, 0);
  356. searchbar.select();
  357. } else {
  358. showSearch(false);
  359. }
  360. }
  361. // Eventhandler for keyevents while the searchbar is focused
  362. function searchbarKeyUpHandler() {
  363. var searchterm = searchbar.value.trim();
  364. if (searchterm != "") {
  365. searchbar.classList.add("active");
  366. doSearch(searchterm);
  367. } else {
  368. searchbar.classList.remove("active");
  369. showResults(false);
  370. removeChildren(searchresults);
  371. }
  372. setSearchUrlParameters(searchterm, "push_if_new_search_else_replace");
  373. // Remove marks
  374. marker.unmark();
  375. }
  376. // Update current url with ?URL_SEARCH_PARAM= parameter, remove ?URL_MARK_PARAM and #heading-anchor .
  377. // `action` can be one of "push", "replace", "push_if_new_search_else_replace"
  378. // and replaces or pushes a new browser history item.
  379. // "push_if_new_search_else_replace" pushes if there is no `?URL_SEARCH_PARAM=abc` yet.
  380. function setSearchUrlParameters(searchterm, action) {
  381. var url = parseURL(window.location.href);
  382. var first_search = ! url.params.hasOwnProperty(URL_SEARCH_PARAM);
  383. if (searchterm != "" || action == "push_if_new_search_else_replace") {
  384. url.params[URL_SEARCH_PARAM] = searchterm;
  385. delete url.params[URL_MARK_PARAM];
  386. url.hash = "";
  387. } else {
  388. delete url.params[URL_SEARCH_PARAM];
  389. }
  390. // A new search will also add a new history item, so the user can go back
  391. // to the page prior to searching. A updated search term will only replace
  392. // the url.
  393. if (action == "push" || (action == "push_if_new_search_else_replace" && first_search) ) {
  394. history.pushState({}, document.title, renderURL(url));
  395. } else if (action == "replace" || (action == "push_if_new_search_else_replace" && !first_search) ) {
  396. history.replaceState({}, document.title, renderURL(url));
  397. }
  398. }
  399. function doSearch(searchterm) {
  400. // Don't search the same twice
  401. if (current_searchterm == searchterm) { return; }
  402. else { current_searchterm = searchterm; }
  403. if (searchindex == null) { return; }
  404. // Do the actual search
  405. var results = searchindex.search(searchterm, search_options);
  406. var resultcount = Math.min(results.length, results_options.limit_results);
  407. // Display search metrics
  408. searchresults_header.innerText = formatSearchMetric(resultcount, searchterm);
  409. // Clear and insert results
  410. var searchterms = searchterm.split(' ');
  411. removeChildren(searchresults);
  412. for(var i = 0; i < resultcount ; i++){
  413. var resultElem = document.createElement('li');
  414. resultElem.innerHTML = formatSearchResult(results[i], searchterms);
  415. searchresults.appendChild(resultElem);
  416. }
  417. // Display results
  418. showResults(true);
  419. }
  420. fetch(path_to_root + 'searchindex.json')
  421. .then(response => response.json())
  422. .then(json => init(json))
  423. .catch(error => { // Try to load searchindex.js if fetch failed
  424. var script = document.createElement('script');
  425. script.src = path_to_root + 'searchindex.js';
  426. script.onload = () => init(window.search);
  427. document.head.appendChild(script);
  428. });
  429. // Exported functions
  430. search.hasFocus = hasFocus;
  431. })(window.search);