searcher.js 17 KB

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