searcher.js 17 KB

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