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