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 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="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 }; 345 346 for (let i = 0; i < markers.length; i++) { 347 markers[i].addEventListener('click', hide); 348 } 349 } 350 } 351 352 // Eventhandler for keyevents on `document` 353 function globalKeyHandler(e) { 354 if (e.altKey || 355 e.ctrlKey || 356 e.metaKey || 357 e.shiftKey || 358 e.target.type === 'textarea' || 359 e.target.type === 'text' || 360 !hasFocus() && /^(?:input|select|textarea)$/i.test(e.target.nodeName) 361 ) { 362 return; 363 } 364 365 if (e.key === 'Escape') { 366 e.preventDefault(); 367 searchbar.classList.remove('active'); 368 setSearchUrlParameters('',
369 searchbar.value.trim() !== '' ? 'push' : 'replace'); 370 if (hasFocus()) { 371 unfocusSearchbar(); 372 } 373 showSearch(false); 374 marker.unmark(); 375 } else if (!hasFocus() && (e.key === 's' || e.key === '/')) { 376 e.preventDefault(); 377 showSearch(true); 378 window.scrollTo(0, 0); 379 searchbar.select(); 380 } else if (hasFocus() && (e.key === 'ArrowDown' 381 || e.key === 'Enter')) { 382 e.preventDefault(); 383 const first = searchresults.firstElementChild; 384 if (first !== null) { 385 unfocusSearchbar(); 386 first.classList.add('focus'); 387 if (e.key === 'Enter') { 388 window.location.assign(first.querySelector('a')); 389 } 390 } 391 } else if (!hasFocus() && (e.key === 'ArrowDown' 392 || e.key === 'ArrowUp' 393 || e.key === 'Enter')) { 394 // not `:focus` because browser does annoying scrolling 395 const focused = searchresults.querySelector('li.focus'); 396 if (!focused) { 397 return; 398 } 399 e.preventDefault(); 400 if (e.key === 'ArrowDown') { 401 const next = focused.nextElementSibling; 402 if (next) { 403 focused.classList.remove('focus'); 404 next.classList.add('focus'); 405 } 406 } else if (e.key === 'ArrowUp') { 407 focused.classList.remove('focus'); 408 const prev = focused.previousElementSibling; 409 if (prev) { 410 prev.classList.add('focus'); 411 } else { 412 searchbar.select(); 413 } 414 } else { // Enter 415 window.location.assign(focused.querySelector('a')); 416 } 417 } 418 } 419 420 function loadSearchScript(url, id) { 421 if (document.getElementById(id)) { 422 return; 423 } 424 searchbar_outer.classList.add('searching'); 425 426 const script = document.createElement('script'); 427 script.src = url; 428 script.id = id; 429 script.onload = () => init(window.search); 430 script.onerror = error => { 431 console.error(`Failed to load \`${url}\`: ${error}`); 432 }; 433 document.head.append(script); 434 } 435 436 function showSearch(yes) { 437 if (yes) { 438 loadSearchScript( 439 window.path_to_searchindex_js || 440 path_to_root + 'searchindex-a37dc288.js', 441 'mdbook-search-index'); 442 search_wrap.classList.remove('hidden'); 443 searchicon.setAttribute('aria-expanded', 'true'); 444 } else { 445 search_wrap.classList.add('hidden'); 446 searchicon.setAttribute('aria-expanded', 'false'); 447 const results = searchresults.children; 448 for (let i = 0; i < results.length; i++) { 449 results[i].classList.remove('focus'); 450 } 451 } 452 } 453 454 function showResults(yes) { 455 if (yes) { 456 searchresults_outer.classList.remove('hidden'); 457 } else { 458 searchresults_outer.classList.add('hidden'); 459 } 460 } 461 462 // Eventhandler for search icon 463 function searchIconClickHandler() { 464 if (search_wrap.classList.contains('hidden')) { 465 showSearch(true); 466 window.scrollTo(0, 0); 467 searchbar.select(); 468 } else { 469 showSearch(false); 470 } 471 } 472 473 // Eventhandler for keyevents while the searchbar is focused 474 function searchbarKeyUpHandler() { 475 const searchterm = searchbar.value.trim(); 476 if (searchterm !== '') { 477 searchbar.classList.add('active'); 478 doSearch(searchterm); 479 } else { 480 searchbar.classList.remove('active'); 481 showResults(false); 482 removeChildren(searchresults); 483 } 484 485 setSearchUrlParameters(searchterm, 'push_if_new_search_else_replace'); 486 487 // Remove marks 488 marker.unmark(); 489 } 490 491 // Update current url with ?URL_SEARCH_PARAM= parameter, remove ?URL_MARK_PARAM and 492 // `#heading-anchor`. `action` can be one of "push", "replace", 493 // "push_if_new_search_else_replace" and replaces or pushes a new browser history item. 494 // "push_if_new_search_else_replace" pushes if there is no `?URL_SEARCH_PARAM=abc` yet. 495 function setSearchUrlParameters(searchterm, action) { 496 const url = parseURL(window.location.href); 497 const first_search = !Object.prototype.hasOwnProperty.call(url.params, URL_SEARCH_PARAM); 498 499 if (searchterm !== '' || action === 'push_if_new_search_else_replace') { 500 url.params[URL_SEARCH_PARAM] = searchterm; 501 delete url.params[URL_MARK_PARAM]; 502 url.hash = ''; 503 } else { 504 delete url.params[URL_MARK_PARAM]; 505 delete url.params[URL_SEARCH_PARAM]; 506 } 507 // A new search will also add a new history item, so the user can go back 508 // to the page prior to searching. A updated search term will only replace 509 // the url. 510 if (action === 'push' || action === 'push_if_new_search_else_replace' && first_search ) { 511 history.pushState({}, document.title, renderURL(url)); 512 } else if (action === 'replace' || 513 action === 'push_if_new_search_else_replace' && 514 !first_search 515 ) { 516 history.replaceState({}, document.title, renderURL(url)); 517 } 518 } 519 520 function doSearch(searchterm) { 521 // Don't search the same twice 522 if (current_searchterm === searchterm) { 523 return; 524 } 525 searchbar_outer.classList.add('searching'); 526 if (searchindex === null) { 527 return; 528 } 529 530 current_searchterm = searchterm; 531 532 // Do the actual search 533 const results = searchindex.search(searchterm, search_options); 534 const resultcount = Math.min(results.length, results_options.limit_results); 535 536 // Display search metrics 537 searchresults_header.innerText = formatSearchMetric(resultcount, searchterm); 538 539 // Clear and insert results 540 const searchterms = searchterm.split(' '); 541 removeChildren(searchresults); 542 for (let i = 0; i < resultcount ; i++) { 543 const resultElem = document.createElement('li'); 544 resultElem.innerHTML = formatSearchResult(results[i], searchterms); 545 searchresults.appendChild(resultElem); 546 } 547 548 // Display results 549 showResults(true); 550 searchbar_outer.classList.remove('searching'); 551 } 552 553 // Exported functions 554 search.hasFocus = hasFocus; 555})(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.