searchtools.js 21 KB

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