Path: blob/main/docs/_static/searchtools.js
469 views
/*1* searchtools.js2* ~~~~~~~~~~~~~~~~3*4* Sphinx JavaScript utilities for the full-text search.5*6* :copyright: Copyright 2007-2024 by the Sphinx team, see AUTHORS.7* :license: BSD, see LICENSE for details.8*9*/10"use strict";11/**12* Simple result scoring code.13*/14if (typeof Scorer === "undefined") {15var Scorer = {16// Implement the following function to further tweak the score for each result17// The function takes a result array [docname, title, anchor, descr, score, filename]18// and returns the new score.19/*20score: result => {21const [docname, title, anchor, descr, score, filename] = result22return score23},24*/25// query matches the full name of an object26objNameMatch: 11,27// or matches in the last dotted part of the object name28objPartialMatch: 6,29// Additive scores depending on the priority of the object30objPrio: {310: 15, // used to be importantResults321: 5, // used to be objectResults332: -5, // used to be unimportantResults34},35// Used when the priority is not in the mapping.36objPrioDefault: 0,37// query found in title38title: 15,39partialTitle: 7,40// query found in terms41term: 5,42partialTerm: 2,43};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] = item;60let listItem = document.createElement("li");61let requestUrl;62let linkUrl;63if (docBuilder === "dirhtml") {64// dirhtml builder65let dirname = docName + "/";66if (dirname.match(/\/index\/$/))67dirname = dirname.substring(0, dirname.length - 6);68else if (dirname === "index/") dirname = "";69requestUrl = contentRoot + dirname;70linkUrl = requestUrl;71} else {72// normal html builders73requestUrl = contentRoot + docName + docFileSuffix;74linkUrl = docName + docLinkSuffix;75}76let linkEl = listItem.appendChild(document.createElement("a"));77linkEl.href = linkUrl + anchor;78linkEl.dataset.score = score;79linkEl.innerHTML = title;80if (descr) {81listItem.appendChild(document.createElement("span")).innerHTML =82" (" + descr + ")";83// highlight search terms in the description84if (SPHINX_HIGHLIGHT_ENABLED) // set in sphinx_highlight.js85highlightTerms.forEach((term) => _highlightText(listItem, term, "highlighted"));86}87else if (showSearchSummary)88fetch(requestUrl)89.then((responseData) => responseData.text())90.then((data) => {91if (data)92listItem.appendChild(93Search.makeSearchSummary(data, searchTerms, anchor)94);95// highlight search terms in the summary96if (SPHINX_HIGHLIGHT_ENABLED) // set in sphinx_highlight.js97highlightTerms.forEach((term) => _highlightText(listItem, term, "highlighted"));98});99Search.output.appendChild(listItem);100};101const _finishSearch = (resultCount) => {102Search.stopPulse();103Search.title.innerText = _("Search Results");104if (!resultCount)105Search.status.innerText = Documentation.gettext(106"Your search did not match any documents. Please make sure that all words are spelled correctly and that you've selected enough categories."107);108else109Search.status.innerText = _(110"Search finished, found ${resultCount} page(s) matching the search query."111).replace('${resultCount}', resultCount);112};113const _displayNextItem = (114results,115resultCount,116searchTerms,117highlightTerms,118) => {119// results left, load the summary and display it120// this is intended to be dynamic (don't sub resultsCount)121if (results.length) {122_displayItem(results.pop(), searchTerms, highlightTerms);123setTimeout(124() => _displayNextItem(results, resultCount, searchTerms, highlightTerms),1255126);127}128// search finished, update title and status message129else _finishSearch(resultCount);130};131// Helper function used by query() to order search results.132// Each input is an array of [docname, title, anchor, descr, score, filename].133// Order the results by score (in opposite order of appearance, since the134// `_displayNextItem` function uses pop() to retrieve items) and then alphabetically.135const _orderResultsByScoreThenName = (a, b) => {136const leftScore = a[4];137const rightScore = b[4];138if (leftScore === rightScore) {139// same score: sort alphabetically140const leftTitle = a[1].toLowerCase();141const rightTitle = b[1].toLowerCase();142if (leftTitle === rightTitle) return 0;143return leftTitle > rightTitle ? -1 : 1; // inverted is intentional144}145return leftScore > rightScore ? 1 : -1;146};147/**148* Default splitQuery function. Can be overridden in ``sphinx.search`` with a149* custom function per language.150*151* The regular expression works by splitting the string on consecutive characters152* that are not Unicode letters, numbers, underscores, or emoji characters.153* This is the same as ``\W+`` in Python, preserving the surrogate pair area.154*/155if (typeof splitQuery === "undefined") {156var splitQuery = (query) => query157.split(/[^\p{Letter}\p{Number}_\p{Emoji_Presentation}]+/gu)158.filter(term => term) // remove remaining empty strings159}160/**161* Search Module162*/163const Search = {164_index: null,165_queued_query: null,166_pulse_status: -1,167htmlToText: (htmlString, anchor) => {168const htmlElement = new DOMParser().parseFromString(htmlString, 'text/html');169for (const removalQuery of [".headerlink", "script", "style"]) {170htmlElement.querySelectorAll(removalQuery).forEach((el) => { el.remove() });171}172if (anchor) {173const anchorContent = htmlElement.querySelector(`[role="main"] ${anchor}`);174if (anchorContent) return anchorContent.textContent;175console.warn(176`Anchored content block not found. Sphinx search tries to obtain it via DOM query '[role=main] ${anchor}'. Check your theme or template.`177);178}179// if anchor not specified or not found, fall back to main content180const docContent = htmlElement.querySelector('[role="main"]');181if (docContent) return docContent.textContent;182console.warn(183"Content block not found. Sphinx search tries to obtain it via DOM query '[role=main]'. Check your theme or template."184);185return "";186},187init: () => {188const query = new URLSearchParams(window.location.search).get("q");189document190.querySelectorAll('input[name="q"]')191.forEach((el) => (el.value = query));192if (query) Search.performSearch(query);193},194loadIndex: (url) =>195(document.body.appendChild(document.createElement("script")).src = url),196setIndex: (index) => {197Search._index = index;198if (Search._queued_query !== null) {199const query = Search._queued_query;200Search._queued_query = null;201Search.query(query);202}203},204hasIndex: () => Search._index !== null,205deferQuery: (query) => (Search._queued_query = query),206stopPulse: () => (Search._pulse_status = -1),207startPulse: () => {208if (Search._pulse_status >= 0) return;209const pulse = () => {210Search._pulse_status = (Search._pulse_status + 1) % 4;211Search.dots.innerText = ".".repeat(Search._pulse_status);212if (Search._pulse_status >= 0) window.setTimeout(pulse, 500);213};214pulse();215},216/**217* perform a search for something (or wait until index is loaded)218*/219performSearch: (query) => {220// create the required interface elements221const searchText = document.createElement("h2");222searchText.textContent = _("Searching");223const searchSummary = document.createElement("p");224searchSummary.classList.add("search-summary");225searchSummary.innerText = "";226const searchList = document.createElement("ul");227searchList.classList.add("search");228const out = document.getElementById("search-results");229Search.title = out.appendChild(searchText);230Search.dots = Search.title.appendChild(document.createElement("span"));231Search.status = out.appendChild(searchSummary);232Search.output = out.appendChild(searchList);233const searchProgress = document.getElementById("search-progress");234// Some themes don't use the search progress node235if (searchProgress) {236searchProgress.innerText = _("Preparing search...");237}238Search.startPulse();239// index already loaded, the browser was quick!240if (Search.hasIndex()) Search.query(query);241else Search.deferQuery(query);242},243_parseQuery: (query) => {244// stem the search terms and add them to the correct list245const stemmer = new Stemmer();246const searchTerms = new Set();247const excludedTerms = new Set();248const highlightTerms = new Set();249const objectTerms = new Set(splitQuery(query.toLowerCase().trim()));250splitQuery(query.trim()).forEach((queryTerm) => {251const queryTermLower = queryTerm.toLowerCase();252// maybe skip this "word"253// stopwords array is from language_data.js254if (255stopwords.indexOf(queryTermLower) !== -1 ||256queryTerm.match(/^\d+$/)257)258return;259// stem the word260let word = stemmer.stemWord(queryTermLower);261// select the correct list262if (word[0] === "-") excludedTerms.add(word.substr(1));263else {264searchTerms.add(word);265highlightTerms.add(queryTermLower);266}267});268if (SPHINX_HIGHLIGHT_ENABLED) { // set in sphinx_highlight.js269localStorage.setItem("sphinx_highlight_terms", [...highlightTerms].join(" "))270}271// console.debug("SEARCH: searching for:");272// console.info("required: ", [...searchTerms]);273// console.info("excluded: ", [...excludedTerms]);274return [query, searchTerms, excludedTerms, highlightTerms, objectTerms];275},276/**277* execute search (requires search index to be loaded)278*/279_performSearch: (query, searchTerms, excludedTerms, highlightTerms, objectTerms) => {280const filenames = Search._index.filenames;281const docNames = Search._index.docnames;282const titles = Search._index.titles;283const allTitles = Search._index.alltitles;284const indexEntries = Search._index.indexentries;285// Collect multiple result groups to be sorted separately and then ordered.286// Each is an array of [docname, title, anchor, descr, score, filename].287const normalResults = [];288const nonMainIndexResults = [];289_removeChildren(document.getElementById("search-progress"));290const queryLower = query.toLowerCase().trim();291for (const [title, foundTitles] of Object.entries(allTitles)) {292if (title.toLowerCase().trim().includes(queryLower) && (queryLower.length >= title.length/2)) {293for (const [file, id] of foundTitles) {294const score = Math.round(Scorer.title * queryLower.length / title.length);295const boost = titles[file] === title ? 1 : 0; // add a boost for document titles296normalResults.push([297docNames[file],298titles[file] !== title ? `${titles[file]} > ${title}` : title,299id !== null ? "#" + id : "",300null,301score + boost,302filenames[file],303]);304}305}306}307// search for explicit entries in index directives308for (const [entry, foundEntries] of Object.entries(indexEntries)) {309if (entry.includes(queryLower) && (queryLower.length >= entry.length/2)) {310for (const [file, id, isMain] of foundEntries) {311const score = Math.round(100 * queryLower.length / entry.length);312const result = [313docNames[file],314titles[file],315id ? "#" + id : "",316null,317score,318filenames[file],319];320if (isMain) {321normalResults.push(result);322} else {323nonMainIndexResults.push(result);324}325}326}327}328// lookup as object329objectTerms.forEach((term) =>330normalResults.push(...Search.performObjectSearch(term, objectTerms))331);332// lookup as search terms in fulltext333normalResults.push(...Search.performTermsSearch(searchTerms, excludedTerms));334// let the scorer override scores with a custom scoring function335if (Scorer.score) {336normalResults.forEach((item) => (item[4] = Scorer.score(item)));337nonMainIndexResults.forEach((item) => (item[4] = Scorer.score(item)));338}339// Sort each group of results by score and then alphabetically by name.340normalResults.sort(_orderResultsByScoreThenName);341nonMainIndexResults.sort(_orderResultsByScoreThenName);342// Combine the result groups in (reverse) order.343// Non-main index entries are typically arbitrary cross-references,344// so display them after other results.345let results = [...nonMainIndexResults, ...normalResults];346// remove duplicate search results347// note the reversing of results, so that in the case of duplicates, the highest-scoring entry is kept348let seen = new Set();349results = results.reverse().reduce((acc, result) => {350let resultStr = result.slice(0, 4).concat([result[5]]).map(v => String(v)).join(',');351if (!seen.has(resultStr)) {352acc.push(result);353seen.add(resultStr);354}355return acc;356}, []);357return results.reverse();358},359query: (query) => {360const [searchQuery, searchTerms, excludedTerms, highlightTerms, objectTerms] = Search._parseQuery(query);361const results = Search._performSearch(searchQuery, searchTerms, excludedTerms, highlightTerms, objectTerms);362// for debugging363//Search.lastresults = results.slice(); // a copy364// console.info("search results:", Search.lastresults);365// print the results366_displayNextItem(results, results.length, searchTerms, highlightTerms);367},368/**369* search for object names370*/371performObjectSearch: (object, objectTerms) => {372const filenames = Search._index.filenames;373const docNames = Search._index.docnames;374const objects = Search._index.objects;375const objNames = Search._index.objnames;376const titles = Search._index.titles;377const results = [];378const objectSearchCallback = (prefix, match) => {379const name = match[4]380const fullname = (prefix ? prefix + "." : "") + name;381const fullnameLower = fullname.toLowerCase();382if (fullnameLower.indexOf(object) < 0) return;383let score = 0;384const parts = fullnameLower.split(".");385// check for different match types: exact matches of full name or386// "last name" (i.e. last dotted part)387if (fullnameLower === object || parts.slice(-1)[0] === object)388score += Scorer.objNameMatch;389else if (parts.slice(-1)[0].indexOf(object) > -1)390score += Scorer.objPartialMatch; // matches in last name391const objName = objNames[match[1]][2];392const title = titles[match[0]];393// If more than one term searched for, we require other words to be394// found in the name/title/description395const otherTerms = new Set(objectTerms);396otherTerms.delete(object);397if (otherTerms.size > 0) {398const haystack = `${prefix} ${name} ${objName} ${title}`.toLowerCase();399if (400[...otherTerms].some((otherTerm) => haystack.indexOf(otherTerm) < 0)401)402return;403}404let anchor = match[3];405if (anchor === "") anchor = fullname;406else if (anchor === "-") anchor = objNames[match[1]][1] + "-" + fullname;407const descr = objName + _(", in ") + title;408// add custom score for some objects according to scorer409if (Scorer.objPrio.hasOwnProperty(match[2]))410score += Scorer.objPrio[match[2]];411else score += Scorer.objPrioDefault;412results.push([413docNames[match[0]],414fullname,415"#" + anchor,416descr,417score,418filenames[match[0]],419]);420};421Object.keys(objects).forEach((prefix) =>422objects[prefix].forEach((array) =>423objectSearchCallback(prefix, array)424)425);426return results;427},428/**429* search for full-text terms in the index430*/431performTermsSearch: (searchTerms, excludedTerms) => {432// prepare search433const terms = Search._index.terms;434const titleTerms = Search._index.titleterms;435const filenames = Search._index.filenames;436const docNames = Search._index.docnames;437const titles = Search._index.titles;438const scoreMap = new Map();439const fileMap = new Map();440// perform the search on the required terms441searchTerms.forEach((word) => {442const files = [];443const arr = [444{ files: terms[word], score: Scorer.term },445{ files: titleTerms[word], score: Scorer.title },446];447// add support for partial matches448if (word.length > 2) {449const escapedWord = _escapeRegExp(word);450if (!terms.hasOwnProperty(word)) {451Object.keys(terms).forEach((term) => {452if (term.match(escapedWord))453arr.push({ files: terms[term], score: Scorer.partialTerm });454});455}456if (!titleTerms.hasOwnProperty(word)) {457Object.keys(titleTerms).forEach((term) => {458if (term.match(escapedWord))459arr.push({ files: titleTerms[term], score: Scorer.partialTitle });460});461}462}463// no match but word was a required one464if (arr.every((record) => record.files === undefined)) return;465// found search word in contents466arr.forEach((record) => {467if (record.files === undefined) return;468let recordFiles = record.files;469if (recordFiles.length === undefined) recordFiles = [recordFiles];470files.push(...recordFiles);471// set score for the word in each file472recordFiles.forEach((file) => {473if (!scoreMap.has(file)) scoreMap.set(file, {});474scoreMap.get(file)[word] = record.score;475});476});477// create the mapping478files.forEach((file) => {479if (!fileMap.has(file)) fileMap.set(file, [word]);480else if (fileMap.get(file).indexOf(word) === -1) fileMap.get(file).push(word);481});482});483// now check if the files don't contain excluded terms484const results = [];485for (const [file, wordList] of fileMap) {486// check if all requirements are matched487// as search terms with length < 3 are discarded488const filteredTermCount = [...searchTerms].filter(489(term) => term.length > 2490).length;491if (492wordList.length !== searchTerms.size &&493wordList.length !== filteredTermCount494)495continue;496// ensure that none of the excluded terms is in the search result497if (498[...excludedTerms].some(499(term) =>500terms[term] === file ||501titleTerms[term] === file ||502(terms[term] || []).includes(file) ||503(titleTerms[term] || []).includes(file)504)505)506break;507// select one (max) score for the file.508const score = Math.max(...wordList.map((w) => scoreMap.get(file)[w]));509// add result to the result list510results.push([511docNames[file],512titles[file],513"",514null,515score,516filenames[file],517]);518}519return results;520},521/**522* helper function to return a node containing the523* search summary for a given text. keywords is a list524* of stemmed words.525*/526makeSearchSummary: (htmlText, keywords, anchor) => {527const text = Search.htmlToText(htmlText, anchor);528if (text === "") return null;529const textLower = text.toLowerCase();530const actualStartPosition = [...keywords]531.map((k) => textLower.indexOf(k.toLowerCase()))532.filter((i) => i > -1)533.slice(-1)[0];534const startWithContext = Math.max(actualStartPosition - 120, 0);535const top = startWithContext === 0 ? "" : "...";536const tail = startWithContext + 240 < text.length ? "..." : "";537let summary = document.createElement("p");538summary.classList.add("context");539summary.textContent = top + text.substr(startWithContext, 240).trim() + tail;540return summary;541},542};543_ready(Search.init);544545546