mirror of
https://github.com/orbbec/OrbbecSDK_ROS2.git
synced 2026-10-04 12:07:46 +08:00
docs: improve search UX
This commit is contained in:
@@ -0,0 +1,126 @@
|
|||||||
|
"""Build a section-level JSON index for custom documentation search."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from docutils import nodes
|
||||||
|
|
||||||
|
|
||||||
|
INDEX_FILENAME = "section-search-index.json"
|
||||||
|
INDEX_SCRIPT_FILENAME = "section-search-index.js"
|
||||||
|
WHITESPACE_RE = re.compile(r"\s+")
|
||||||
|
EXTENSION_DIR = Path(__file__).resolve().parent
|
||||||
|
STATIC_DIR = EXTENSION_DIR / "static"
|
||||||
|
TEMPLATES_DIR = EXTENSION_DIR / "templates"
|
||||||
|
|
||||||
|
|
||||||
|
def setup(app):
|
||||||
|
app.connect("config-inited", configure_assets)
|
||||||
|
app.connect("build-finished", write_section_search_index)
|
||||||
|
app.add_js_file("section_search.js")
|
||||||
|
return {"version": "0.1", "parallel_read_safe": True}
|
||||||
|
|
||||||
|
|
||||||
|
def configure_assets(app, config):
|
||||||
|
static_dir = str(STATIC_DIR)
|
||||||
|
templates_dir = str(TEMPLATES_DIR)
|
||||||
|
|
||||||
|
if static_dir not in config.html_static_path:
|
||||||
|
config.html_static_path.append(static_dir)
|
||||||
|
if templates_dir not in config.templates_path:
|
||||||
|
config.templates_path.append(templates_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def write_section_search_index(app, exception):
|
||||||
|
if exception is not None or app.builder.format != "html":
|
||||||
|
return
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
env = app.builder.env
|
||||||
|
|
||||||
|
for docname in sorted(env.found_docs):
|
||||||
|
if docname in {"search", "genindex"}:
|
||||||
|
continue
|
||||||
|
|
||||||
|
doctree = env.get_doctree(docname)
|
||||||
|
target_uri = app.builder.get_target_uri(docname)
|
||||||
|
page_title = normalize_text(env.titles[docname].astext()) if docname in env.titles else docname
|
||||||
|
|
||||||
|
document_text = collect_direct_text(doctree)
|
||||||
|
if document_text or page_title:
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"kind": "document",
|
||||||
|
"title": page_title,
|
||||||
|
"page_title": page_title,
|
||||||
|
"anchor": "",
|
||||||
|
"url": target_uri,
|
||||||
|
"text": document_text,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for section in doctree.findall(nodes.section):
|
||||||
|
title = get_section_title(section)
|
||||||
|
text = collect_direct_text(section)
|
||||||
|
section_ids = section.get("ids", [])
|
||||||
|
anchor = section_ids[0] if section_ids else ""
|
||||||
|
url = f"{target_uri}#{anchor}" if anchor else target_uri
|
||||||
|
|
||||||
|
if not title and not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"kind": "section",
|
||||||
|
"title": title or page_title,
|
||||||
|
"page_title": page_title,
|
||||||
|
"anchor": anchor,
|
||||||
|
"url": url,
|
||||||
|
"text": text,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"language": app.config.language or "en",
|
||||||
|
"entries": entries,
|
||||||
|
}
|
||||||
|
output_dir = Path(app.outdir) / "_static"
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
(output_dir / INDEX_FILENAME).write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(output_dir / INDEX_SCRIPT_FILENAME).write_text(
|
||||||
|
"window.SECTION_SEARCH_INDEX = "
|
||||||
|
+ json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
+ ";",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_section_title(section: nodes.section) -> str:
|
||||||
|
for child in section.children:
|
||||||
|
if isinstance(child, nodes.title):
|
||||||
|
return normalize_text(child.astext())
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def collect_direct_text(container: nodes.Element) -> str:
|
||||||
|
parts = []
|
||||||
|
for child in container.children:
|
||||||
|
if isinstance(child, (nodes.section, nodes.title)):
|
||||||
|
continue
|
||||||
|
|
||||||
|
text = normalize_text(child.astext())
|
||||||
|
if text:
|
||||||
|
parts.append(text)
|
||||||
|
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_text(value: str) -> str:
|
||||||
|
return WHITESPACE_RE.sub(" ", value).strip()
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const INDEX_PATH = "_static/section-search-index.json";
|
||||||
|
const INDEX_SCRIPT_PATH = "_static/section-search-index.js";
|
||||||
|
const SEARCH_APP_ID = "section-search-app";
|
||||||
|
const SEARCH_RESULTS_ID = "section-search-results";
|
||||||
|
const SEARCH_STATUS_ID = "section-search-status";
|
||||||
|
const HIT_CLASS = "section-search-hit";
|
||||||
|
|
||||||
|
let searchIndexPromise = null;
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
initSearchPage();
|
||||||
|
initContentHighlight();
|
||||||
|
});
|
||||||
|
|
||||||
|
function initSearchPage() {
|
||||||
|
const app = document.getElementById(SEARCH_APP_ID);
|
||||||
|
if (!app) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = getQueryParam("q").trim();
|
||||||
|
const form = app.querySelector("form");
|
||||||
|
const input = form ? form.querySelector('input[name="q"]') : null;
|
||||||
|
const resultsRoot = document.getElementById(SEARCH_RESULTS_ID);
|
||||||
|
const statusRoot = document.getElementById(SEARCH_STATUS_ID);
|
||||||
|
|
||||||
|
if (input) {
|
||||||
|
input.value = query;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
if (statusRoot) {
|
||||||
|
statusRoot.textContent = app.dataset.emptyQuery || "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusRoot) {
|
||||||
|
statusRoot.textContent = app.dataset.loading || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSearchIndex()
|
||||||
|
.then((payload) => {
|
||||||
|
const results = searchEntries(payload.entries || [], query);
|
||||||
|
renderResults(resultsRoot, statusRoot, app, query, results);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to load section search index.", error);
|
||||||
|
if (statusRoot) {
|
||||||
|
statusRoot.textContent = app.dataset.loadError || "Search index failed to load.";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initContentHighlight() {
|
||||||
|
const app = document.getElementById(SEARCH_APP_ID);
|
||||||
|
if (app) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = getQueryParam("highlight").trim();
|
||||||
|
if (!query) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.querySelector('[itemprop="articleBody"]');
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const terms = buildHighlightTerms(query);
|
||||||
|
if (!terms.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightMatches(container, terms);
|
||||||
|
scrollToClosestHit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSearchIndex() {
|
||||||
|
if (window.SECTION_SEARCH_INDEX) {
|
||||||
|
return Promise.resolve(window.SECTION_SEARCH_INDEX);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!searchIndexPromise) {
|
||||||
|
searchIndexPromise = loadSearchIndexScript().catch(() => loadSearchIndexJson());
|
||||||
|
}
|
||||||
|
return searchIndexPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSearchIndexScript() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (window.SECTION_SEARCH_INDEX) {
|
||||||
|
resolve(window.SECTION_SEARCH_INDEX);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = document.querySelector('script[data-section-search-index="true"]');
|
||||||
|
if (existing) {
|
||||||
|
existing.addEventListener("load", () => resolve(window.SECTION_SEARCH_INDEX));
|
||||||
|
existing.addEventListener("error", reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = getUrlRoot() + INDEX_SCRIPT_PATH;
|
||||||
|
script.async = true;
|
||||||
|
script.dataset.sectionSearchIndex = "true";
|
||||||
|
script.onload = () => {
|
||||||
|
if (window.SECTION_SEARCH_INDEX) {
|
||||||
|
resolve(window.SECTION_SEARCH_INDEX);
|
||||||
|
} else {
|
||||||
|
reject(new Error("Section search index script loaded without payload."));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
script.onerror = () => reject(new Error("Failed to load section search index script."));
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSearchIndexJson() {
|
||||||
|
return fetch(getUrlRoot() + INDEX_PATH).then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("HTTP " + response.status);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchEntries(entries, rawQuery) {
|
||||||
|
const query = normalizeText(rawQuery);
|
||||||
|
const tokens = buildSearchTokens(rawQuery);
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.map((entry) => scoreEntry(entry, query, tokens))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort(compareResults)
|
||||||
|
.slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreEntry(entry, query, tokens) {
|
||||||
|
const title = normalizeText((entry.title || "") + " " + (entry.page_title || ""));
|
||||||
|
const text = normalizeText(entry.text || "");
|
||||||
|
const combined = (title + "\n" + text).trim();
|
||||||
|
|
||||||
|
if (!combined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const phraseMatch = query && combined.includes(query);
|
||||||
|
const matchedTokens = tokens.filter((token) => title.includes(token) || text.includes(token));
|
||||||
|
|
||||||
|
if (!phraseMatch && tokens.length && matchedTokens.length < tokens.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!phraseMatch && !matchedTokens.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let score = entry.kind === "section" ? 20 : 5;
|
||||||
|
|
||||||
|
if (query && title.includes(query)) {
|
||||||
|
score += 160;
|
||||||
|
}
|
||||||
|
if (query && text.includes(query)) {
|
||||||
|
score += 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedTokens.forEach((token) => {
|
||||||
|
if (title.includes(token)) {
|
||||||
|
score += 45;
|
||||||
|
} else {
|
||||||
|
score += 18;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (entry.page_title && entry.title && entry.page_title !== entry.title) {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
entry,
|
||||||
|
score,
|
||||||
|
snippet: makeSnippet(entry.text || entry.page_title || entry.title || "", query, tokens),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareResults(left, right) {
|
||||||
|
if (right.score !== left.score) {
|
||||||
|
return right.score - left.score;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftTitle = ((left.entry.title || "") + " " + (left.entry.page_title || "")).toLowerCase();
|
||||||
|
const rightTitle = ((right.entry.title || "") + " " + (right.entry.page_title || "")).toLowerCase();
|
||||||
|
return leftTitle.localeCompare(rightTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResults(root, statusRoot, app, query, results) {
|
||||||
|
if (!root) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlightTerms = buildHighlightTerms(query);
|
||||||
|
root.replaceChildren();
|
||||||
|
|
||||||
|
if (!results.length) {
|
||||||
|
if (statusRoot) {
|
||||||
|
statusRoot.textContent = app.dataset.noResults || "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusRoot) {
|
||||||
|
statusRoot.textContent = formatCountMessage(app, results.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = document.createElement("ul");
|
||||||
|
list.className = "search";
|
||||||
|
list.setAttribute("role", "list");
|
||||||
|
|
||||||
|
results.forEach((result) => {
|
||||||
|
list.appendChild(renderResultItem(result, query, highlightTerms));
|
||||||
|
});
|
||||||
|
|
||||||
|
root.appendChild(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResultItem(result, query, highlightTerms) {
|
||||||
|
const item = document.createElement("li");
|
||||||
|
item.className = "kind-text";
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = buildResultUrl(result.entry.url, query);
|
||||||
|
appendHighlightedText(link, result.entry.title || result.entry.page_title || "", highlightTerms);
|
||||||
|
item.appendChild(link);
|
||||||
|
|
||||||
|
if (result.entry.page_title && result.entry.page_title !== result.entry.title) {
|
||||||
|
const context = document.createElement("span");
|
||||||
|
context.appendChild(document.createTextNode(" ("));
|
||||||
|
context.appendChild(createHighlightedFragment(result.entry.page_title, highlightTerms));
|
||||||
|
context.appendChild(document.createTextNode(")"));
|
||||||
|
item.appendChild(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.snippet) {
|
||||||
|
const summary = document.createElement("p");
|
||||||
|
summary.className = "search-snippet";
|
||||||
|
summary.appendChild(createHighlightedFragment(result.snippet, highlightTerms));
|
||||||
|
item.appendChild(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResultUrl(url, query) {
|
||||||
|
const hashIndex = url.indexOf("#");
|
||||||
|
const base = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
|
||||||
|
const hash = hashIndex >= 0 ? url.slice(hashIndex) : "";
|
||||||
|
const separator = base.includes("?") ? "&" : "?";
|
||||||
|
return base + separator + "highlight=" + encodeURIComponent(query) + hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSnippet(text, query, tokens) {
|
||||||
|
const source = collapseWhitespace(text);
|
||||||
|
if (!source) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [query].concat(tokens).filter(Boolean);
|
||||||
|
let index = -1;
|
||||||
|
|
||||||
|
for (const term of candidates) {
|
||||||
|
index = source.toLowerCase().indexOf(term.toLowerCase());
|
||||||
|
if (index >= 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index < 0) {
|
||||||
|
return source.slice(0, 180) + (source.length > 180 ? "..." : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.max(index - 70, 0);
|
||||||
|
const end = Math.min(index + 110, source.length);
|
||||||
|
const prefix = start > 0 ? "..." : "";
|
||||||
|
const suffix = end < source.length ? "..." : "";
|
||||||
|
return prefix + source.slice(start, end) + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchTokens(query) {
|
||||||
|
const normalized = normalizeText(query);
|
||||||
|
const latinTokens = normalized
|
||||||
|
.split(/[^\p{Letter}\p{Number}_]+/u)
|
||||||
|
.map((token) => token.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const cjkTokens = normalized.match(/[\u3400-\u9fff]+/gu) || [];
|
||||||
|
|
||||||
|
return Array.from(new Set(latinTokens.concat(cjkTokens)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHighlightTerms(query) {
|
||||||
|
const normalized = normalizeText(query);
|
||||||
|
const tokens = buildSearchTokens(query);
|
||||||
|
const ordered = [normalized]
|
||||||
|
.concat(tokens)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((left, right) => right.length - left.length);
|
||||||
|
|
||||||
|
return Array.from(new Set(ordered));
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightMatches(container, terms) {
|
||||||
|
const matcher = new RegExp("(" + terms.map(escapeRegExp).join("|") + ")", "giu");
|
||||||
|
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
|
||||||
|
acceptNode(node) {
|
||||||
|
const parent = node.parentElement;
|
||||||
|
if (!parent || !node.nodeValue || !node.nodeValue.trim()) {
|
||||||
|
return NodeFilter.FILTER_REJECT;
|
||||||
|
}
|
||||||
|
if (parent.closest("script, style, noscript, mark, .headerlink")) {
|
||||||
|
return NodeFilter.FILTER_REJECT;
|
||||||
|
}
|
||||||
|
return containsAny(node.nodeValue, terms) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const textNodes = [];
|
||||||
|
while (walker.nextNode()) {
|
||||||
|
textNodes.push(walker.currentNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
textNodes.forEach((node) => {
|
||||||
|
const value = node.nodeValue;
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
matcher.lastIndex = 0;
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match = null;
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
while ((match = matcher.exec(value)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
fragment.appendChild(document.createTextNode(value.slice(lastIndex, match.index)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const mark = document.createElement("mark");
|
||||||
|
mark.className = HIT_CLASS;
|
||||||
|
mark.textContent = match[0];
|
||||||
|
fragment.appendChild(mark);
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < value.length) {
|
||||||
|
fragment.appendChild(document.createTextNode(value.slice(lastIndex)));
|
||||||
|
}
|
||||||
|
|
||||||
|
node.parentNode.replaceChild(fragment, node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToClosestHit() {
|
||||||
|
const hash = decodeURIComponent(window.location.hash || "").replace(/^#/, "");
|
||||||
|
let target = null;
|
||||||
|
|
||||||
|
if (hash) {
|
||||||
|
const anchor = document.getElementById(hash);
|
||||||
|
if (anchor) {
|
||||||
|
const scope = anchor.closest("section") || anchor;
|
||||||
|
target = scope.querySelector("." + HIT_CLASS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
target = document.querySelector("." + HIT_CLASS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
target.scrollIntoView({ block: "center" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCountMessage(app, count) {
|
||||||
|
const template = count === 1 ? app.dataset.resultOne : app.dataset.resultMany;
|
||||||
|
return (template || "").replace("{count}", String(count));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQueryParam(name) {
|
||||||
|
return new URLSearchParams(window.location.search).get(name) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUrlRoot() {
|
||||||
|
return (window.DOCUMENTATION_OPTIONS && window.DOCUMENTATION_OPTIONS.URL_ROOT) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsAny(text, terms) {
|
||||||
|
const normalized = text.toLowerCase();
|
||||||
|
return terms.some((term) => normalized.includes(term.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendHighlightedText(target, text, terms) {
|
||||||
|
target.appendChild(createHighlightedFragment(text, terms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHighlightedFragment(text, terms) {
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
const source = text || "";
|
||||||
|
const filteredTerms = (terms || []).filter(Boolean);
|
||||||
|
|
||||||
|
if (!source || !filteredTerms.length) {
|
||||||
|
fragment.appendChild(document.createTextNode(source));
|
||||||
|
return fragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matcher = new RegExp("(" + filteredTerms.map(escapeRegExp).join("|") + ")", "giu");
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match = null;
|
||||||
|
|
||||||
|
while ((match = matcher.exec(source)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
fragment.appendChild(document.createTextNode(source.slice(lastIndex, match.index)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const mark = document.createElement("mark");
|
||||||
|
mark.className = "section-search-match";
|
||||||
|
mark.textContent = match[0];
|
||||||
|
fragment.appendChild(mark);
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < source.length) {
|
||||||
|
fragment.appendChild(document.createTextNode(source.slice(lastIndex)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return fragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(text) {
|
||||||
|
return collapseWhitespace(text).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseWhitespace(text) {
|
||||||
|
return (text || "").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(text) {
|
||||||
|
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{%- extends "layout.html" %}
|
||||||
|
{% set title = _('Search') %}
|
||||||
|
{% set is_zh = language and language.startswith('zh') %}
|
||||||
|
|
||||||
|
{% block extrahead %}
|
||||||
|
<script src="{{ pathto('_static/section-search-index.js', 1) }}"></script>
|
||||||
|
<meta name="robots" content="noindex" />
|
||||||
|
{{ super() }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<h1 id="search-documentation">{{ _('Search') }}</h1>
|
||||||
|
|
||||||
|
<noscript>
|
||||||
|
<div class="admonition warning">
|
||||||
|
<p>
|
||||||
|
{{ '请启用 JavaScript 以使用搜索功能。' if is_zh else 'Please enable JavaScript to use search.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="section-search-app"
|
||||||
|
data-empty-query="{{ '请输入搜索关键词。' if is_zh else 'Enter a keyword to search.' }}"
|
||||||
|
data-loading="{{ '正在搜索...' if is_zh else 'Searching...' }}"
|
||||||
|
data-load-error="{{ '搜索索引加载失败。' if is_zh else 'Search index failed to load.' }}"
|
||||||
|
data-no-results="{{ '没有找到匹配结果。' if is_zh else 'No matching results found.' }}"
|
||||||
|
data-result-one="{{ '找到 1 个匹配结果。' if is_zh else 'Found 1 matching result.' }}"
|
||||||
|
data-result-many="{{ '找到 {count} 个匹配结果。' if is_zh else 'Found {count} matching results.' }}"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
{{ '支持中文和英文搜索,结果会优先跳转到最接近的章节位置。' if is_zh else 'Supports both Chinese and English search and jumps to the closest matching section.' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form action="" method="get" role="search">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
aria-labelledby="search-documentation"
|
||||||
|
value=""
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
<input type="submit" value="{{ _('search') }}" />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p id="section-search-status" class="search-summary"></p>
|
||||||
|
<div id="section-search-results"></div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -57,6 +57,7 @@ extensions = ['recommonmark',
|
|||||||
'sphinx_copybutton',
|
'sphinx_copybutton',
|
||||||
'sphinx.ext.autosectionlabel',
|
'sphinx.ext.autosectionlabel',
|
||||||
'language_switch',
|
'language_switch',
|
||||||
|
'section_search',
|
||||||
# 'myst_parser',
|
# 'myst_parser',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,15 @@
|
|||||||
color: #0550ae !important;
|
color: #0550ae !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wy-nav-content .rst-content .search mark,
|
||||||
|
.wy-nav-content .rst-content mark.section-search-match,
|
||||||
|
.wy-nav-content .rst-content mark.section-search-hit {
|
||||||
|
background: #fff3a3 !important;
|
||||||
|
color: inherit !important;
|
||||||
|
border-radius: 3px !important;
|
||||||
|
padding: 0 0.12em !important;
|
||||||
|
}
|
||||||
|
|
||||||
.wy-nav-content .rst-content code,
|
.wy-nav-content .rst-content code,
|
||||||
.wy-nav-content .rst-content tt,
|
.wy-nav-content .rst-content tt,
|
||||||
.wy-nav-content .rst-content .literal {
|
.wy-nav-content .rst-content .literal {
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ extensions = ['recommonmark',
|
|||||||
'sphinx_copybutton',
|
'sphinx_copybutton',
|
||||||
'sphinx.ext.autosectionlabel',
|
'sphinx.ext.autosectionlabel',
|
||||||
'language_switch',
|
'language_switch',
|
||||||
|
'section_search',
|
||||||
# 'myst_parser',
|
# 'myst_parser',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,15 @@
|
|||||||
color: #0550ae !important;
|
color: #0550ae !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wy-nav-content .rst-content .search mark,
|
||||||
|
.wy-nav-content .rst-content mark.section-search-match,
|
||||||
|
.wy-nav-content .rst-content mark.section-search-hit {
|
||||||
|
background: #fff3a3 !important;
|
||||||
|
color: inherit !important;
|
||||||
|
border-radius: 3px !important;
|
||||||
|
padding: 0 0.12em !important;
|
||||||
|
}
|
||||||
|
|
||||||
.wy-nav-content .rst-content code,
|
.wy-nav-content .rst-content code,
|
||||||
.wy-nav-content .rst-content tt,
|
.wy-nav-content .rst-content tt,
|
||||||
.wy-nav-content .rst-content .literal {
|
.wy-nav-content .rst-content .literal {
|
||||||
|
|||||||
Reference in New Issue
Block a user