Spaces:
Sleeping
Sleeping
/* | |
* searchtools.js | |
* ~~~~~~~~~~~~~~~~ | |
* | |
* Sphinx JavaScript utilities for the full-text search. | |
* | |
* :copyright: Copyright 2007-2023 by the Sphinx team, see AUTHORS. | |
* :license: BSD, see LICENSE for details. | |
* | |
*/ | |
; | |
/** | |
* Simple result scoring code. | |
*/ | |
if (typeof Scorer === "undefined") { | |
var Scorer = { | |
// Implement the following function to further tweak the score for each result | |
// The function takes a result array [docname, title, anchor, descr, score, filename] | |
// and returns the new score. | |
/* | |
score: result => { | |
const [docname, title, anchor, descr, score, filename] = result | |
return score | |
}, | |
*/ | |
// query matches the full name of an object | |
objNameMatch: 11, | |
// or matches in the last dotted part of the object name | |
objPartialMatch: 6, | |
// Additive scores depending on the priority of the object | |
objPrio: { | |
0: 15, // used to be importantResults | |
1: 5, // used to be objectResults | |
2: -5, // used to be unimportantResults | |
}, | |
// Used when the priority is not in the mapping. | |
objPrioDefault: 0, | |
// query found in title | |
title: 15, | |
partialTitle: 7, | |
// query found in terms | |
term: 5, | |
partialTerm: 2, | |
}; | |
} | |
const _removeChildren = (element) => { | |
while (element && element.lastChild) element.removeChild(element.lastChild); | |
}; | |
/** | |
* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping | |
*/ | |
const _escapeRegExp = (string) => | |
string.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string | |
const _displayItem = (item, searchTerms) => { | |
const docBuilder = DOCUMENTATION_OPTIONS.BUILDER; | |
const docUrlRoot = DOCUMENTATION_OPTIONS.URL_ROOT; | |
const docFileSuffix = DOCUMENTATION_OPTIONS.FILE_SUFFIX; | |
const docLinkSuffix = DOCUMENTATION_OPTIONS.LINK_SUFFIX; | |
const showSearchSummary = DOCUMENTATION_OPTIONS.SHOW_SEARCH_SUMMARY; | |
const [docName, title, anchor, descr, score, _filename] = item; | |
let listItem = document.createElement("li"); | |
let requestUrl; | |
let linkUrl; | |
if (docBuilder === "dirhtml") { | |
// dirhtml builder | |
let dirname = docName + "/"; | |
if (dirname.match(/\/index\/$/)) | |
dirname = dirname.substring(0, dirname.length - 6); | |
else if (dirname === "index/") dirname = ""; | |
requestUrl = docUrlRoot + dirname; | |
linkUrl = requestUrl; | |
} else { | |
// normal html builders | |
requestUrl = docUrlRoot + docName + docFileSuffix; | |
linkUrl = docName + docLinkSuffix; | |
} | |
let linkEl = listItem.appendChild(document.createElement("a")); | |
linkEl.href = linkUrl + anchor; | |
linkEl.dataset.score = score; | |
linkEl.innerHTML = title; | |
if (descr) | |
listItem.appendChild(document.createElement("span")).innerHTML = | |
" (" + descr + ")"; | |
else if (showSearchSummary) | |
fetch(requestUrl) | |
.then((responseData) => responseData.text()) | |
.then((data) => { | |
if (data) | |
listItem.appendChild( | |
Search.makeSearchSummary(data, searchTerms) | |
); | |
}); | |
Search.output.appendChild(listItem); | |
}; | |
const _finishSearch = (resultCount) => { | |
Search.stopPulse(); | |
Search.title.innerText = _("Search Results"); | |
if (!resultCount) | |
Search.status.innerText = Documentation.gettext( | |
"Your search did not match any documents. Please make sure that all words are spelled correctly and that you've selected enough categories." | |
); | |
else | |
Search.status.innerText = _( | |
`Search finished, found ${resultCount} page(s) matching the search query.` | |
); | |
}; | |
const _displayNextItem = ( | |
results, | |
resultCount, | |
searchTerms | |
) => { | |
// results left, load the summary and display it | |
// this is intended to be dynamic (don't sub resultsCount) | |
if (results.length) { | |
_displayItem(results.pop(), searchTerms); | |
setTimeout( | |
() => _displayNextItem(results, resultCount, searchTerms), | |
5 | |
); | |
} | |
// search finished, update title and status message | |
else _finishSearch(resultCount); | |
}; | |
/** | |
* Default splitQuery function. Can be overridden in ``sphinx.search`` with a | |
* custom function per language. | |
* | |
* The regular expression works by splitting the string on consecutive characters | |
* that are not Unicode letters, numbers, underscores, or emoji characters. | |
* This is the same as ``\W+`` in Python, preserving the surrogate pair area. | |
*/ | |
if (typeof splitQuery === "undefined") { | |
var splitQuery = (query) => query | |
.split(/[^\p{Letter}\p{Number}_\p{Emoji_Presentation}]+/gu) | |
.filter(term => term) // remove remaining empty strings | |
} | |
/** | |
* Search Module | |
*/ | |
const Search = { | |
_index: null, | |
_queued_query: null, | |
_pulse_status: -1, | |
htmlToText: (htmlString) => { | |
const htmlElement = new DOMParser().parseFromString(htmlString, 'text/html'); | |
htmlElement.querySelectorAll(".headerlink").forEach((el) => { el.remove() }); | |
const docContent = htmlElement.querySelector('[role="main"]'); | |
if (docContent !== undefined) return docContent.textContent; | |
console.warn( | |
"Content block not found. Sphinx search tries to obtain it via '[role=main]'. Could you check your theme or template." | |
); | |
return ""; | |
}, | |
init: () => { | |
const query = new URLSearchParams(window.location.search).get("q"); | |
document | |
.querySelectorAll('input[name="q"]') | |
.forEach((el) => (el.value = query)); | |
if (query) Search.performSearch(query); | |
}, | |
loadIndex: (url) => | |
(document.body.appendChild(document.createElement("script")).src = url), | |
setIndex: (index) => { | |
Search._index = index; | |
if (Search._queued_query !== null) { | |
const query = Search._queued_query; | |
Search._queued_query = null; | |
Search.query(query); | |
} | |
}, | |
hasIndex: () => Search._index !== null, | |
deferQuery: (query) => (Search._queued_query = query), | |
stopPulse: () => (Search._pulse_status = -1), | |
startPulse: () => { | |
if (Search._pulse_status >= 0) return; | |
const pulse = () => { | |
Search._pulse_status = (Search._pulse_status + 1) % 4; | |
Search.dots.innerText = ".".repeat(Search._pulse_status); | |
if (Search._pulse_status >= 0) window.setTimeout(pulse, 500); | |
}; | |
pulse(); | |
}, | |
/** | |
* perform a search for something (or wait until index is loaded) | |
*/ | |
performSearch: (query) => { | |
// create the required interface elements | |
const searchText = document.createElement("h2"); | |
searchText.textContent = _("Searching"); | |
const searchSummary = document.createElement("p"); | |
searchSummary.classList.add("search-summary"); | |
searchSummary.innerText = ""; | |
const searchList = document.createElement("ul"); | |
searchList.classList.add("search"); | |
const out = document.getElementById("search-results"); | |
Search.title = out.appendChild(searchText); | |
Search.dots = Search.title.appendChild(document.createElement("span")); | |
Search.status = out.appendChild(searchSummary); | |
Search.output = out.appendChild(searchList); | |
const searchProgress = document.getElementById("search-progress"); | |
// Some themes don't use the search progress node | |
if (searchProgress) { | |
searchProgress.innerText = _("Preparing search..."); | |
} | |
Search.startPulse(); | |
// index already loaded, the browser was quick! | |
if (Search.hasIndex()) Search.query(query); | |
else Search.deferQuery(query); | |
}, | |
/** | |
* execute search (requires search index to be loaded) | |
*/ | |
query: (query) => { | |
const filenames = Search._index.filenames; | |
const docNames = Search._index.docnames; | |
const titles = Search._index.titles; | |
const allTitles = Search._index.alltitles; | |
const indexEntries = Search._index.indexentries; | |
// stem the search terms and add them to the correct list | |
const stemmer = new Stemmer(); | |
const searchTerms = new Set(); | |
const excludedTerms = new Set(); | |
const highlightTerms = new Set(); | |
const objectTerms = new Set(splitQuery(query.toLowerCase().trim())); | |
splitQuery(query.trim()).forEach((queryTerm) => { | |
const queryTermLower = queryTerm.toLowerCase(); | |
// maybe skip this "word" | |
// stopwords array is from language_data.js | |
if ( | |
stopwords.indexOf(queryTermLower) !== -1 || | |
queryTerm.match(/^\d+$/) | |
) | |
return; | |
// stem the word | |
let word = stemmer.stemWord(queryTermLower); | |
// select the correct list | |
if (word[0] === "-") excludedTerms.add(word.substr(1)); | |
else { | |
searchTerms.add(word); | |
highlightTerms.add(queryTermLower); | |
} | |
}); | |
if (SPHINX_HIGHLIGHT_ENABLED) { // set in sphinx_highlight.js | |
localStorage.setItem("sphinx_highlight_terms", [...highlightTerms].join(" ")) | |
} | |
// console.debug("SEARCH: searching for:"); | |
// console.info("required: ", [...searchTerms]); | |
// console.info("excluded: ", [...excludedTerms]); | |
// array of [docname, title, anchor, descr, score, filename] | |
let results = []; | |
_removeChildren(document.getElementById("search-progress")); | |
const queryLower = query.toLowerCase(); | |
for (const [title, foundTitles] of Object.entries(allTitles)) { | |
if (title.toLowerCase().includes(queryLower) && (queryLower.length >= title.length/2)) { | |
for (const [file, id] of foundTitles) { | |
let score = Math.round(100 * queryLower.length / title.length) | |
results.push([ | |
docNames[file], | |
titles[file] !== title ? `${titles[file]} > ${title}` : title, | |
id !== null ? "#" + id : "", | |
null, | |
score, | |
filenames[file], | |
]); | |
} | |
} | |
} | |
// search for explicit entries in index directives | |
for (const [entry, foundEntries] of Object.entries(indexEntries)) { | |
if (entry.includes(queryLower) && (queryLower.length >= entry.length/2)) { | |
for (const [file, id] of foundEntries) { | |
let score = Math.round(100 * queryLower.length / entry.length) | |
results.push([ | |
docNames[file], | |
titles[file], | |
id ? "#" + id : "", | |
null, | |
score, | |
filenames[file], | |
]); | |
} | |
} | |
} | |
// lookup as object | |
objectTerms.forEach((term) => | |
results.push(...Search.performObjectSearch(term, objectTerms)) | |
); | |
// lookup as search terms in fulltext | |
results.push(...Search.performTermsSearch(searchTerms, excludedTerms)); | |
// let the scorer override scores with a custom scoring function | |
if (Scorer.score) results.forEach((item) => (item[4] = Scorer.score(item))); | |
// now sort the results by score (in opposite order of appearance, since the | |
// display function below uses pop() to retrieve items) and then | |
// alphabetically | |
results.sort((a, b) => { | |
const leftScore = a[4]; | |
const rightScore = b[4]; | |
if (leftScore === rightScore) { | |
// same score: sort alphabetically | |
const leftTitle = a[1].toLowerCase(); | |
const rightTitle = b[1].toLowerCase(); | |
if (leftTitle === rightTitle) return 0; | |
return leftTitle > rightTitle ? -1 : 1; // inverted is intentional | |
} | |
return leftScore > rightScore ? 1 : -1; | |
}); | |
// remove duplicate search results | |
// note the reversing of results, so that in the case of duplicates, the highest-scoring entry is kept | |
let seen = new Set(); | |
results = results.reverse().reduce((acc, result) => { | |
let resultStr = result.slice(0, 4).concat([result[5]]).map(v => String(v)).join(','); | |
if (!seen.has(resultStr)) { | |
acc.push(result); | |
seen.add(resultStr); | |
} | |
return acc; | |
}, []); | |
results = results.reverse(); | |
// for debugging | |
//Search.lastresults = results.slice(); // a copy | |
// console.info("search results:", Search.lastresults); | |
// print the results | |
_displayNextItem(results, results.length, searchTerms); | |
}, | |
/** | |
* search for object names | |
*/ | |
performObjectSearch: (object, objectTerms) => { | |
const filenames = Search._index.filenames; | |
const docNames = Search._index.docnames; | |
const objects = Search._index.objects; | |
const objNames = Search._index.objnames; | |
const titles = Search._index.titles; | |
const results = []; | |
const objectSearchCallback = (prefix, match) => { | |
const name = match[4] | |
const fullname = (prefix ? prefix + "." : "") + name; | |
const fullnameLower = fullname.toLowerCase(); | |
if (fullnameLower.indexOf(object) < 0) return; | |
let score = 0; | |
const parts = fullnameLower.split("."); | |
// check for different match types: exact matches of full name or | |
// "last name" (i.e. last dotted part) | |
if (fullnameLower === object || parts.slice(-1)[0] === object) | |
score += Scorer.objNameMatch; | |
else if (parts.slice(-1)[0].indexOf(object) > -1) | |
score += Scorer.objPartialMatch; // matches in last name | |
const objName = objNames[match[1]][2]; | |
const title = titles[match[0]]; | |
// If more than one term searched for, we require other words to be | |
// found in the name/title/description | |
const otherTerms = new Set(objectTerms); | |
otherTerms.delete(object); | |
if (otherTerms.size > 0) { | |
const haystack = `${prefix} ${name} ${objName} ${title}`.toLowerCase(); | |
if ( | |
[...otherTerms].some((otherTerm) => haystack.indexOf(otherTerm) < 0) | |
) | |
return; | |
} | |
let anchor = match[3]; | |
if (anchor === "") anchor = fullname; | |
else if (anchor === "-") anchor = objNames[match[1]][1] + "-" + fullname; | |
const descr = objName + _(", in ") + title; | |
// add custom score for some objects according to scorer | |
if (Scorer.objPrio.hasOwnProperty(match[2])) | |
score += Scorer.objPrio[match[2]]; | |
else score += Scorer.objPrioDefault; | |
results.push([ | |
docNames[match[0]], | |
fullname, | |
"#" + anchor, | |
descr, | |
score, | |
filenames[match[0]], | |
]); | |
}; | |
Object.keys(objects).forEach((prefix) => | |
objects[prefix].forEach((array) => | |
objectSearchCallback(prefix, array) | |
) | |
); | |
return results; | |
}, | |
/** | |
* search for full-text terms in the index | |
*/ | |
performTermsSearch: (searchTerms, excludedTerms) => { | |
// prepare search | |
const terms = Search._index.terms; | |
const titleTerms = Search._index.titleterms; | |
const filenames = Search._index.filenames; | |
const docNames = Search._index.docnames; | |
const titles = Search._index.titles; | |
const scoreMap = new Map(); | |
const fileMap = new Map(); | |
// perform the search on the required terms | |
searchTerms.forEach((word) => { | |
const files = []; | |
const arr = [ | |
{ files: terms[word], score: Scorer.term }, | |
{ files: titleTerms[word], score: Scorer.title }, | |
]; | |
// add support for partial matches | |
if (word.length > 2) { | |
const escapedWord = _escapeRegExp(word); | |
Object.keys(terms).forEach((term) => { | |
if (term.match(escapedWord) && !terms[word]) | |
arr.push({ files: terms[term], score: Scorer.partialTerm }); | |
}); | |
Object.keys(titleTerms).forEach((term) => { | |
if (term.match(escapedWord) && !titleTerms[word]) | |
arr.push({ files: titleTerms[word], score: Scorer.partialTitle }); | |
}); | |
} | |
// no match but word was a required one | |
if (arr.every((record) => record.files === undefined)) return; | |
// found search word in contents | |
arr.forEach((record) => { | |
if (record.files === undefined) return; | |
let recordFiles = record.files; | |
if (recordFiles.length === undefined) recordFiles = [recordFiles]; | |
files.push(...recordFiles); | |
// set score for the word in each file | |
recordFiles.forEach((file) => { | |
if (!scoreMap.has(file)) scoreMap.set(file, {}); | |
scoreMap.get(file)[word] = record.score; | |
}); | |
}); | |
// create the mapping | |
files.forEach((file) => { | |
if (fileMap.has(file) && fileMap.get(file).indexOf(word) === -1) | |
fileMap.get(file).push(word); | |
else fileMap.set(file, [word]); | |
}); | |
}); | |
// now check if the files don't contain excluded terms | |
const results = []; | |
for (const [file, wordList] of fileMap) { | |
// check if all requirements are matched | |
// as search terms with length < 3 are discarded | |
const filteredTermCount = [...searchTerms].filter( | |
(term) => term.length > 2 | |
).length; | |
if ( | |
wordList.length !== searchTerms.size && | |
wordList.length !== filteredTermCount | |
) | |
continue; | |
// ensure that none of the excluded terms is in the search result | |
if ( | |
[...excludedTerms].some( | |
(term) => | |
terms[term] === file || | |
titleTerms[term] === file || | |
(terms[term] || []).includes(file) || | |
(titleTerms[term] || []).includes(file) | |
) | |
) | |
break; | |
// select one (max) score for the file. | |
const score = Math.max(...wordList.map((w) => scoreMap.get(file)[w])); | |
// add result to the result list | |
results.push([ | |
docNames[file], | |
titles[file], | |
"", | |
null, | |
score, | |
filenames[file], | |
]); | |
} | |
return results; | |
}, | |
/** | |
* helper function to return a node containing the | |
* search summary for a given text. keywords is a list | |
* of stemmed words. | |
*/ | |
makeSearchSummary: (htmlText, keywords) => { | |
const text = Search.htmlToText(htmlText); | |
if (text === "") return null; | |
const textLower = text.toLowerCase(); | |
const actualStartPosition = [...keywords] | |
.map((k) => textLower.indexOf(k.toLowerCase())) | |
.filter((i) => i > -1) | |
.slice(-1)[0]; | |
const startWithContext = Math.max(actualStartPosition - 120, 0); | |
const top = startWithContext === 0 ? "" : "..."; | |
const tail = startWithContext + 240 < text.length ? "..." : ""; | |
let summary = document.createElement("p"); | |
summary.classList.add("context"); | |
summary.textContent = top + text.substr(startWithContext, 240).trim() + tail; | |
return summary; | |
}, | |
}; | |
_ready(Search.init); | |