diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 92b841f4..706b710f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -236,6 +236,10 @@ jobs: run: | tar -C "spec${baseURL}" --strip-components=1 -xzf openapi.tar.gz + - name: "🔍 pagefind indexing" + run: | + npx -y pagefind --site "spec${baseURL}" + - name: "📦 Tarball creation" run: | cd spec diff --git a/README.md b/README.md index 0678713b..d2bf3152 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,18 @@ We use a highly customized [Docsy](https://www.docsy.dev/) theme for our generat Awesome. If you're looking at making design-related changes to the spec site, please coordinate with us in [#matrix-docs:matrix.org](https://matrix.to/#/#matrix-docs:matrix.org) before opening a PR. +## Page search + +The spec uses [Pagefind](https://pagefind.app/) to provide a page search widget. To test this locally, you'll need to generate the +search index _after_ building the static site. + +``` +hugo build && npx -y pagefind --site public && hugo serve +``` + +Note that while `hugo serve` supports hot reloading, changes made to the site content won't reflect in the search index without +rebuilding it. + ## Building the specification If for some reason you're not a CI/CD system and want to render a static version of the spec for yourself, follow the above diff --git a/assets/js/offline-search.js b/assets/js/offline-search.js new file mode 100644 index 00000000..ebf65adf --- /dev/null +++ b/assets/js/offline-search.js @@ -0,0 +1,230 @@ +/* +Copyright 2026 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* +Adapted from [1] to combine Docsy"s built-in search UI with the Pagefind +search backend. + + [1]: https://github.com/matrix-org/docsy/blob/71d103ebb20ace3d528178c4b6d92b6cc4f7fd53/assets/js/offline-search.js +*/ + +(function ($) { + "use strict"; + + $(document).ready(async function () { + const pagefind = await import("/pagefind/pagefind.js"); + const $searchInput = $(".td-search input"); + + // + // Lazily initialise Pagefind only when the user is about to start a search. + // + + $searchInput.focus(() => { + pagefind.init(); + }); + + // + // Set up search input handler. + // + + $searchInput.on("change", (event) => { + render($(event.target)); + }); + + // Prevent reloading page by enter key on sidebar search. + $searchInput.closest("form").on("submit", () => { + return false; + }); + + // + // Callback for searching and rendering the results. + // + + const render = async ($targetSearchInput) => { + // + // Dispose any existing popover. + // + + { + let popover = bootstrap.Popover.getInstance($targetSearchInput[0]); + if (popover !== null) { + popover.dispose(); + } + } + + // + // Kick off the search and collect the results. + // + + const searchQuery = $targetSearchInput.val(); + if (searchQuery === "") { + return; + } + + // Show the results popover with a spinner while we're busy. + const $spinner = $("
") + .addClass("spinner-container") + .append($("
") + .addClass("spinner-border") + .attr("role", "status") + .append($("
") + .addClass("visually-hidden") + .text("Loading..."))) + .append($("

") + .text("Loading...")); + const popover = new bootstrap.Popover($targetSearchInput, { + content: $spinner[0], + html: true, + customClass: "td-offline-search-results", + placement: "bottom", + }); + popover.show(); + + // Kick off the search. + const search = await pagefind.debouncedSearch(searchQuery); + if (search === null) { + // A more recent search call has been made, nothing to do. + return; + } + + // Load all result details. We may want to switch to a paged UI in future for better performance. + const results = await Promise.all(search.results.slice(0, 100).map(r => r.data())); + + // + // Construct the search results html. + // + + const $html = $("

"); + + // Add the header and close button. + $html.append($("
") + .css({ + display: "flex", + justifyContent: "space-between", + marginBottom: "1em", + }) + .append($("") + .text("Search results") + .css({ fontWeight: "bold" })) + .append($("") + .addClass("td-offline-search-results__close-button") + .attr("role", "button") + .attr("aria-label", "Close") + .on("click", () => { + $targetSearchInput.val(""); + $targetSearchInput.trigger("change"); + }) + ) + ); + + // Add the main search results body. + const $searchResultBody = $("
").css({ + maxHeight: `calc(100vh - ${ + $targetSearchInput.offset().top - $(window).scrollTop() + 180 + }px)`, + overflowY: "auto", + }); + $html.append($searchResultBody); + + if (results.length === 0) { + $searchResultBody.append( + $("

").text(`No results found for query "${searchQuery}"`) + ); + } else { + results.forEach((r, index_r) => { + // Add the main result's page title. + $searchResultBody.append($("

") + .append($("") + .css({ + fontSize: "1.2rem", + }) + .attr("href", r.url) + .text(r.meta.title)) + .append($("") + .addClass("text-body-secondary") + .text(` – ${r.sub_results.length} ${resultsString(r.sub_results.length)}`))); + + // Render the first 3 subresults per page and wrap the rest + // in a collapsed container. + const LIMIT = 3; + let $wrapper = null; + + r.sub_results.forEach((s, index_s) => { + if (index_s === LIMIT) { + const num_hidden_results = r.sub_results.length - index_s; + const wrapper_id = `collapssible-subresults-${index_r}`; + const $action = $("").text("▶ Show"); + const $expander = $("") + .attr("data-bs-toggle", "collapse") + .attr("data-bs-target", `#${wrapper_id}`) + .attr("href", "#") + .attr("role", "button") + .attr("aria-expanded", "false") + .attr("aria-controls", wrapper_id) + .append($action) + .append($("").text(` ${num_hidden_results} more ${resultsString(num_hidden_results)} from ${r.meta.title}`)); + + $searchResultBody.append($("

").append($expander)); + $wrapper = $("

") + .addClass("collapse td-offline-search-results__subresults") + .attr("id", wrapper_id) + .on("hide.bs.collapse", _ => $action.text("▶ Show")) + .on("show.bs.collapse", _ => $action.text("▼ Hide")); + $searchResultBody.append($wrapper); + } + + const $entry = $("
") + .css("margin-top", "0.5rem"); + + $entry.append( + $("") + .addClass("d-block") + .attr("href", s.url) + .text(s.title) + ); + + $entry.append($("

").html(s.excerpt)); + + if (index_s < LIMIT) { + $searchResultBody.append($entry); + } else { + $wrapper.append($entry); + } + }); + }); + } + + // Finally, show the search results. + // + // Ideally we would just call setContent but there appears to be a bug in Bootstrap + // that causes the popover to be hidden when setContent is called after the popover + // has been shown. To work around this, we use the hack from [1] to inject the HTML + // content manually. + // + // [1]: https://github.com/twbs/bootstrap/issues/37206#issuecomment-1259541205 + $(popover.tip.querySelector('.popover-body')).html($html[0]); + popover.update(); + }; + }); +})(jQuery); + +// +// Helpers +// + +const resultsString = (n) => { + return n > 1 ? "results" : "result"; +}; diff --git a/assets/scss/_styles_project.scss b/assets/scss/_styles_project.scss index cd9937a9..f6a4897f 100644 --- a/assets/scss/_styles_project.scss +++ b/assets/scss/_styles_project.scss @@ -661,3 +661,19 @@ dd { margin: 0; } } + +/* Style for the page search widget */ +.td-offline-search-results { + width: 100%; + max-width: 460px; +} + +.td-offline-search-results .td-offline-search-results__subresults.collapse.show { + // Prevent the first child margin from collapsing upward when Bootstrap + // finishes the collapse animation, which otherwise causes a small jump. + display: flow-root; +} + +.td-offline-search-results .spinner-container { + text-align: center; +} diff --git a/changelogs/internal/newsfragments/2331.feature b/changelogs/internal/newsfragments/2331.feature new file mode 100644 index 00000000..eb94a36e --- /dev/null +++ b/changelogs/internal/newsfragments/2331.feature @@ -0,0 +1 @@ +Add page search widget. diff --git a/config/_default/hugo.toml b/config/_default/hugo.toml index a33f803b..eae2900f 100644 --- a/config/_default/hugo.toml +++ b/config/_default/hugo.toml @@ -66,6 +66,7 @@ description = "Home of the Matrix specification for decentralised communication" [params] copyright = "The Matrix.org Foundation C.I.C." +offlineSearch = true [params.version] # must be one of "unstable", "current", "historical" @@ -151,7 +152,9 @@ sidebar_menu_compact = true [server.headers.values] # `style-src 'unsafe-inline'` is needed to correctly render the maths in the Olm spec: # https://github.com/KaTeX/KaTeX/issues/4096 - Content-Security-Policy = "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self' data:; media-src 'self'; child-src 'self'; form-action 'self'; object-src 'self'" + # `script-src 'unsafe-eval'` is needed because Pagefind relies on it to load its Wasm: + # https://github.com/Pagefind/pagefind/blob/main/docs/content/docs/hosting.md + Content-Security-Policy = "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; img-src 'self' data:; connect-src 'self'; font-src 'self' data:; media-src 'self'; child-src 'self'; form-action 'self'; object-src 'self'" X-XSS-Protection = "1; mode=block" X-Content-Type-Options = "nosniff" # Strict-Transport-Security = "max-age=31536000; includeSubDomains; preload" diff --git a/layouts/_partials/search-input.html b/layouts/_partials/search-input.html new file mode 100644 index 00000000..7d4f5994 --- /dev/null +++ b/layouts/_partials/search-input.html @@ -0,0 +1,10 @@ +