diff --git a/src/dockle/sphinx/themes/dockle/static/dockle.css b/src/dockle/sphinx/themes/dockle/static/dockle.css
index f1ccdb3..7a8af89 100644
--- a/src/dockle/sphinx/themes/dockle/static/dockle.css
+++ b/src/dockle/sphinx/themes/dockle/static/dockle.css
@@ -925,19 +925,32 @@ html[data-dockle-framework="rustdoc"] .sidebar::before {
color: var(--dockle-content);
font: inherit;
height: 2.6rem;
- padding: 0.55rem 0.7rem 0.55rem 2.25rem;
+ padding: 0.55rem 3rem 0.55rem 0.7rem;
width: 100%;
}
-.dockle-search-icon {
+.dockle-search-submit {
+ align-items: center;
+ background: transparent;
+ border: 0;
+ border-radius: var(--dockle-radius);
color: var(--dockle-muted);
- left: 0.7rem;
- pointer-events: none;
+ cursor: pointer;
+ display: flex;
+ height: 2.6rem;
+ justify-content: center;
position: absolute;
- top: 0.8rem;
+ right: 0;
+ top: 0;
+ width: 2.6rem;
z-index: 1;
}
+.dockle-search-submit:hover,
+.dockle-search-submit:focus-visible {
+ color: var(--dockle-primary);
+}
+
.dockle-search input[type="search"]:focus-visible {
border-color: var(--dockle-primary);
outline: 2px solid color-mix(in srgb, var(--dockle-primary) 35%, transparent);
@@ -950,7 +963,7 @@ html[data-dockle-framework="rustdoc"] .sidebar::before {
border-radius: var(--dockle-radius);
list-style: none;
margin: 0.35rem 0 0;
- max-height: 16rem;
+ max-height: 24rem;
overflow-y: auto;
padding: 0.25rem;
position: absolute;
@@ -967,15 +980,63 @@ html[data-dockle-framework="rustdoc"] .sidebar::before {
.dockle-search-results a {
color: var(--dockle-content);
display: flex;
+ flex-direction: column;
gap: 0.35rem;
text-decoration: none;
}
+.dockle-live-search-snippet {
+ color: var(--dockle-muted);
+ display: block;
+ line-height: 1.4;
+}
+
+.dockle-search-results mark,
+.dockle-search-page-results mark {
+ background: color-mix(in srgb, var(--dockle-primary) 25%, transparent);
+ border-radius: 0.15rem;
+ color: inherit;
+}
+
.dockle-search-results a:hover,
.dockle-search-results a:focus-visible {
color: var(--dockle-primary);
}
+.dockle-search-main {
+ display: block;
+}
+
+.dockle-search-page-results {
+ list-style: none;
+ padding: 0;
+}
+
+.dockle-search-page-results li {
+ border-bottom: 1px solid var(--dockle-border);
+ padding: 0.8rem 0;
+}
+
+.dockle-search-page-results a {
+ font-size: 1.1rem;
+ font-weight: 600;
+}
+
+.dockle-search-page-results p {
+ color: var(--dockle-muted);
+ margin: 0.35rem 0 0;
+}
+
+.dockle-search-more {
+ background: var(--dockle-background);
+ border: 1px solid var(--dockle-border);
+ border-radius: var(--dockle-radius);
+ color: var(--dockle-content);
+ cursor: pointer;
+ font: inherit;
+ padding: 0.6rem 1rem;
+}
+
.dockle-sidebar .dockle-tree {
flex: 1 1 auto;
margin: 0;
diff --git a/src/dockle/sphinx/themes/dockle/static/dockle.js b/src/dockle/sphinx/themes/dockle/static/dockle.js
index 9f1ecbd..9006092 100644
--- a/src/dockle/sphinx/themes/dockle/static/dockle.js
+++ b/src/dockle/sphinx/themes/dockle/static/dockle.js
@@ -1331,6 +1331,51 @@
};
const normalize = (value) => value.toLocaleLowerCase();
+ const cleanSearchTitle = (value) => value.replace(/\s+[—–]\s+[^—–]+$/, "");
+ const searchExcerpt = (value, query) => {
+ const content = value.replace(/\s+/g, " ").trim();
+ if (!content) {
+ return "";
+ }
+ const folded = normalize(content);
+ const terms = normalize(query).split(/\s+/).filter(Boolean);
+ let matchAt = folded.indexOf(normalize(query));
+ if (matchAt < 0) {
+ matchAt = Math.min(...terms.map((term) => {
+ const position = folded.indexOf(term);
+ return position < 0 ? Infinity : position;
+ }));
+ }
+ const start = Number.isFinite(matchAt) && matchAt > 70
+ ? content.indexOf(" ", matchAt - 70) + 1 : 0;
+ const limit = Math.min(content.length, start + 220);
+ const wordEnd = content.lastIndexOf(" ", limit);
+ const end = limit < content.length && wordEnd > start ? wordEnd : limit;
+ return `${start ? "… " : ""}${content.slice(start, end)}${end < content.length ? " …" : ""}`;
+ };
+ const decodeSearchHighlight = (value) => {
+ const template = document.createElement("template");
+ template.innerHTML = value;
+ return template.content.textContent || "";
+ };
+ const appendSearchHighlight = (host, value, query) => {
+ const terms = [...new Set(query.split(/\s+/).filter(Boolean))];
+ if (!terms.length) {
+ host.textContent = value;
+ return;
+ }
+ const escaped = terms.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
+ const pattern = new RegExp(`(${escaped.join("|")})`, "gi");
+ for (const fragment of value.split(pattern)) {
+ if (terms.some((term) => normalize(term) === normalize(fragment))) {
+ const mark = document.createElement("mark");
+ mark.textContent = fragment;
+ host.append(mark);
+ } else {
+ host.append(document.createTextNode(fragment));
+ }
+ }
+ };
const score = (entry, terms) => {
const title = normalize(entry.title);
const text = normalize(entry.text);
@@ -1356,6 +1401,12 @@
}
let documents;
+ let hostedSearch;
+ let liveRequestId = 0;
+ let pageRequestId = 0;
+ const pageResults = document.querySelector("[data-dockle-search-page]");
+ const pageSummary = document.querySelector("[data-dockle-search-summary]");
+ const rootPath = input.dataset.dockleRoot.replace(/\/?$/, "/");
const loadDocuments = async () => {
if (!documents) {
const response = await fetch(input.dataset.dockleSearch);
@@ -1367,6 +1418,73 @@
return documents;
};
+ const localSearch = async (query, limit, page) => {
+ const terms = normalize(query).split(/\s+/).filter(Boolean);
+ const matches = (await loadDocuments())
+ .map((entry) => ({ entry, score: score(entry, terms) }))
+ .filter((match) => match.score >= 0)
+ .sort((left, right) => right.score - left.score);
+ const start = (page - 1) * limit;
+ return {
+ count: matches.length,
+ next: start + limit < matches.length,
+ matches: matches.slice(start, start + limit).map(({ entry }) => ({
+ title: cleanSearchTitle(entry.title),
+ excerpt: searchExcerpt(entry.text, query),
+ href: new URL(`${rootPath}${entry.location}`, document.baseURI).href,
+ })),
+ };
+ };
+
+ const search = async (query, limit, page = 1) => {
+ if (hostedSearch) {
+ try {
+ const url = new URL("/_/api/v3/search/", location.origin);
+ url.searchParams.set("q", `project:${hostedSearch.project}/${hostedSearch.version} ${query}`);
+ url.searchParams.set("page_size", String(limit));
+ url.searchParams.set("page", String(page));
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`Read the Docs search returned ${response.status}`);
+ }
+ const data = await response.json();
+ if (data.count) {
+ return {
+ count: data.count,
+ next: data.next,
+ matches: data.results.map((entry) => {
+ const highlights = entry.blocks?.flatMap((block) => block.highlights?.content || []) || [];
+ const context = highlights.filter(Boolean).slice(0, 2).join(" … ")
+ || entry.blocks?.find((block) => block.content)?.content || "";
+ return {
+ title: cleanSearchTitle(entry.title),
+ excerpt: searchExcerpt(decodeSearchHighlight(context), query),
+ href: new URL(entry.path, entry.domain).href,
+ };
+ }),
+ };
+ }
+ } catch {
+ // Read the Docs preview builds may not have a server index yet.
+ }
+ }
+ return localSearch(query, limit, page);
+ };
+
+ const setHostedSearch = (eventData) => {
+ const data = eventData?.detail?.data?.() || eventData?.data?.();
+ const project = data?.projects?.current?.slug;
+ const version = data?.versions?.current?.slug;
+ if (project && version && !/^\d+$/.test(version)) {
+ hostedSearch = { project, version };
+ if (pageResults) {
+ void renderPage();
+ } else if (input.value.trim().length >= 2) {
+ input.dispatchEvent(new Event("input"));
+ }
+ }
+ };
+
const closeResults = () => {
results.hidden = true;
results.replaceChildren();
@@ -1374,7 +1492,7 @@
input.addEventListener("input", async () => {
const query = input.value.trim();
- const terms = normalize(query).split(/\s+/).filter(Boolean);
+ const currentRequest = ++liveRequestId;
results.replaceChildren();
results.hidden = query.length < 2;
if (results.hidden) {
@@ -1382,19 +1500,23 @@
}
try {
- const matches = (await loadDocuments())
- .map((entry) => ({ entry, score: score(entry, terms) }))
- .filter((match) => match.score >= 0)
- .sort((left, right) => right.score - left.score)
- .slice(0, 8);
+ const { matches } = await search(query, 8);
+ if (currentRequest !== liveRequestId) {
+ return;
+ }
for (const match of matches) {
const item = document.createElement("li");
const link = document.createElement("a");
- const rootPath = input.dataset.dockleRoot.replace(/\/?$/, "/");
- link.href = new URL(`${rootPath}${match.entry.location}`, document.baseURI);
+ link.href = match.href;
const title = document.createElement("strong");
- title.textContent = match.entry.title;
+ title.textContent = match.title;
link.append(title);
+ if (match.excerpt) {
+ const excerpt = document.createElement("small");
+ excerpt.className = "dockle-live-search-snippet";
+ appendSearchHighlight(excerpt, match.excerpt, query);
+ link.append(excerpt);
+ }
item.append(link);
results.append(item);
}
@@ -1404,12 +1526,71 @@
results.append(item);
}
} catch {
+ if (currentRequest !== liveRequestId) {
+ return;
+ }
const item = document.createElement("li");
item.textContent = "Search is unavailable";
results.append(item);
}
});
+ const renderPage = async () => {
+ const query = new URLSearchParams(location.search).get("q")?.trim() || "";
+ input.value = query;
+ if (!pageResults || !pageSummary) {
+ return;
+ }
+ pageResults.replaceChildren();
+ pageResults.parentElement?.querySelector(".dockle-search-more")?.remove();
+ pageSummary.textContent = query ? `Searching for “${query}”…` : "Enter a search term above.";
+ if (!query) {
+ return;
+ }
+ const currentRequest = ++pageRequestId;
+ const addPage = async (page) => {
+ try {
+ const { matches, count, next } = await search(query, 25, page);
+ if (currentRequest !== pageRequestId) {
+ return;
+ }
+ pageSummary.textContent = count === 1 ? "1 matching page" : `${count} matching pages`;
+ for (const match of matches) {
+ const item = document.createElement("li");
+ const link = document.createElement("a");
+ link.href = match.href;
+ link.textContent = match.title;
+ const excerpt = document.createElement("p");
+ appendSearchHighlight(excerpt, match.excerpt, query);
+ item.append(link, excerpt);
+ pageResults.append(item);
+ }
+ pageResults.parentElement?.querySelector(".dockle-search-more")?.remove();
+ if (next) {
+ const more = document.createElement("button");
+ more.className = "dockle-search-more";
+ more.type = "button";
+ more.textContent = "Load more results";
+ more.addEventListener("click", () => {
+ more.disabled = true;
+ void addPage(page + 1);
+ });
+ pageResults.after(more);
+ }
+ } catch {
+ pageSummary.textContent = "Search is unavailable";
+ }
+ };
+ await addPage(1);
+ };
+ if (pageResults) {
+ void renderPage();
+ }
+ document.addEventListener("readthedocs-addons-data-ready", setHostedSearch);
+ if (window.ReadTheDocsEventData) {
+ setHostedSearch(window.ReadTheDocsEventData);
+ }
+
input.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeResults();
diff --git a/src/dockle/theme.py b/src/dockle/theme.py
index 2e8fa73..2c7732d 100644
--- a/src/dockle/theme.py
+++ b/src/dockle/theme.py
@@ -134,39 +134,73 @@ class ThemeError(RuntimeError):
class _SearchDocumentParser(HTMLParser):
"""Collect useful searchable text from one generated HTML page."""
- def __init__(self) -> None:
+ _VOID_TAGS = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "wbr"}
+ _SKIP_TAGS = {"aside", "button", "footer", "form", "header", "nav", "script", "style", "svg"}
+ _SKIP_CLASSES = {
+ "dockle-page-actions", "dockle-page-links", "dockle-universal-search",
+ "headerlink", "visually-hidden",
+ }
+
+ def __init__(self, framework: str) -> None:
super().__init__(convert_charrefs=True)
+ self.framework = framework
self.title: list[str] = []
- self.text: list[str] = []
- self._in_title = False
- self._ignored = 0
+ self.heading: list[str] = []
+ self.content: list[str] = []
+ self.body: list[str] = []
+ self.has_content = False
+ self._stack: list[tuple[str, bool, bool, bool, bool, bool]] = []
+
+ def _is_content_root(self, tag: str, attributes: dict[str, str], classes: set[str]) -> bool:
+ if self.framework in {"sphinx", "mkdocs"}:
+ return tag == "article" and "dockle-article" in classes
+ if self.framework == "doxygen":
+ return tag == "div" and "contents" in classes
+ if self.framework == "jsdoc":
+ return tag == "div" and attributes.get("id") == "main"
+ if self.framework == "rustdoc":
+ return tag == "section" and attributes.get("id") == "main-content"
+ return tag == "main"
def handle_starttag(
self,
tag: str,
attrs: list[tuple[str, str | None]],
) -> None:
- del attrs
- if tag in {"script", "style", "svg"}:
- self._ignored += 1
- if tag == "title":
- self._in_title = True
+ attributes = {name: value or "" for name, value in attrs}
+ classes = set(attributes.get("class", "").split())
+ parent = self._stack[-1] if self._stack else ("", False, False, False, False, False)
+ in_body = parent[1] or tag == "body"
+ in_content = parent[2] or (not self.has_content and self._is_content_root(tag, attributes, classes))
+ if in_content and not parent[2]:
+ self.has_content = True
+ ignored = parent[3] or tag in self._SKIP_TAGS or bool(classes & self._SKIP_CLASSES)
+ in_title = parent[4] or tag == "title"
+ in_heading = parent[5] or (tag == "h1" and in_content)
+ if tag not in self._VOID_TAGS:
+ self._stack.append((tag, in_body, in_content, ignored, in_title, in_heading))
def handle_endtag(self, tag: str) -> None:
- if tag == "title":
- self._in_title = False
- if tag in {"script", "style", "svg"} and self._ignored:
- self._ignored -= 1
+ for index in range(len(self._stack) - 1, -1, -1):
+ if self._stack[index][0] == tag:
+ del self._stack[index:]
+ break
def handle_data(self, data: str) -> None:
- if self._ignored:
- return
cleaned = " ".join(data.split())
- if not cleaned:
+ if not cleaned or not self._stack:
return
- self.text.append(cleaned)
- if self._in_title:
+ _, in_body, in_content, ignored, in_title, in_heading = self._stack[-1]
+ if in_title:
self.title.append(cleaned)
+ if ignored:
+ return
+ if in_heading:
+ self.heading.append(cleaned)
+ elif in_content:
+ self.content.append(cleaned)
+ if in_body and not in_heading:
+ self.body.append(cleaned)
class _LinkRelationshipParser(HTMLParser):
@@ -465,7 +499,10 @@ def apply_theme(
) -> int:
"""Inject shared assets, navigation, branding, and client search."""
- html_files = sorted(output.rglob(_HTML_GLOB))
+ html_files = sorted(
+ path for path in output.rglob(_HTML_GLOB)
+ if path.name != "dockle-search.html"
+ )
if not html_files:
raise ThemeError(
f"{framework} did not generate any HTML files in {output}"
@@ -474,7 +511,7 @@ def apply_theme(
asset_dir = _write_theme_assets(output, stylesheet)
logo_asset = _copy_logo(logo, asset_dir)
favicon_asset = _copy_favicon(favicon, asset_dir)
- search_documents = _build_search_documents(html_files, output)
+ search_documents = _build_search_documents(html_files, output, framework)
(asset_dir / "search.json").write_text(
json.dumps({"docs": search_documents}, ensure_ascii=False),
encoding="utf-8",
@@ -493,6 +530,12 @@ def apply_theme(
document = _inject_theme_assets(
document, html_file, asset_dir, framework, native_theme
)
+ if 'name="readthedocs-addons-api-version"' not in document:
+ document = _insert_before_head_end(
+ document,
+ '
',
+ html_file,
+ )
document = _inject_favicon(document, html_file, favicon_asset)
document = _mark_framework(document, html_file, framework)
document = _inject_repository_action(
@@ -517,6 +560,28 @@ def apply_theme(
if document != original:
html_file.write_text(document, encoding="utf-8")
themed += 1
+ search_page = (
+ Path(__file__).parent / "jsdoc_template" / "tmpl" / "dockle-search.html"
+ ).read_text(encoding="utf-8")
+ search_page = (
+ search_page.replace("{{FRAMEWORK}}", escape(framework, quote=True))
+ .replace("{{PROJECT}}", escape(project_name or target_title or "Documentation"))
+ .replace("{{ASSETS}}", "_dockle/")
+ .replace("{{INDEX}}", "_dockle/search.json")
+ .replace(
+ "{{LOGO}}",
+ escape(_asset_url(logo_asset, output / "dockle-search.html"))
+ if logo_asset else "",
+ )
+ .replace(
+ "{{FAVICON_LINK}}",
+ '
'
+ if favicon_asset else "",
+ )
+ )
+ (output / "dockle-search.html").write_text(search_page, encoding="utf-8")
return themed
@@ -895,13 +960,15 @@ def _find_tag_end(document: str, start: int) -> int | None:
def _build_search_documents(
html_files: list[Path],
output: Path,
+ framework: str,
) -> list[dict[str, str]]:
documents: list[dict[str, str]] = []
for html_file in html_files:
- parser = _SearchDocumentParser()
+ parser = _SearchDocumentParser(framework)
parser.feed(html_file.read_text(encoding="utf-8"))
- text = " ".join(parser.text)
- title = " ".join(parser.title)
+ text = " ".join(parser.content if parser.has_content else parser.body)
+ title = " ".join(parser.heading or parser.title)
+ title = re.split(r"\s+[—–]\s+", title, maxsplit=1)[0]
if not title:
title = html_file.stem.replace("-", " ").title()
documents.append(
@@ -931,21 +998,24 @@ def _page_decorations(
)
relative_root = _relative(output, html_file)
logo_url = _asset_url(logo_asset, html_file) if logo_asset else ""
- search = f"""
-
+ data-dockle-target-title="{escape(target_title)}"
+ action="{_relative(output / 'dockle-search.html', html_file)}"
+ method="get" role="search">
Search documentation
-
+
-
"""
+ """
links = ""
if project_name and framework in {"doxygen", "jsdoc", "rustdoc"}:
brand_root = portal if portal is not None else output
diff --git a/tests-js/jsdoc-template.test.mjs b/tests-js/jsdoc-template.test.mjs
index 4200f30..fbd2bdf 100644
--- a/tests-js/jsdoc-template.test.mjs
+++ b/tests-js/jsdoc-template.test.mjs
@@ -180,6 +180,7 @@ export function add(left, right) { return left + right; }
const document = await readFile(path.join(output, 'index.html'), 'utf8');
const search = JSON.parse(await readFile(path.join(output, 'search.json'), 'utf8'));
+ const searchPage = await readFile(path.join(output, 'dockle-search.html'), 'utf8');
assert.match(document, /data-dockle-framework="jsdoc"/);
assert.match(document, /data-dockle-theme="jsdoc"/);
assert.match(document, /native-jsdoc-example/);
@@ -216,6 +217,17 @@ export function add(left, right) { return left + right; }
assert.match(document, /classList\.remove\("prettyprint", "source"\)/);
assert.doesNotMatch(document, /data-dockle-home/);
assert.ok(search.docs.some((entry) => entry.text.includes('Add two values')));
+ assert.ok(!search.docs.some((entry) => entry.location === 'dockle-search.html'));
+ assert.ok(search.docs.every((entry) => !entry.text.includes('Search documentation')));
+ assert.ok(search.docs.every((entry) => !entry.text.includes('Generated by')));
+ assert.match(document, /action="dockle-search\.html" method="get"/);
+ assert.match(document, /name="readthedocs-addons-api-version"/);
+ assert.match(searchPage, /data-dockle-search-page/);
+ assert.match(searchPage, /data-dockle-search="search\.json"/);
+ assert.match(searchPage, /data-dockle-logo-url="dockle-logo\.svg"/);
+ assert.match(searchPage, /rel="icon" href="dockle-favicon\.svg" data-dockle-favicon/);
+ assert.match(searchPage, /href="project\.css" data-dockle-extra-stylesheet="0"/);
+ assert.match(searchPage, /src="project\.js" data-dockle-extra-javascript="0"/);
await readFile(path.join(output, 'dockle.css'), 'utf8');
await readFile(path.join(output, 'dockle.js'), 'utf8');
await readFile(path.join(output, 'highlight.min.js'), 'utf8');
diff --git a/tests/test_theme.py b/tests/test_theme.py
index 844bffe..85e82b0 100644
--- a/tests/test_theme.py
+++ b/tests/test_theme.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import json
import tempfile
import unittest
from importlib.metadata import PackageNotFoundError
@@ -229,7 +230,16 @@ def test_apply_theme_uses_relative_asset_paths_for_nested_pages(
document.index("data-dockle-built-with"),
)
self.assertIn("../../_dockle/search.json", document)
+ self.assertIn('action="../../dockle-search.html"', document)
+ self.assertIn('type="submit"', document)
+ self.assertIn('name="readthedocs-addons-api-version"', document)
self.assertTrue((output / "_dockle" / "search.json").is_file())
+ search_page = (output / "dockle-search.html").read_text(
+ encoding="utf-8"
+ )
+ self.assertIn('data-dockle-search-page', search_page)
+ self.assertIn('data-dockle-search="_dockle/search.json"', search_page)
+ self.assertIn('action="dockle-search.html"', search_page)
self.assertTrue(
(output / "_dockle" / "lucide.min.js").is_file()
)
@@ -237,6 +247,42 @@ def test_apply_theme_uses_relative_asset_paths_for_nested_pages(
(output / "_dockle" / "highlight.min.js").is_file()
)
+ def test_search_index_uses_page_content_and_heading(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ content_roots = {
+ "doxygen": '
{content}
',
+ "jsdoc": '
{content}
',
+ "mkdocs": '
{content} ',
+ "rustdoc": '
',
+ "sphinx": '
{content} ',
+ }
+ for framework, root in content_roots.items():
+ with self.subTest(framework=framework):
+ output = Path(directory) / framework
+ output.mkdir()
+ content = (
+ '
Component reference '
+ '
Reusable components provide a stable API for each framework.
'
+ )
+ (output / "guide.html").write_text(
+ '
Component reference — Example documentation '
+ '
Repeated navigation labels '
+ + root.format(content=content)
+ + '
',
+ encoding="utf-8",
+ )
+
+ apply_theme(output, framework, "body {}")
+
+ search = json.loads(
+ (output / "_dockle" / "search.json").read_text(encoding="utf-8")
+ )
+ self.assertEqual(search["docs"], [{
+ "location": "guide.html",
+ "title": "Component reference",
+ "text": "Reusable components provide a stable API for each framework.",
+ }])
+
def test_repository_action_is_added_for_every_framework(self) -> None:
content = {
"doxygen": (
@@ -484,6 +530,10 @@ def test_apply_theme_copies_and_exposes_project_logo(self) -> None:
document = html.read_text(encoding="utf-8")
self.assertTrue((output / "_dockle" / "logo.png").is_file())
self.assertIn('data-dockle-logo-url="_dockle/logo.png"', document)
+ self.assertIn(
+ 'data-dockle-logo-url="_dockle/logo.png"',
+ (output / "dockle-search.html").read_text(encoding="utf-8"),
+ )
def test_apply_theme_exposes_remote_project_logo(self) -> None:
with tempfile.TemporaryDirectory() as directory:
@@ -525,6 +575,11 @@ def test_apply_theme_exposes_remote_favicon(self) -> None:
'href="https://example.com/favicon.svg"',
html.read_text(encoding="utf-8"),
)
+ self.assertIn(
+ 'rel="icon" href="https://example.com/favicon.svg" '
+ "data-dockle-favicon",
+ (output / "dockle-search.html").read_text(encoding="utf-8"),
+ )
def test_apply_theme_replaces_favicon_for_every_framework(self) -> None:
with tempfile.TemporaryDirectory() as directory:
@@ -559,6 +614,11 @@ def test_apply_theme_replaces_favicon_for_every_framework(self) -> None:
"data-dockle-favicon",
document,
)
+ self.assertIn(
+ 'rel="icon" href="_dockle/favicon.svg" '
+ "data-dockle-favicon",
+ (output / "dockle-search.html").read_text(encoding="utf-8"),
+ )
self.assertTrue(
(output / "_dockle" / "favicon.svg").is_file()
)
From 993b8ded43f26ec229afd14ee93b4d5509541398 Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Thu, 24 Sep 2026 17:00:32 -0400
Subject: [PATCH 2/2] fix(search): address Sonar findings
---
src/dockle/jsdoc_template/publish.js | 2 +-
src/dockle/sphinx/themes/dockle/static/dockle.js | 7 +++++--
src/dockle/theme.py | 15 ++++++++-------
3 files changed, 14 insertions(+), 10 deletions(-)
diff --git a/src/dockle/jsdoc_template/publish.js b/src/dockle/jsdoc_template/publish.js
index d7102de..8dcd60a 100644
--- a/src/dockle/jsdoc_template/publish.js
+++ b/src/dockle/jsdoc_template/publish.js
@@ -174,7 +174,7 @@ function searchDocument(filename, root) {
return {
location: path.relative(root, filename).split(path.sep).join('/'),
text: searchableText(body).slice(0, 4000),
- title: decodeEntities(title).trim().split(/\s+[—–]\s+/)[0],
+ title: decodeEntities(title).trim().split(' — ')[0].split(' – ')[0],
};
}
diff --git a/src/dockle/sphinx/themes/dockle/static/dockle.js b/src/dockle/sphinx/themes/dockle/static/dockle.js
index 9006092..16d1f28 100644
--- a/src/dockle/sphinx/themes/dockle/static/dockle.js
+++ b/src/dockle/sphinx/themes/dockle/static/dockle.js
@@ -1331,7 +1331,10 @@
};
const normalize = (value) => value.toLocaleLowerCase();
- const cleanSearchTitle = (value) => value.replace(/\s+[—–]\s+[^—–]+$/, "");
+ const cleanSearchTitle = (value) => {
+ const separator = Math.max(value.lastIndexOf(" — "), value.lastIndexOf(" – "));
+ return separator < 0 ? value : value.slice(0, separator);
+ };
const searchExcerpt = (value, query) => {
const content = value.replace(/\s+/g, " ").trim();
if (!content) {
@@ -1364,7 +1367,7 @@
host.textContent = value;
return;
}
- const escaped = terms.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
+ const escaped = terms.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`));
const pattern = new RegExp(`(${escaped.join("|")})`, "gi");
for (const fragment of value.split(pattern)) {
if (terms.some((term) => normalize(term) === normalize(fragment))) {
diff --git a/src/dockle/theme.py b/src/dockle/theme.py
index 2c7732d..5aea8b4 100644
--- a/src/dockle/theme.py
+++ b/src/dockle/theme.py
@@ -106,6 +106,7 @@
_LUCIDE_ASSET = "lucide.min.js"
_HIGHLIGHT_ASSET = "highlight.min.js"
_INDEX_FILE = "index.html"
+_SEARCH_PAGE = "dockle-search.html"
_HTML_GLOB = "*.html"
_FRAMEWORKS = {
"doxygen": ("Doxygen", "https://www.doxygen.nl/"),
@@ -501,7 +502,7 @@ def apply_theme(
html_files = sorted(
path for path in output.rglob(_HTML_GLOB)
- if path.name != "dockle-search.html"
+ if path.name != _SEARCH_PAGE
)
if not html_files:
raise ThemeError(
@@ -561,7 +562,7 @@ def apply_theme(
html_file.write_text(document, encoding="utf-8")
themed += 1
search_page = (
- Path(__file__).parent / "jsdoc_template" / "tmpl" / "dockle-search.html"
+ Path(__file__).parent / "jsdoc_template" / "tmpl" / _SEARCH_PAGE
).read_text(encoding="utf-8")
search_page = (
search_page.replace("{{FRAMEWORK}}", escape(framework, quote=True))
@@ -570,18 +571,18 @@ def apply_theme(
.replace("{{INDEX}}", "_dockle/search.json")
.replace(
"{{LOGO}}",
- escape(_asset_url(logo_asset, output / "dockle-search.html"))
+ escape(_asset_url(logo_asset, output / _SEARCH_PAGE))
if logo_asset else "",
)
.replace(
"{{FAVICON_LINK}}",
'
'
if favicon_asset else "",
)
)
- (output / "dockle-search.html").write_text(search_page, encoding="utf-8")
+ (output / _SEARCH_PAGE).write_text(search_page, encoding="utf-8")
return themed
@@ -968,7 +969,7 @@ def _build_search_documents(
parser.feed(html_file.read_text(encoding="utf-8"))
text = " ".join(parser.content if parser.has_content else parser.body)
title = " ".join(parser.heading or parser.title)
- title = re.split(r"\s+[—–]\s+", title, maxsplit=1)[0]
+ title = title.split(" — ", 1)[0].split(" – ", 1)[0]
if not title:
title = html_file.stem.replace("-", " ").title()
documents.append(
@@ -1001,7 +1002,7 @@ def _page_decorations(
search = f"""