diff --git a/src/sunpy_sphinx_theme/__init__.py b/src/sunpy_sphinx_theme/__init__.py index f6452476..0b279c8e 100644 --- a/src/sunpy_sphinx_theme/__init__.py +++ b/src/sunpy_sphinx_theme/__init__.py @@ -2,9 +2,11 @@ SunPy Sphinx Theme. """ +import json import os from functools import partial from pathlib import Path +from textwrap import dedent, indent from urllib.parse import urljoin from pydata_sphinx_theme import utils @@ -154,12 +156,54 @@ def update_html_context(app: Sphinx, pagename: str, templatename: str, context, context["sst_pathto"] = partial(sst_pathto, context) +def generate_search_config(app): + """ + This function parses the config for the "Documentation" section of the theme config. + """ + theme_config = utils.get_theme_options_dict(app) + search_projects = theme_config.get("rtd_search_projects", None) + if search_projects is None: + navbar_links = theme_config["navbar_links"] + doc_links = next(section[1] for section in navbar_links if section[0] == "Documentation") + + def filter_doc_links(links): + out_links = [] + for link in links: + if isinstance(link[1], list): + out_links += filter_doc_links(link[1]) + elif isinstance(link[1], str) and link[1].startswith("http"): + out_links.append({"name": link[0], "link": link[1]}) + else: + err = f"Unable to parse {link} in the nav tree. Try setting search_projects explicitly or fixing navbar_links." + raise ValueError(err) + return out_links + + projects = filter_doc_links(doc_links) + + load_more_label = theme_config.get("rtd_search_load_more_label", "Load more results") + no_results_label = theme_config.get("rtd_search_no_results_label", "There are no results for this search") + script = dedent(f""" + const set_search_config = {{ + "no-results":{{ + "label": "{no_results_label}" + }}, + "load-more":{{ + "label": "{load_more_label}", + "class": "btn sd-btn sd-bg-primary sd-bg-text-primary" + }}, + "projects":{indent(json.dumps(projects, indent=2), " " * 10, predicate=lambda line: line.strip() != "[")} + }}; + """) + app.add_js_file(None, body=script) + + def setup(app: Sphinx): # Register theme theme_dir = get_html_theme_path() app.add_html_theme("sunpy", theme_dir) app.add_css_file("sunpy_style.css", priority=600) - app.connect("builder-inited", update_config) + app.connect("builder-inited", update_config, priority=100) + app.connect("builder-inited", generate_search_config, priority=500) app.connect("html-page-context", update_html_context) # Conditionally include goat counter js # We can't do this in update_config as that causes the scripts to be duplicated. @@ -198,6 +242,14 @@ def setup(app: Sphinx): loading_method="async", ) + if theme_options.get("rtd_search", True): + # Add project-wide search + app.add_css_file("css/rtd_enhanced_search.css") + app.add_js_file( + "js/rtd_enhanced_search.js", + loading_method="async", + ) + return { "parallel_read_safe": True, "parallel_write_safe": True, diff --git a/src/sunpy_sphinx_theme/cards.py b/src/sunpy_sphinx_theme/cards.py index 6528d220..7bb240be 100644 --- a/src/sunpy_sphinx_theme/cards.py +++ b/src/sunpy_sphinx_theme/cards.py @@ -109,7 +109,7 @@ def run(self): def setup(app: Sphinx): - app.add_css_file("sunpy_cards.css", priority=600) + app.add_css_file("css/sunpy_cards.css", priority=600) app.add_directive("custom-card", Card) app.add_node(_Card, html=(visit_card_node, depart_card_node)) return { diff --git a/src/sunpy_sphinx_theme/theme/sunpy/static/css/rtd_enhanced_search.css b/src/sunpy_sphinx_theme/theme/sunpy/static/css/rtd_enhanced_search.css new file mode 100644 index 00000000..fcc1183e --- /dev/null +++ b/src/sunpy_sphinx_theme/theme/sunpy/static/css/rtd_enhanced_search.css @@ -0,0 +1,168 @@ +#pst-search-dialog[open] { + left: revert; + top: 2rem; + margin-top: 0; + transform: revert; + max-height: calc(100vh - 4rem); +} + +#pst-search-dialog[open] form.bd-search { + flex-grow: 0; +} + +.readthedocs-search { + background: var(--pst-color-background); + border-radius: 0.25rem; + width: 100%; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.readthedocs-search form { + margin: 0.5rem; + border: 0; +} + +.readthedocs-search form:focus-within { + outline: 2px solid var(--pst-color-accent); + border: 0; +} + +/* .readthedocs-search form.loading input { */ +/* } */ + +.readthedocs-search form.loading .fa-magnifying-glass { + background-image: url("data:image/svg+xml,"); + background-position: left center; + background-repeat: no-repeat; + background-size: contain; +} + +html[data-theme="dark"] .readthedocs-search form.loading .fa-magnifying-glass { + background-image: url("data:image/svg+xml,"); +} + +.readthedocs-search form.loading .fa-magnifying-glass path { + display: none; +} + +.readthedocs-search .content { + max-height: calc(100% - 110px); + overflow-y: auto; +} + +.readthedocs-search .tablist { + position: sticky; + display: flex; + overflow-x: auto; + top: 0; + outline: 0; + background: var(--pst-color-background); +} + +.readthedocs-search .tablist button { + display: flex; + border: 0; + border-bottom: max(3px, 0.1875rem, 0.12em) solid transparent; + color: var(--pst-color-text-base); + background-color: var(--pst-color-surface); + padding: 0.5rem 1rem; + align-items: center; + white-space: nowrap; +} + +.readthedocs-search .tablist button:focus { + color: var(--sst-lightest-color); + background-color: var(--sst-dark-color); +} + +.readthedocs-search .tablist button:focus, +.readthedocs-search .tablist button[aria-selected="true"] { + outline: 0; + border-bottom: max(3px, 0.1875rem, 0.12em) solid var(--pst-color-secondary); +} + +.readthedocs-search .tablist button svg { + padding-right: 0.25rem; +} + +.readthedocs-search .tablist button .n { + padding-left: 0.25rem; +} + +.readthedocs-search .results .results-message > * { + margin: 1rem; +} + +.readthedocs-search .results ul { + list-style: none; + padding: 0; + margin: 0; +} + +.readthedocs-search .results ul li { + margin: 0; + padding: 1rem; +} + +.readthedocs-search .results ul li:focus { + background: var(--pst-color-attention-bg); + outline: 0; +} + +.readthedocs-search .results ul li:first-child { + border: 0; +} + +/* .readthedocs-search .results ul li > *:last-child { */ +/* } */ + +.readthedocs-search .results ul li a:hover, +.readthedocs-search .results ul li a:focus, +.readthedocs-search .results ul li.selected a { + color: var(--pst-color-text-base); + background-color: var(--sst-footer-background-color); +} + +.readthedocs-search .footer { + padding: 10px; + border-top: 1px solid #e1e4e5; + display: flex; + gap: 1em; + justify-content: space-between; + align-items: center; + font-size: 12px; + color: var(--pst-color-text-base); + background-color: var(--sst-footer-background-color); +} + +.readthedocs-search .help { + display: flex; + list-style: none; + margin: 0; + padding: 0; + gap: 20px; +} + +.readthedocs-search .credits { + display: flex; + align-items: center; + gap: 5px; + margin-left: auto; + text-align: right; +} +.readthedocs-search .credits a { + color: inherit; +} + +.readthedocs-search .credits svg { + height: 20px; +} + +@media only screen and (max-width: 70rem) { + .readthedocs-search .footer, + .readthedocs-search .help { + display: block; + } +} diff --git a/src/sunpy_sphinx_theme/theme/sunpy/static/sunpy_cards.css b/src/sunpy_sphinx_theme/theme/sunpy/static/css/sunpy_cards.css similarity index 100% rename from src/sunpy_sphinx_theme/theme/sunpy/static/sunpy_cards.css rename to src/sunpy_sphinx_theme/theme/sunpy/static/css/sunpy_cards.css diff --git a/src/sunpy_sphinx_theme/theme/sunpy/static/js/rtd_enhanced_search.js b/src/sunpy_sphinx_theme/theme/sunpy/static/js/rtd_enhanced_search.js new file mode 100644 index 00000000..088d5b40 --- /dev/null +++ b/src/sunpy_sphinx_theme/theme/sunpy/static/js/rtd_enhanced_search.js @@ -0,0 +1,528 @@ +/* + SunPy's enhanced search using ReadTheDocs + Created: 2025-05 + Author: Stuart Lowe + + This script will augment the built-in search. + + You can provide an array of projects to a `set_search_config`. + For example, you could add the following to the page: + + ``` + + ``` + where: + * no-results - an optional object to set the label that gets displayed when there are no results + * load-more - an optional object to set the class/label of the "load more" button + * projects - an ordered array of projects to include (if empty this will be constructed from the ".nav-link" items found within #Documentation) + +*/ +/*jshint esversion: 6 */ +(function (root) { + function ready(fn) { + if (document.readyState != "loading") fn(); + else document.addEventListener("DOMContentLoaded", fn); + } + + function Search(dialog, config) { + // Set some defaults if not provided + if (!config) config = {}; + if (!config.all) config.all = "All"; + if (!config["no-results"]) config["no-results"] = {}; + if (!config["no-results"].label) + config["no-results"].label = "There are no results"; + if (!config["load-more"]) config["load-more"] = {}; + if (!config["load-more"].class) + config["load-more"].class = "btn sd-btn sd-bg-primary sd-bg-text-primary"; + if (!config["load-more"].label) + config["load-more"].label = "Load more results"; + + let _obj = this; + + let debug = location.search.match(/debug/) ? true : false; + + this.results = {}; + this.tabs = {}; + this.selectedPanel = config.all; + let icons = { + filter: + '', + book: '', + }; + + // Build the list of projects + this.projects = {}; + this.projects[config.all] = { name: config.all, url: "" }; + if (config.projects) { + this.projectorder = [config.all]; + // Use a provided array of objects containing "name" and "url" + for (let p = 0; p < config.projects.length; p++) { + let slug = config.projects[p].name; + this.projectorder.push(slug); + this.projects[slug] = { name: slug, url: config.projects[p].url }; + } + } else { + // Use the documentation nav links + document + .getElementById("Documentation") + .querySelectorAll(".nav-link") + .forEach(function (el) { + // We might want to somehow limit which projects to include in the search + let slug = el.innerHTML.replace(/(^\s|\s$)/, ""); + let link = el.getAttribute("href"); + _obj.projects[slug] = { name: slug, url: link }; + }); + this.projectorder = Object.keys(this.projects); + } + + // Find the form/input + const form = dialog.querySelector("form"); + const inp = form.querySelector("input[type=search]"); + + const holder = document.createElement("div"); + holder.classList.add("readthedocs-search"); + form.replaceWith(holder); + holder.appendChild(form); + + const content = document.createElement("div"); + content.classList.add("content", "bd-search-container"); + content.setAttribute("tabindex", -1); + holder.appendChild(content); + + // Create a tablist container + const tablist = document.createElement("div"); + tablist.classList.add("tablist"); + tablist.setAttribute("role", "tablist"); + tablist.setAttribute("tabindex", "-1"); + tablist.setAttribute("aria-label", "Project"); + content.appendChild(tablist); + + // Create a results container + const results = document.createElement("div"); + results.classList.add("results"); + content.appendChild(results); + + // Create the search footer + const footer = document.createElement("div"); + footer.classList.add("footer"); + footer.innerHTML = ` +
+ View search syntax +
+
+ Enhanced by + + ReadTheDocs + +
`; + holder.appendChild(footer); + + // Build an element to hold any results message + this.msg = { + header: document.createElement("div"), + footer: document.createElement("div"), + }; + this.msg.header.classList.add("results-message", "results-header"); + this.msg.footer.classList.add("results-message", "results-footer"); + results.appendChild(this.msg.header); + + // Loop over projects and create any tabs that are needed + for (let p = 0; p < this.projectorder.length; p++) { + let slug = this.projectorder[p]; + if (!this.projects[slug].tab) { + let tab = document.createElement("button"); + tab.innerHTML = + (slug == config.all ? icons.book : icons.filter) + + " " + + this.projects[slug].name + + ''; + tab.id = "tab-" + slug; + tab.classList.add("tab"); + tab.setAttribute("role", "tab"); + tab.setAttribute("aria-controls", "panel-" + slug); + tab.setAttribute("tabindex", slug == config.all ? 0 : -1); + tab.setAttribute("aria-selected", slug == config.all ? true : false); + // Add a click/focus event + tab.addEventListener("click", function (e) { + e.preventDefault(); + e.stopPropagation(); + _obj.selectTab(e); + }); + tab.addEventListener("focus", function (e) { + e.preventDefault(); + e.stopPropagation(); + _obj.selectTab(e); + }); + + // Add keyboard navigation to arrow keys following https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/Tab_Role + tab.addEventListener("keydown", function (e) { + if (e.key == "ArrowDown" || e.key == "ArrowRight") { + e.preventDefault(); + _obj.selectTab(e, 1); + } else if (e.key == "ArrowUp" || e.key == "ArrowLeft") { + e.preventDefault(); + _obj.selectTab(e, -1); + } + }); + tablist.appendChild(tab); + let panel = document.createElement("div"); + panel.classList.add("panel"); + panel.setAttribute("role", "tabpanel"); + panel.setAttribute("aria-labelledby", "tab-" + slug); + panel.style.display = slug == this.selectedPanel ? "" : "none"; + + this.projects[slug].results = document.createElement("ul"); + this.projects[slug].results.classList.add("search"); + panel.appendChild(this.projects[slug].results); + + results.appendChild(panel); + + this.projects[slug].tab = tab; + this.projects[slug].value = tab.querySelector(".n"); + this.projects[slug].panel = panel; + } + } + + results.appendChild(this.msg.footer); + + // Disable existing form behaviour + form.addEventListener("submit", function (e) { + e.preventDefault(); + }); + + // Add keyup event to form input + inp.addEventListener("keyup", function (e) { + if (e.key == "ArrowDown") { + _obj.navLi(e); + } else { + _obj.searchByString(e.target.value); + } + }); + + // Function to search with an input string + this.searchByString = function (str) { + if (this.resultdelay) clearTimeout(this.resultdelay); + if (str.length >= 2) { + if (str in this.results) this.displayResults(str); + else + this.resultdelay = setTimeout(function () { + _obj.getReadTheDocsResults(str); + }, 500); + } else { + this.displayResults(""); + } + return this; + }; + + this.getReadTheDocsResults = function (str, page) { + // Set as loading + form.classList.add("loading"); + + let projstr = this.projectorder + .join("+project:") + .replace(new RegExp("^" + config.all + "[s+]"), ""); + let url; + if (page) { + url = (debug ? "https://corsproxy.io/?" : "") + page; + } else { + url = + (debug ? "https://corsproxy.io/?https://readthedocs.org/" : "/_/") + + ("api/v3/search/?q=" + projstr + "+" + encodeURIComponent(str)); + } + console.info("Getting " + url); + fetch(url, {}) + .then((response) => { + return response.json(); + }) + .then((json) => { + if (page) { + _obj.results[str].next = json.next; + _obj.results[str].results = _obj.results[str].results.concat( + json.results, + ); + } else { + _obj.results[str] = json; + } + // Unset loading + form.classList.remove("loading"); + _obj.displayResults(str); + }) + .catch((e) => { + // Unset loading + form.classList.remove("loading"); + // Submit default form + form.submit(); + }); + return this; + }; + + this.getMoreReadTheDocsResults = function () { + let str = inp.value; + let data = this.results[str]; + if (data.next) + this.getReadTheDocsResults(str, data.next.replace(/%3A/g, ":")); + return this; + }; + + this.formatResult = function (str, data) { + let url = data.domain + data.path; + let txt = '' + data.title + ""; + let prevExtract = ""; + let extract; + txt += " (from " + data.project.slug + ")"; + for (let b = 0; b < data.blocks.length; b++) { + if ( + "highlights" in data.blocks[b] && + "content" in data.blocks[b].highlights && + data.blocks[b].highlights.content.length > 0 + ) { + extract = data.blocks[b].highlights.content[0]; + + // Sanitise HTML + extract = extract.replace(/(.*?)<\/span>/g, function (m, p1) { + return "[[MATCH]]" + p1 + "[[/MATCH]]"; + }); + extract = extract.replace(/<[^\>]+>/g, ""); + extract = extract.replace(/\[\[(\/?)MATCH\]\]/g, function (m, p1) { + return "<" + p1 + "strong>"; + }); + + if (extract != prevExtract) txt += "

" + extract + "

"; + prevExtract = extract; + } + } + return txt; + }; + + this.displayResults = function (str) { + let data, slug, r, li, li2, result; + if (str in this.results) { + data = this.results[str]; + } else { + data = { results: [] }; + } + for (slug in this.projects) { + this.projects[slug].count = 0; + this.projects[slug].results.innerHTML = ""; + } + + // Get a count of how many results for each project + for (r = 0; r < data.results.length; r++) { + slug = data.results[r].project.slug; + if (slug in this.projects) { + this.projects[slug].count++; + + result = this.formatResult(str, data.results[r]); + + // Add result to appropriate panel list + li = document.createElement("li"); + li.classList.add("kind-title"); + li.setAttribute("tabindex", 0); + li.innerHTML = result; + li.addEventListener("keyup", function (e) { + _obj.navLi(e); + }); + this.projects[slug].results.appendChild(li); + + li2 = document.createElement("li"); + li2.classList.add("kind-title"); + li2.setAttribute("tabindex", 0); + li2.innerHTML = result; + li2.addEventListener("keyup", function (e) { + _obj.navLi(e); + }); + this.projects[config.all].results.appendChild(li2); + } else { + console.warn("No " + slug + " in projects.", this.projects); + } + } + + // Hide any existing tabs + for (slug in this.projects) { + //this.projects[slug].value.innerHTML = (this.projects[slug].count > 0 ? ' (' + this.projects[slug].count + ')' : '(0)'); + this.projects[slug].value.innerHTML = + " (" + this.projects[slug].count + ")"; + this.projects[slug].tab.style.display = + this.projects[slug].count == 0 ? "none" : ""; + } + + // Update all tabs + this.projects[config.all].tab.style.display = + data.results.length > 0 ? "" : "none"; + this.projects[config.all].value.innerHTML = + data.results.length > 0 ? " (" + data.results.length + ")" : ""; + + // TO DO If the number of results is less than the count we create a "load more" link at the bottom + if (data.next) { + // Show a "load more results" link + this.msg.footer.innerHTML = + '"; + this.msg.footer + .querySelector("button") + .addEventListener("click", function (e) { + e.preventDefault(); + e.stopPropagation(); + _obj.getMoreReadTheDocsResults(); + return; + }); + } else { + this.msg.footer.innerHTML = ""; + } + + // Update the results message header + this.msg.header.innerHTML = + str && data.results.length == 0 + ? "" + + config["no-results"].label + + "

" + : ""; + + return this; + }; + + this.selectTab = function (e, inc) { + let tab = + e.target.tagName.toUpperCase() === "BUTTON" + ? e.target + : e.target.closest("button"); + let found = ""; + // Find out which project this tab is + for (var p = 0; p < this.projectorder.length; p++) { + let slug = this.projectorder[p]; + if (this.projects[slug].tab == tab) found = slug; + } + // If we've been provided an increment we find the appropriate project + if (typeof inc === "number") { + let idx = this.projectorder.indexOf(found); + let i = 0; + do { + idx = + (idx + inc + this.projectorder.length) % this.projectorder.length; + found = this.projectorder[idx]; + i++; + // We try again if the tab is hidden and we haven't run out of projects (avoid an infinite loop) + } while ( + this.projects[found].tab.style.display == "none" && + i < this.projectorder.length + ); + } + // Loop over projects setting the properties + for (let p = 0; p < this.projectorder.length; p++) { + let slug = this.projectorder[p]; + if (slug == found) { + // Update the selected tab + this.projects[slug].tab.setAttribute("aria-selected", "true"); + this.projects[slug].tab.setAttribute("tabindex", 0); + this.projects[slug].tab.focus(); + this.projects[slug].panel.style.display = ""; + this.projects[slug].panel.removeAttribute("hidden"); + } else { + // Deselect any others + this.projects[slug].tab.removeAttribute("aria-selected"); + this.projects[slug].tab.setAttribute("tabindex", -1); + this.projects[slug].panel.style.display = "none"; + this.projects[slug].panel.setAttribute("hidden", true); + } + } + if (found in this.projects) + this.projects[found].tab.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "nearest", + }); + return this; + }; + + this.navLi = function (e) { + var nextEl, prevEl; + if (e.target == inp) { + nextEl = + this.projects[this.selectedPanel].results.querySelector( + "li.kind-title", + ); + } else { + nextEl = next(e.target); + prevEl = prev(e.target); + if (!prevEl) prevEl = inp; + } + if (e.key == "ArrowDown") { + if (nextEl) { + nextEl.focus(); + nextEl.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "nearest", + }); + } + } else if (e.key == "ArrowUp") { + if (prevEl) { + prevEl.focus(); + prevEl.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "nearest", + }); + } + } else if (e.key == "Enter") { + var a = e.target.querySelector("a"); + if (a) location.href = a.getAttribute("href"); + } else if (e.key == "Escape") { + dialog.close(); + } + return; + }; + function next(el, selector) { + const nextEl = el.nextElementSibling; + if (!selector || (nextEl && nextEl.matches(selector))) return nextEl; + return null; + } + function prev(el, selector) { + const prevEl = el.previousElementSibling; + if (!selector || (prevEl && prevEl.matches(selector))) return prevEl; + return null; + } + function highlightAllMatches(text, search) { + const regex = new RegExp(`(${search})`, "gi"); + return text.replace(regex, "$1"); + } + return this; + } + + ready(function () { + let config = {}; + if (typeof set_search_config !== "undefined") config = set_search_config; + let search = new Search( + document.getElementById("pst-search-dialog"), + config, + ); + }); +})(window || this); diff --git a/src/sunpy_sphinx_theme/theme/sunpy/theme.conf b/src/sunpy_sphinx_theme/theme/sunpy/theme.conf index 7fe0ec38..7490b5fd 100644 --- a/src/sunpy_sphinx_theme/theme/sunpy/theme.conf +++ b/src/sunpy_sphinx_theme/theme/sunpy/theme.conf @@ -30,3 +30,9 @@ footer_links = # Override the default light pygment styles pygment_light_style = github-light + +# Enable project-wide search with RTD +rtd_search = True +rtd_search_projects = +rtd_search_load_more_label = +rtd_search_no_results_label =