searchtools.js 18 KB

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