diff --git a/docs/_ext/section_search.py b/docs/_ext/section_search.py new file mode 100644 index 00000000..f2655dd8 --- /dev/null +++ b/docs/_ext/section_search.py @@ -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() diff --git a/docs/_ext/static/section_search.js b/docs/_ext/static/section_search.js new file mode 100644 index 00000000..5f619bde --- /dev/null +++ b/docs/_ext/static/section_search.js @@ -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, "\\$&"); + } +})(); diff --git a/docs/_ext/templates/search.html b/docs/_ext/templates/search.html new file mode 100644 index 00000000..eee77ad8 --- /dev/null +++ b/docs/_ext/templates/search.html @@ -0,0 +1,52 @@ +{%- extends "layout.html" %} +{% set title = _('Search') %} +{% set is_zh = language and language.startswith('zh') %} + +{% block extrahead %} + + + {{ super() }} +{% endblock %} + +{% block body %} +

{{ _('Search') }}

+ + + +
+

+ {{ '支持中文和英文搜索,结果会优先跳转到最接近的章节位置。' if is_zh else 'Supports both Chinese and English search and jumps to the closest matching section.' }} +

+ +
+ + +
+ +

+
+
+{% endblock %} diff --git a/docs/en/conf.py b/docs/en/conf.py index 29c563fe..61843f23 100644 --- a/docs/en/conf.py +++ b/docs/en/conf.py @@ -57,6 +57,7 @@ extensions = ['recommonmark', 'sphinx_copybutton', 'sphinx.ext.autosectionlabel', 'language_switch', + 'section_search', # 'myst_parser', ] diff --git a/docs/en/source/_static/custom.css b/docs/en/source/_static/custom.css index 4aaff63a..b10df3bc 100755 --- a/docs/en/source/_static/custom.css +++ b/docs/en/source/_static/custom.css @@ -147,6 +147,15 @@ 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 tt, .wy-nav-content .rst-content .literal { diff --git a/docs/zh/conf.py b/docs/zh/conf.py index cbf608c9..1d24ef3f 100644 --- a/docs/zh/conf.py +++ b/docs/zh/conf.py @@ -57,6 +57,7 @@ extensions = ['recommonmark', 'sphinx_copybutton', 'sphinx.ext.autosectionlabel', 'language_switch', + 'section_search', # 'myst_parser', ] diff --git a/docs/zh/source/_static/custom.css b/docs/zh/source/_static/custom.css index 4aaff63a..b10df3bc 100755 --- a/docs/zh/source/_static/custom.css +++ b/docs/zh/source/_static/custom.css @@ -147,6 +147,15 @@ 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 tt, .wy-nav-content .rst-content .literal {