From e86f4540bc276f528d860cec20985ce107c8b819 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:11:57 -0400 Subject: [PATCH 1/2] feat: add Read the Docs and full-page search --- docs/architecture.rst | 4 + src/dockle/jsdoc_template/publish.js | 26 ++- .../jsdoc_template/tmpl/dockle-search.html | 58 +++++ src/dockle/jsdoc_template/tmpl/layout.tmpl | 13 +- .../sphinx/themes/dockle/static/dockle.css | 73 ++++++- .../sphinx/themes/dockle/static/dockle.js | 199 +++++++++++++++++- src/dockle/theme.py | 126 ++++++++--- tests-js/jsdoc-template.test.mjs | 12 ++ tests/test_theme.py | 60 ++++++ 9 files changed, 519 insertions(+), 52 deletions(-) create mode 100644 src/dockle/jsdoc_template/tmpl/dockle-search.html diff --git a/docs/architecture.rst b/docs/architecture.rst index cef7ec8..ca1e31e 100644 --- a/docs/architecture.rst +++ b/docs/architecture.rst @@ -79,6 +79,10 @@ navigation tree. Rustdoc receives the same controls through generated-HTML enhan implementations are first-party Dockle code; ``doxygen-awesome-css`` and ``doxyconfig`` are design references, not dependencies. +Each target publishes a ``dockle-search.html`` results page. The search field offers live suggestions and submits to +that page with a shareable ``?q=`` URL. On Read the Docs, Dockle uses the Addons project and version metadata to query +the hosted search API; elsewhere, and when a hosted index is unavailable, it uses the target's local ``search.json``. + Dockle also packages the complete pinned Highlight.js browser distribution. After a generator renders authored code, the shared client normalizes its language identifier and replaces native Pygments, Prettify, Doxygen, or rustdoc token markup with one Highlight.js token stream. This gives every adapter the same lexer behavior and palette without making diff --git a/src/dockle/jsdoc_template/publish.js b/src/dockle/jsdoc_template/publish.js index 1e31860..d7102de 100644 --- a/src/dockle/jsdoc_template/publish.js +++ b/src/dockle/jsdoc_template/publish.js @@ -163,12 +163,18 @@ function searchableText(document) { function searchDocument(filename, root) { const document = fs.readFileSync(filename, 'utf8'); - const title = /]*>([^<]*)<\/title>/i.exec(document)?.[1] + const title = /]*class="page-title"[^>]*>([\s\S]*?)<\/h1>/i.exec(document)?.[1] + || /]*>([^<]*)<\/title>/i.exec(document)?.[1] || path.basename(filename, '.html'); + const content = /
([\s\S]*?)/i, ' ') + .replace(/]*>[\s\S]*?<\/h1>/i, ' '); return { location: path.relative(root, filename).split(path.sep).join('/'), - text: searchableText(document).slice(0, 4000), - title: decodeEntities(title).trim(), + text: searchableText(body).slice(0, 4000), + title: decodeEntities(title).trim().split(/\s+[—–]\s+/)[0], }; } @@ -180,8 +186,20 @@ function finishSite(destination, dockle) { fs.writeFileSync(path.join(root, 'dockle.css'), stylesheet, 'utf8'); copyConfiguredAsset(dockle.logo, path.join(root, dockle.logoFile)); copyConfiguredAsset(dockle.favicon, path.join(root, dockle.faviconFile)); + const searchPage = fs.readFileSync(path.join(__dirname, 'tmpl', 'dockle-search.html'), 'utf8') + .replaceAll('{{FRAMEWORK}}', 'jsdoc') + .replaceAll('{{PROJECT}}', escapeAttribute(dockle.projectName)) + .replaceAll('{{ASSETS}}', '') + .replaceAll('{{INDEX}}', 'search.json') + .replaceAll('{{LOGO}}', escapeAttribute(dockle.logoFile)) + .replaceAll('{{FAVICON_LINK}}', dockle.faviconFile + ? `` + : ''); + fs.writeFileSync(path.join(root, 'dockle-search.html'), searchPage, 'utf8'); installExtraAssets(root, dockle); - const docs = htmlFiles(root).map((filename) => searchDocument(filename, root)); + const docs = htmlFiles(root) + .filter((filename) => path.basename(filename) !== 'dockle-search.html') + .map((filename) => searchDocument(filename, root)); fs.writeFileSync( path.join(root, 'search.json'), JSON.stringify({ docs }), diff --git a/src/dockle/jsdoc_template/tmpl/dockle-search.html b/src/dockle/jsdoc_template/tmpl/dockle-search.html new file mode 100644 index 0000000..a138584 --- /dev/null +++ b/src/dockle/jsdoc_template/tmpl/dockle-search.html @@ -0,0 +1,58 @@ + + + + + + + Search — {{PROJECT}} + {{FAVICON_LINK}} + + + + + +
+ +
+
+ +
+
+ + {{PROJECT}} +
+
+
+
+ +
+

Search results

+

+
    +
    +
    +
    +
    + + diff --git a/src/dockle/jsdoc_template/tmpl/layout.tmpl b/src/dockle/jsdoc_template/tmpl/layout.tmpl index 8691d6e..93eb244 100644 --- a/src/dockle/jsdoc_template/tmpl/layout.tmpl +++ b/src/dockle/jsdoc_template/tmpl/layout.tmpl @@ -13,6 +13,7 @@ const repositoryService = normalizedRepositoryUrl.includes('github') + <?js= safe(pageTitle) ?> @@ -33,19 +34,21 @@ const repositoryService = normalizedRepositoryUrl.includes('github')
    - +
    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"""""" + """ 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": '
    {content}
    ', + "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' + '' + + root.format(content=content) + + '
    Generated by Example
    ', + 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"""