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('mdbook-search-wrapper'), 25 searchbar_outer = document.getElementById('mdbook-searchbar-outer'), 26 searchbar = document.getElementById('mdbook-searchbar'), 27 searchresults = document.getElementById('mdbook-searchresults'), 28 searchresults_outer = document.getElementById('mdbook-searchresults-outer'), 29 searchresults_header = document.getElementById('mdbook-searchresults-header'), 30 searchicon = document.getElementById('mdbook-search-toggle'), 31 content = document.getElementById('mdbook-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 in between 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="mdbook-teaser_' + teaser_count + '">' 158 + result.doc.breadcrumbs + '</a>' 159 + '<span class="teaser" id="mdbook-teaser_' + teaser_count 160 + '" aria-label="Search Result Teaser">' + teaser + '</span>'; 161 } 162 163 function makeTeaser(body, searchterms) { 164 // The strategy is as follows: 165 // First, assign a value to each word in the document: 166 // Words that correspond to search terms (stemmer aware): 40 167 // Normal words: 2 168 // First word in a sentence: 8 169 // Then use a sliding window with a constant number of words and count the 170 // sum of the values of the words within the window. Then use the window that got the 171 // maximum sum. If there are multiple maximas, then get the last one. 172 // Enclose the terms in <em>. 173 const stemmed_searchterms = searchterms.map(function(w) { 174 return elasticlunr.stemmer(w.toLowerCase()); 175 }); 176 const searchterm_weight = 40; 177 const weighted = []; // contains elements of ["word", weight, index_in_document] 178 // split in sentences, then words 179 const sentences = body.toLowerCase().split('. '); 180 let index = 0; 181 let value = 0; 182 let searchterm_found = false;
183 for (const sentenceindex in sentences) { 184 const words = sentences[sentenceindex].split(' '); 185 value = 8; 186 for (const wordindex in words) { 187 const word = words[wordindex]; 188 if (word.length > 0) { 189 for (const searchtermindex in stemmed_searchterms) { 190 if (elasticlunr.stemmer(word).startsWith( 191 stemmed_searchterms[searchtermindex]) 192 ) { 193 value = searchterm_weight; 194 searchterm_found = true; 195 } 196 } 197 weighted.push([word, value, index]); 198 value = 2; 199 } 200 index += word.length; 201 index += 1; // ' ' or '.' if last word in sentence 202 } 203 index += 1; // because we split at a two-char boundary '. ' 204 } 205 206 if (weighted.length === 0) { 207 return body; 208 } 209 210 const window_weight = []; 211 const window_size = Math.min(weighted.length, results_options.teaser_word_count); 212 213 let cur_sum = 0; 214 for (let wordindex = 0; wordindex < window_size; wordindex++) { 215 cur_sum += weighted[wordindex][1]; 216 } 217 window_weight.push(cur_sum); 218 for (let wordindex = 0; wordindex < weighted.length - window_size; wordindex++) { 219 cur_sum -= weighted[wordindex][1]; 220 cur_sum += weighted[wordindex + window_size][1]; 221 window_weight.push(cur_sum); 222 } 223 224 let max_sum_window_index = 0; 225 if (searchterm_found) { 226 let max_sum = 0; 227 // backwards 228 for (let i = window_weight.length - 1; i >= 0; i--) { 229 if (window_weight[i] > max_sum) { 230 max_sum = window_weight[i]; 231 max_sum_window_index = i; 232 } 233 } 234 } else { 235 max_sum_window_index = 0; 236 } 237 238 // add <em/> around searchterms 239 const teaser_split = []; 240 index = weighted[max_sum_window_index][2]; 241 for (let i = max_sum_window_index; i < max_sum_window_index + window_size; i++) { 242 const word = weighted[i]; 243 if (index < word[2]) { 244 // missing text from index to start of `word` 245 teaser_split.push(body.substring(index, word[2])); 246 index = word[2]; 247 } 248 if (word[1] === searchterm_weight) { 249 teaser_split.push('<em>'); 250 } 251 index = word[2] + word[0].length; 252 teaser_split.push(body.substring(word[2], index)); 253 if (word[1] === searchterm_weight) { 254 teaser_split.push('</em>'); 255 } 256 } 257 258 return teaser_split.join(''); 259 } 260 261 function init(config) { 262 results_options = config.results_options; 263 search_options = config.search_options; 264 doc_urls = config.doc_urls; 265 searchindex = elasticlunr.Index.load(config.index); 266 267 searchbar_outer.classList.remove('searching'); 268 269 searchbar.focus(); 270 271 const searchterm = searchbar.value.trim(); 272 if (searchterm !== '') { 273 searchbar.classList.add('active'); 274 doSearch(searchterm); 275 } 276 } 277 278 function initSearchInteractions(config) { 279 // Set up events 280 searchicon.addEventListener('click', () => { 281 searchIconClickHandler(); 282 }, false); 283 searchbar.addEventListener('keyup', () => { 284 searchbarKeyUpHandler(); 285 }, false); 286 document.addEventListener('keydown', e => { 287 globalKeyHandler(e); 288 }, false); 289 // If the user uses the browser buttons, do the same as if a reload happened 290 window.onpopstate = () => { 291 doSearchOrMarkFromUrl(); 292 }; 293 // Suppress "submit" events so the page doesn't reload when the user presses Enter 294 document.addEventListener('submit', e => { 295 e.preventDefault(); 296 }, false); 297 298 // If reloaded, do the search or mark again, depending on the current url parameters 299 doSearchOrMarkFromUrl(); 300 301 // Exported functions 302 config.hasFocus = hasFocus; 303 } 304 305 initSearchInteractions(window.search); 306 307 function unfocusSearchbar() { 308 // hacky, but just focusing a div only works once 309 const tmp = document.createElement('input'); 310 tmp.setAttribute('style', 'position: absolute; opacity: 0;'); 311 searchicon.appendChild(tmp); 312 tmp.focus(); 313 tmp.remove(); 314 } 315 316 // On reload or browser history backwards/forwards events, parse the url and do search or mark 317 function doSearchOrMarkFromUrl() { 318 // Check current URL for search request 319 const url = parseURL(window.location.href); 320 if (Object.prototype.hasOwnProperty.call(url.params, URL_SEARCH_PARAM) 321 && url.params[URL_SEARCH_PARAM] !== '') { 322 showSearch(true); 323 searchbar.value = decodeURIComponent( 324 (url.params[URL_SEARCH_PARAM] + '').replace(/\+/g, '%20')); 325 searchbarKeyUpHandler(); // -> doSearch() 326 } else { 327 showSearch(false); 328 } 329 330 if (Object.prototype.hasOwnProperty.call(url.params, URL_MARK_PARAM)) { 331 const words = decodeURIComponent(url.params[URL_MARK_PARAM]).split(' '); 332 marker.mark(words, { 333 exclude: mark_exclude, 334 }); 335 336 const markers = document.querySelectorAll('mark'); 337 const hide = () => { 338 for (let i = 0; i < markers.length; i++) { 339 markers[i].classList.add('fade-out'); 340 window.setTimeout(() => { 341 marker.unmark(); 342 }, 300); 343 } 344 // also removes the `?URL_MARK_PARAM=` search param so that 345 // in-page navigation doesn't make highlights unexpectedly appear again 346 setSearchUrlParameters('', 'replace'); 347 }; 348 349 for (let i = 0; i < markers.length; i++) { 350 markers[i].addEventListener('click', hide); 351 } 352 } 353 } 354 355 // Eventhandler for keyevents on `document` 356 function globalKeyHandler(e) { 357 if (e.altKey || 358 e.ctrlKey || 359 e.metaKey || 360 e.shiftKey || 361 e.target.type === 'textarea' || 362 e.target.type === 'text' || 363 !hasFocus() && mdbook_something_else_has_focus(e) 364 ) { 365 return; 366 } 367 368 if (e.key === 'Escape') { 369 e.preventDefault(); 370 searchbar.classList.remove('active'); 371 setSearchUrlParameters('',
372 searchbar.value.trim() !== '' ? 'push' : 'replace'); 373 if (hasFocus()) { 374 unfocusSearchbar(); 375 } 376 showSearch(false); 377 marker.unmark(); 378 } else if (!hasFocus() && (e.key === 's' || e.key === '/')) { 379 e.preventDefault(); 380 showSearch(true); 381 window.scrollTo(0, 0); 382 searchbar.select(); 383 } else if (hasFocus() && (e.key === 'ArrowDown' 384 || e.key === 'Enter')) { 385 e.preventDefault(); 386 const first = searchresults.firstElementChild; 387 if (first !== null) { 388 unfocusSearchbar(); 389 first.classList.add('focus'); 390 if (e.key === 'Enter') { 391 window.location.assign(first.querySelector('a')); 392 } 393 } 394 } else if (!hasFocus() && (e.key === 'ArrowDown' 395 || e.key === 'ArrowUp' 396 || e.key === 'Enter')) { 397 // not `:focus` because browser does annoying scrolling 398 const focused = searchresults.querySelector('li.focus'); 399 if (!focused) { 400 return; 401 } 402 e.preventDefault(); 403 if (e.key === 'ArrowDown') { 404 const next = focused.nextElementSibling; 405 if (next) { 406 focused.classList.remove('focus'); 407 next.classList.add('focus'); 408 } 409 } else if (e.key === 'ArrowUp') { 410 focused.classList.remove('focus'); 411 const prev = focused.previousElementSibling; 412 if (prev) { 413 prev.classList.add('focus'); 414 } else { 415 searchbar.select(); 416 } 417 } else { // Enter 418 window.location.assign(focused.querySelector('a')); 419 } 420 } 421 } 422 423 function loadSearchScript(url, id) { 424 if (document.getElementById(id)) { 425 return; 426 } 427 searchbar_outer.classList.add('searching'); 428 429 const script = document.createElement('script'); 430 script.src = url; 431 script.id = id; 432 script.onload = () => init(window.search); 433 script.onerror = error => { 434 console.error(`Failed to load \`${url}\`: ${error}`); 435 }; 436 document.head.append(script); 437 } 438 439 function showSearch(yes) { 440 if (yes) { 441 loadSearchScript( 442 window.path_to_searchindex_js || 443 path_to_root + 'searchindex-4589f4a4.js', 444 'mdbook-search-index'); 445 search_wrap.classList.remove('hidden'); 446 searchicon.setAttribute('aria-expanded', 'true'); 447 } else { 448 search_wrap.classList.add('hidden'); 449 searchicon.setAttribute('aria-expanded', 'false'); 450 const results = searchresults.children; 451 for (let i = 0; i < results.length; i++) { 452 results[i].classList.remove('focus'); 453 } 454 } 455 } 456 457 function showResults(yes) { 458 if (yes) { 459 searchresults_outer.classList.remove('hidden'); 460 } else { 461 searchresults_outer.classList.add('hidden'); 462 } 463 } 464 465 // Eventhandler for search icon 466 function searchIconClickHandler() { 467 if (search_wrap.classList.contains('hidden')) { 468 showSearch(true); 469 window.scrollTo(0, 0); 470 searchbar.select(); 471 } else { 472 showSearch(false); 473 } 474 } 475 476 // Eventhandler for keyevents while the searchbar is focused 477 function searchbarKeyUpHandler() { 478 const searchterm = searchbar.value.trim(); 479 if (searchterm !== '') { 480 searchbar.classList.add('active'); 481 doSearch(searchterm); 482 } else { 483 searchbar.classList.remove('active'); 484 showResults(false); 485 removeChildren(searchresults); 486 } 487 488 setSearchUrlParameters(searchterm, 'push_if_new_search_else_replace'); 489 490 // Remove marks 491 marker.unmark(); 492 } 493 494 // Update current url with ?URL_SEARCH_PARAM= parameter, remove ?URL_MARK_PARAM and 495 // `#heading-anchor`. `action` can be one of "push", "replace", 496 // "push_if_new_search_else_replace" and replaces or pushes a new browser history item. 497 // "push_if_new_search_else_replace" pushes if there is no `?URL_SEARCH_PARAM=abc` yet. 498 function setSearchUrlParameters(searchterm, action) { 499 const url = parseURL(window.location.href); 500 const first_search = !Object.prototype.hasOwnProperty.call(url.params, URL_SEARCH_PARAM); 501 502 if (searchterm !== '' || action === 'push_if_new_search_else_replace') { 503 url.params[URL_SEARCH_PARAM] = searchterm; 504 delete url.params[URL_MARK_PARAM]; 505 url.hash = ''; 506 } else { 507 delete url.params[URL_MARK_PARAM]; 508 delete url.params[URL_SEARCH_PARAM]; 509 } 510 // A new search will also add a new history item, so the user can go back 511 // to the page prior to searching. A updated search term will only replace 512 // the url. 513 if (action === 'push' || action === 'push_if_new_search_else_replace' && first_search ) { 514 history.pushState({}, document.title, renderURL(url)); 515 } else if (action === 'replace' || 516 action === 'push_if_new_search_else_replace' && 517 !first_search 518 ) { 519 history.replaceState({}, document.title, renderURL(url)); 520 } 521 } 522 523 function doSearch(searchterm) { 524 // Don't search the same twice 525 if (current_searchterm === searchterm) { 526 return; 527 } 528 searchbar_outer.classList.add('searching'); 529 if (searchindex === null) { 530 return; 531 } 532 533 current_searchterm = searchterm; 534 535 // Do the actual search 536 const results = searchindex.search(searchterm, search_options); 537 const resultcount = Math.min(results.length, results_options.limit_results); 538 539 // Display search metrics 540 searchresults_header.innerText = formatSearchMetric(resultcount, searchterm); 541 542 // Clear and insert results 543 const searchterms = searchterm.split(' '); 544 removeChildren(searchresults); 545 for (let i = 0; i < resultcount ; i++) { 546 const resultElem = document.createElement('li'); 547 resultElem.innerHTML = formatSearchResult(results[i], searchterms); 548 searchresults.appendChild(resultElem); 549 } 550 551 // Display results 552 showResults(true); 553 searchbar_outer.classList.remove('searching'); 554 } 555 556 // Exported functions 557 search.hasFocus = hasFocus; 558})(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.