1/* 2 * Sphinx JavaScript utilities for the full-text search. 3 */ 4"use strict"; 5 6/** 7 * Simple result scoring code. 8 */ 9if (typeof Scorer === "undefined") { 10 var Scorer = { 11 // Implement the following function to further tweak the score for each result 12 // The function takes a result array [docname, title, anchor, descr, score, filename] 13 // and returns the new score. 14 /* 15 score: result => { 16 const [docname, title, anchor, descr, score, filename, kind] = result 17 return score 18 }, 19 */ 20 21 // query matches the full name of an object 22 objNameMatch: 11, 23 // or matches in the last dotted part of the object name 24 objPartialMatch: 6, 25 // Additive scores depending on the priority of the object 26 objPrio: { 27 0: 15, // used to be importantResults 28 1: 5, // used to be objectResults 29 2: -5, // used to be unimportantResults 30 }, 31 // Used when the priority is not in the mapping. 32 objPrioDefault: 0, 33 34 // query found in title 35 title: 15, 36 partialTitle: 7, 37 // query found in terms 38 term: 5, 39 partialTerm: 2, 40 }; 41} 42 43// Global search result kind enum, used by themes to style search results. 44// prettier-ignore 45class SearchResultKind { 46 static get index() { return "index"; } 47 static get object() { return "object"; } 48 static get text() { return "text"; } 49 static get title() { return "title"; } 50} 51 52const _removeChildren = (element) => { 53 while (element && element.lastChild) element.removeChild(element.lastChild); 54}; 55 56/** 57 * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping 58 */ 59const _escapeRegExp = (string) => 60 string.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string 61 62const _escapeHTML = (text) => { 63 return text 64 .replaceAll("&", "&") 65 .replaceAll("<", "<") 66 .replaceAll(">", ">") 67 .replaceAll('"', """) 68 .replaceAll("'", "'"); 69}; 70 71const _displayItem = (item, searchTerms, highlightTerms) => { 72 const docBuilder = DOCUMENTATION_OPTIONS.BUILDER; 73 const docFileSuffix = DOCUMENTATION_OPTIONS.FILE_SUFFIX; 74 const docLinkSuffix = DOCUMENTATION_OPTIONS.LINK_SUFFIX; 75 const showSearchSummary = DOCUMENTATION_OPTIONS.SHOW_SEARCH_SUMMARY; 76 const contentRoot = document.documentElement.dataset.content_root; 77 78 const [docName, title, anchor, descr, score, _filename, kind] = item; 79 80 let listItem = document.createElement("li"); 81 // Add a class representing the item's type: 82 // can be used by a theme's CSS selector for styling 83 // See SearchResultKind for the class names. 84 listItem.classList.add(`kind-${kind}`); 85 let requestUrl; 86 let linkUrl; 87 if (docBuilder === "dirhtml") { 88 // dirhtml builder 89 let dirname = docName + "/"; 90 if (dirname.match(/\/index\/$/)) 91 dirname = dirname.substring(0, dirname.length - 6); 92 else if (dirname === "index/") dirname = ""; 93 requestUrl = contentRoot + dirname; 94 linkUrl = requestUrl; 95 } else { 96 // normal html builders 97 requestUrl = contentRoot + docName + docFileSuffix; 98 linkUrl = docName + docLinkSuffix; 99 } 100 let linkEl = listItem.appendChild(document.createElement("a")); 101 linkEl.href = linkUrl + anchor; 102 linkEl.dataset.score = score; 103 linkEl.innerHTML = _escapeHTML(title); 104 if (descr) { 105 listItem.appendChild(document.createElement("span")).innerHTML = 106 ` (${_escapeHTML(descr)})`; 107 // highlight search terms in the description 108 if (SPHINX_HIGHLIGHT_ENABLED) 109 // SPHINX_HIGHLIGHT_ENABLED is set in sphinx_highlight.js 110 highlightTerms.forEach((term) => 111 _highlightText(listItem, term, "highlighted"), 112 ); 113 } else if (showSearchSummary) 114 fetch(requestUrl) 115 .then((responseData) => responseData.text()) 116 .then((data) => { 117 if (data) 118 listItem.appendChild( 119 Search.makeSearchSummary(data, searchTerms, anchor), 120 ); 121 // highlight search terms in the summary 122 if (SPHINX_HIGHLIGHT_ENABLED) 123 // SPHINX_HIGHLIGHT_ENABLED is set in sphinx_highlight.js 124 highlightTerms.forEach((term) => 125 _highlightText(listItem, term, "highlighted"), 126 ); 127 }); 128 Search.output.appendChild(listItem); 129}; 130const _finishSearch = (resultCount) => { 131 Search.stopPulse(); 132 Search.title.innerText = _("Search Results"); 133 if (!resultCount) 134 Search.status.innerText = Documentation.gettext( 135 "Your search did not match any documents. Please make sure that all words are spelled correctly and that you've selected enough categories.", 136 ); 137 else 138 Search.status.innerText = Documentation.ngettext(
139 "Search finished, found one page matching the search query.", 140 "Search finished, found ${resultCount} pages matching the search query.", 141 resultCount, 142 ).replace("${resultCount}", resultCount); 143}; 144const _displayNextItem = ( 145 results, 146 resultCount, 147 searchTerms, 148 highlightTerms, 149) => { 150 // results left, load the summary and display it 151 // this is intended to be dynamic (don't sub resultsCount) 152 if (results.length) { 153 _displayItem(results.pop(), searchTerms, highlightTerms); 154 setTimeout( 155 () => _displayNextItem(results, resultCount, searchTerms, highlightTerms), 156 5, 157 ); 158 } 159 // search finished, update title and status message 160 else _finishSearch(resultCount); 161}; 162// Helper function used by query() to order search results. 163// Each input is an array of [docname, title, anchor, descr, score, filename, kind]. 164// Order the results by score (in opposite order of appearance, since the 165// `_displayNextItem` function uses pop() to retrieve items) and then alphabetically. 166const _orderResultsByScoreThenName = (a, b) => { 167 const leftScore = a[4]; 168 const rightScore = b[4]; 169 if (leftScore === rightScore) { 170 // same score: sort alphabetically 171 const leftTitle = a[1].toLowerCase(); 172 const rightTitle = b[1].toLowerCase(); 173 if (leftTitle === rightTitle) return 0; 174 return leftTitle > rightTitle ? -1 : 1; // inverted is intentional 175 } 176 return leftScore > rightScore ? 1 : -1; 177}; 178 179/** 180 * Default splitQuery function. Can be overridden in ``sphinx.search`` with a 181 * custom function per language. 182 * 183 * The regular expression works by splitting the string on consecutive characters 184 * that are not Unicode letters, numbers, underscores, or emoji characters. 185 * This is the same as ``\W+`` in Python, preserving the surrogate pair area. 186 */ 187if (typeof splitQuery === "undefined") { 188 var splitQuery = (query) => 189 query 190 .split(/[^\p{Letter}\p{Number}_\p{Emoji_Presentation}]+/gu) 191 .filter((term) => term); // remove remaining empty strings 192} 193 194/** 195 * Search Module 196 */ 197const Search = { 198 _index: null, 199 _queued_query: null, 200 _pulse_status: -1, 201 202 htmlToText: (htmlString, anchor) => { 203 const htmlElement = new DOMParser().parseFromString( 204 htmlString, 205 "text/html", 206 ); 207 for (const removalQuery of [".headerlink", "script", "style"]) { 208 htmlElement.querySelectorAll(removalQuery).forEach((el) => { 209 el.remove(); 210 }); 211 } 212 if (anchor) { 213 const anchorContent = htmlElement.querySelector( 214 `[role="main"] ${anchor}`, 215 ); 216 if (anchorContent) return anchorContent.textContent; 217 218 console.warn( 219 `Anchored content block not found. Sphinx search tries to obtain it via DOM query '[role=main] ${anchor}'. Check your theme or template.`, 220 ); 221 } 222 223 // if anchor not specified or not found, fall back to main content 224 const docContent = htmlElement.querySelector('[role="main"]'); 225 if (docContent) return docContent.textContent; 226 227 console.warn( 228 "Content block not found. Sphinx search tries to obtain it via DOM query '[role=main]'. Check your theme or template.", 229 ); 230 return ""; 231 }, 232 233 init: () => { 234 const query = new URLSearchParams(window.location.search).get("q"); 235 document 236 .querySelectorAll('input[name="q"]') 237 .forEach((el) => (el.value = query)); 238 if (query) Search.performSearch(query); 239 }, 240 241 loadIndex: (url) => 242 (document.body.appendChild(document.createElement("script")).src = url), 243 244 setIndex: (index) => { 245 Search._index = index; 246 if (Search._queued_query !== null) { 247 const query = Search._queued_query; 248 Search._queued_query = null; 249 Search.query(query); 250 } 251 }, 252 253 hasIndex: () => Search._index !== null, 254 255 deferQuery: (query) => (Search._queued_query = query), 256 257 stopPulse: () => (Search._pulse_status = -1), 258 259 startPulse: () => { 260 if (Search._pulse_status >= 0) return; 261 262 const pulse = () => { 263 Search._pulse_status = (Search._pulse_status + 1) % 4; 264 Search.dots.innerText = ".".repeat(Search._pulse_status); 265 if (Search._pulse_status >= 0) window.setTimeout(pulse, 500); 266 }; 267 pulse(); 268 }, 269 270 /** 271 * perform a search for something (or wait until index is loaded) 272 */ 273 performSearch: (query) => { 274 // create the required interface elements 275 const searchText = document.createElement("h2"); 276 searchText.textContent = _("Searching"); 277 const searchSummary = document.createElement("p"); 278 searchSummary.classList.add("search-summary"); 279 searchSummary.innerText = ""; 280 const searchList = document.createElement("ul"); 281 searchList.setAttribute("role", "list"); 282 searchList.classList.add("search"); 283 284 const out = document.getElementById("search-results"); 285 Search.title = out.appendChild(searchText); 286 Search.dots = Search.title.appendChild(document.createElement("span")); 287 Search.status = out.appendChild(searchSummary); 288 Search.output = out.appendChild(searchList); 289 290 const searchProgress = document.getElementById("search-progress"); 291 // Some themes don't use the search progress node 292 if (searchProgress) { 293 searchProgress.innerText = _("Preparing search..."); 294 } 295 Search.startPulse(); 296 297 // index already loaded, the browser was quick! 298 if (Search.hasIndex()) Search.query(query); 299 else Search.deferQuery(query); 300 }, 301 302 _parseQuery: (query) => { 303 // stem the search terms and add them to the correct list 304 const stemmer = new Stemmer(); 305 const searchTerms = new Set(); 306 const excludedTerms = new Set(); 307 const highlightTerms = new Set(); 308 const objectTerms = new Set(splitQuery(query.toLowerCase().trim()));
309 splitQuery(query.trim()).forEach((queryTerm) => { 310 const queryTermLower = queryTerm.toLowerCase(); 311 312 // maybe skip this "word" 313 // stopwords set is from language_data.js 314 if (stopwords.has(queryTermLower) || queryTerm.match(/^\d+$/)) return; 315 316 // stem the word 317 let word = stemmer.stemWord(queryTermLower); 318 // select the correct list 319 if (word[0] === "-") excludedTerms.add(word.substr(1)); 320 else { 321 searchTerms.add(word); 322 highlightTerms.add(queryTermLower); 323 } 324 }); 325 326 if (SPHINX_HIGHLIGHT_ENABLED) { 327 // SPHINX_HIGHLIGHT_ENABLED is set in sphinx_highlight.js 328 localStorage.setItem( 329 "sphinx_highlight_terms", 330 [...highlightTerms].join(" "), 331 ); 332 } 333 334 // console.debug("SEARCH: searching for:"); 335 // console.info("required: ", [...searchTerms]); 336 // console.info("excluded: ", [...excludedTerms]); 337 338 return [query, searchTerms, excludedTerms, highlightTerms, objectTerms]; 339 }, 340 341 /** 342 * execute search (requires search index to be loaded) 343 */ 344 _performSearch: ( 345 query, 346 searchTerms, 347 excludedTerms, 348 highlightTerms, 349 objectTerms, 350 ) => { 351 const filenames = Search._index.filenames; 352 const docNames = Search._index.docnames; 353 const titles = Search._index.titles; 354 const allTitles = Search._index.alltitles; 355 const indexEntries = Search._index.indexentries; 356 357 // Collect multiple result groups to be sorted separately and then ordered. 358 // Each is an array of [docname, title, anchor, descr, score, filename, kind]. 359 const normalResults = []; 360 const nonMainIndexResults = []; 361 362 _removeChildren(document.getElementById("search-progress")); 363 364 const queryLower = query.toLowerCase().trim(); 365 for (const [title, foundTitles] of Object.entries(allTitles)) { 366 if ( 367 title.toLowerCase().trim().includes(queryLower) 368 && queryLower.length >= title.length / 2 369 ) { 370 for (const [file, id] of foundTitles) { 371 const score = Math.round( 372 (Scorer.title * queryLower.length) / title.length, 373 ); 374 const boost = titles[file] === title ? 1 : 0; // add a boost for document titles 375 normalResults.push([ 376 docNames[file], 377 titles[file] !== title ? `${titles[file]} > ${title}` : title, 378 id !== null ? "#" + id : "", 379 null, 380 score + boost, 381 filenames[file], 382 SearchResultKind.title, 383 ]); 384 } 385 } 386 } 387 388 // search for explicit entries in index directives 389 for (const [entry, foundEntries] of Object.entries(indexEntries)) { 390 if (entry.includes(queryLower) && queryLower.length >= entry.length / 2) { 391 for (const [file, id, isMain] of foundEntries) { 392 const score = Math.round((100 * queryLower.length) / entry.length); 393 const result = [ 394 docNames[file], 395 titles[file], 396 id ? "#" + id : "", 397 null, 398 score, 399 filenames[file], 400 SearchResultKind.index, 401 ]; 402 if (isMain) { 403 normalResults.push(result); 404 } else { 405 nonMainIndexResults.push(result); 406 } 407 } 408 } 409 } 410 411 // lookup as object 412 objectTerms.forEach((term) => 413 normalResults.push(...Search.performObjectSearch(term, objectTerms)), 414 ); 415 416 // lookup as search terms in fulltext 417 normalResults.push( 418 ...Search.performTermsSearch(searchTerms, excludedTerms), 419 ); 420 421 // let the scorer override scores with a custom scoring function 422 if (Scorer.score) { 423 normalResults.forEach((item) => (item[4] = Scorer.score(item))); 424 nonMainIndexResults.forEach((item) => (item[4] = Scorer.score(item))); 425 } 426 427 // Sort each group of results by score and then alphabetically by name. 428 normalResults.sort(_orderResultsByScoreThenName); 429 nonMainIndexResults.sort(_orderResultsByScoreThenName); 430 431 // Combine the result groups in (reverse) order. 432 // Non-main index entries are typically arbitrary cross-references, 433 // so display them after other results. 434 let results = [...nonMainIndexResults, ...normalResults]; 435 436 // remove duplicate search results 437 // note the reversing of results, so that in the case of duplicates, the highest-scoring entry is kept 438 let seen = new Set(); 439 results = results.reverse().reduce((acc, result) => { 440 let resultStr = result 441 .slice(0, 4) 442 .concat([result[5]]) 443 .map((v) => String(v)) 444 .join(","); 445 if (!seen.has(resultStr)) { 446 acc.push(result); 447 seen.add(resultStr); 448 } 449 return acc; 450 }, []); 451 452 return results.reverse(); 453 }, 454 455 query: (query) => { 456 const [ 457 searchQuery, 458 searchTerms, 459 excludedTerms, 460 highlightTerms, 461 objectTerms, 462 ] = Search._parseQuery(query); 463 const results = Search._performSearch( 464 searchQuery, 465 searchTerms, 466 excludedTerms, 467 highlightTerms, 468 objectTerms, 469 ); 470 471 // for debugging 472 //Search.lastresults = results.slice(); // a copy 473 // console.info("search results:", Search.lastresults); 474 475 // print the results 476 _displayNextItem(results, results.length, searchTerms, highlightTerms); 477 }, 478 479 /** 480 * search for object names 481 */ 482 performObjectSearch: (object, objectTerms) => { 483 const filenames = Search._index.filenames; 484 const docNames = Search._index.docnames; 485 const objects = Search._index.objects; 486 const objNames = Search._index.objnames; 487 const titles = Search._index.titles; 488 489 const results = []; 490 491 const objectSearchCallback = (prefix, match) => { 492 const name = match[4]; 493 const fullname = (prefix ? prefix + "." : "") + name; 494 const fullnameLower = fullname.toLowerCase(); 495 if (fullnameLower.indexOf(object) < 0) return; 496 497 let score = 0; 498 const parts = fullnameLower.split("."); 499 500 // check for different match types: exact matches of full name or
501 // "last name" (i.e. last dotted part) 502 if (fullnameLower === object || parts.slice(-1)[0] === object) 503 score += Scorer.objNameMatch; 504 else if (parts.slice(-1)[0].indexOf(object) > -1) 505 score += Scorer.objPartialMatch; // matches in last name 506 507 const objName = objNames[match[1]][2]; 508 const title = titles[match[0]]; 509 510 // If more than one term searched for, we require other words to be 511 // found in the name/title/description 512 const otherTerms = new Set(objectTerms); 513 otherTerms.delete(object); 514 if (otherTerms.size > 0) { 515 const haystack = `${prefix} ${name} ${objName} ${title}`.toLowerCase(); 516 if ( 517 [...otherTerms].some((otherTerm) => haystack.indexOf(otherTerm) < 0) 518 ) 519 return; 520 } 521 522 let anchor = match[3]; 523 if (anchor === "") anchor = fullname; 524 else if (anchor === "-") anchor = objNames[match[1]][1] + "-" + fullname; 525 526 const descr = objName + _(", in ") + title; 527 528 // add custom score for some objects according to scorer 529 if (Scorer.objPrio.hasOwnProperty(match[2])) 530 score += Scorer.objPrio[match[2]]; 531 else score += Scorer.objPrioDefault; 532 533 results.push([ 534 docNames[match[0]], 535 fullname, 536 "#" + anchor, 537 descr, 538 score, 539 filenames[match[0]], 540 SearchResultKind.object, 541 ]); 542 }; 543 Object.keys(objects).forEach((prefix) => 544 objects[prefix].forEach((array) => objectSearchCallback(prefix, array)), 545 ); 546 return results; 547 }, 548 549 /** 550 * search for full-text terms in the index 551 */ 552 performTermsSearch: (searchTerms, excludedTerms) => { 553 // prepare search 554 const terms = Search._index.terms; 555 const titleTerms = Search._index.titleterms; 556 const filenames = Search._index.filenames; 557 const docNames = Search._index.docnames; 558 const titles = Search._index.titles; 559 560 const scoreMap = new Map(); 561 const fileMap = new Map(); 562 563 // perform the search on the required terms 564 searchTerms.forEach((word) => { 565 const files = []; 566 // find documents, if any, containing the query word in their text/title term indices 567 // use Object.hasOwnProperty to avoid mismatching against prototype properties 568 const arr = [ 569 { 570 files: terms.hasOwnProperty(word) ? terms[word] : undefined, 571 score: Scorer.term, 572 }, 573 { 574 files: titleTerms.hasOwnProperty(word) ? titleTerms[word] : undefined, 575 score: Scorer.title, 576 }, 577 ]; 578 // add support for partial matches 579 if (word.length > 2) { 580 const escapedWord = _escapeRegExp(word); 581 if (!terms.hasOwnProperty(word)) { 582 Object.keys(terms).forEach((term) => { 583 if (term.match(escapedWord)) 584 arr.push({ files: terms[term], score: Scorer.partialTerm }); 585 }); 586 } 587 if (!titleTerms.hasOwnProperty(word)) { 588 Object.keys(titleTerms).forEach((term) => { 589 if (term.match(escapedWord)) 590 arr.push({ files: titleTerms[term], score: Scorer.partialTitle }); 591 }); 592 } 593 } 594 595 // no match but word was a required one 596 if (arr.every((record) => record.files === undefined)) return; 597 598 // found search word in contents 599 arr.forEach((record) => { 600 if (record.files === undefined) return; 601 602 let recordFiles = record.files; 603 if (recordFiles.length === undefined) recordFiles = [recordFiles]; 604 files.push(...recordFiles); 605 606 // set score for the word in each file 607 recordFiles.forEach((file) => { 608 if (!scoreMap.has(file)) scoreMap.set(file, new Map()); 609 const fileScores = scoreMap.get(file); 610 fileScores.set(word, record.score); 611 }); 612 }); 613 614 // create the mapping 615 files.forEach((file) => { 616 if (!fileMap.has(file)) fileMap.set(file, [word]); 617 else if (fileMap.get(file).indexOf(word) === -1) 618 fileMap.get(file).push(word); 619 }); 620 }); 621 622 // now check if the files don't contain excluded terms 623 const results = []; 624 for (const [file, wordList] of fileMap) { 625 // check if all requirements are matched 626 627 // as search terms with length < 3 are discarded 628 const filteredTermCount = [...searchTerms].filter( 629 (term) => term.length > 2, 630 ).length; 631 if ( 632 wordList.length !== searchTerms.size 633 && wordList.length !== filteredTermCount 634 ) 635 continue; 636 637 // ensure that none of the excluded terms is in the search result 638 if ( 639 [...excludedTerms].some( 640 (term) => 641 terms[term] === file 642 || titleTerms[term] === file 643 || (terms[term] || []).includes(file) 644 || (titleTerms[term] || []).includes(file), 645 ) 646 ) 647 break; 648 649 // select one (max) score for the file. 650 const score = Math.max(...wordList.map((w) => scoreMap.get(file).get(w))); 651 // add result to the result list 652 results.push([ 653 docNames[file], 654 titles[file], 655 "", 656 null, 657 score, 658 filenames[file], 659 SearchResultKind.text, 660 ]); 661 } 662 return results; 663 }, 664 665 /**
666 * helper function to return a node containing the 667 * search summary for a given text. keywords is a list 668 * of stemmed words. 669 */ 670 makeSearchSummary: (htmlText, keywords, anchor) => { 671 const text = Search.htmlToText(htmlText, anchor); 672 if (text === "") return null; 673 674 const textLower = text.toLowerCase(); 675 const actualStartPosition = [...keywords] 676 .map((k) => textLower.indexOf(k.toLowerCase())) 677 .filter((i) => i > -1) 678 .slice(-1)[0]; 679 const startWithContext = Math.max(actualStartPosition - 120, 0); 680 681 const top = startWithContext === 0 ? "" : "..."; 682 const tail = startWithContext + 240 < text.length ? "..." : ""; 683 684 let summary = document.createElement("p"); 685 summary.classList.add("context"); 686 summary.textContent = 687 top + text.substr(startWithContext, 240).trim() + tail; 688 689 return summary; 690 }, 691}; 692 693_ready(Search.init);
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.