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