This commit is contained in:
Johannes Marbach 2026-03-24 11:35:05 +01:00 committed by GitHub
commit e89365f324
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 466 additions and 2 deletions

View file

@ -236,6 +236,10 @@ jobs:
run: |
tar -C "spec${baseURL}" --strip-components=1 -xzf openapi.tar.gz
- name: "🔍 pagefind indexing"
run: |
npm run pagefind -- --site "spec${baseURL}"
- name: "📦 Tarball creation"
run: |
cd spec

View file

@ -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 && npm run 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

243
assets/js/offline-search.js Normal file
View file

@ -0,0 +1,243 @@
/*
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 () {
// This is going to be loaded from ${deployment}/js/main.js so to use a relative path
// to the Pagefind script we need to navigate one level up.
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("keypress", (event) => {
// Start searching only upon Enter.
if (event.which === 13) {
event.preventDefault();
render($(event.target));
return;
}
});
// 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 = $("<div>")
.addClass("spinner-container")
.append($("<div>")
.addClass("spinner-border")
.attr("role", "status")
.append($("<div>")
.addClass("visually-hidden")
.text("Loading...")))
.append($("<p>")
.text("Loading..."));
const popover = new bootstrap.Popover($targetSearchInput[0], {
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 = $("<div>");
// Add the header and close button.
$html.append($("<div>")
.css({
display: "flex",
justifyContent: "space-between",
marginBottom: "1em",
})
.append($("<span>")
.text("Search results")
.css({ fontWeight: "bold" }))
.append($("<button>")
.addClass("td-offline-search-results__close-button")
.addClass("btn")
.addClass("btn-sm")
.addClass("btn-link")
.attr("type", "button")
.attr("aria-label", "Close")
.on("click", () => {
$targetSearchInput.val("");
$targetSearchInput.trigger("change");
})
)
);
// Add the main search results body.
const $searchResultBody = $("<div>").css({
maxHeight: `calc(100vh - ${
$targetSearchInput.offset().top - $(window).scrollTop() + 180
}px)`,
overflowY: "auto",
});
$html.append($searchResultBody);
if (results.length === 0) {
$searchResultBody.append(
$("<p>").text(`No results found for query "${searchQuery}"`)
);
} else {
results.forEach((r, index_r) => {
// Add the main result's page title.
$searchResultBody.append($("<div>")
.append($("<a>")
.css({
fontSize: "1.2rem",
})
.attr("href", r.url)
.text(r.meta.title))
.append($("<span>")
.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 = `collapsible-subresults-${index_r}`;
const $action = $("<span>").text("▶ Show");
const $expander = $("<button>")
.attr("data-bs-toggle", "collapse")
.attr("data-bs-target", `#${wrapper_id}`)
.attr("aria-expanded", "false")
.attr("aria-controls", wrapper_id)
.attr("type", "button")
.addClass("td-offline-search-results__expander-button")
.addClass("btn")
.addClass("btn-sm")
.addClass("btn-link")
.append($action)
.append($("<span>").text(` ${num_hidden_results} more ${resultsString(num_hidden_results)} from ${r.meta.title}`));
$searchResultBody.append($("<p>").append($expander));
$wrapper = $("<div>")
.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 = $("<div>")
.css("margin-top", "0.5rem");
$entry.append(
$("<a>")
.addClass("d-block")
.attr("href", s.url)
.text(s.title)
);
$entry.append($("<p>").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')).empty().append($html);
popover.update();
};
});
})(jQuery);
//
// Helpers
//
const resultsString = (n) => {
return n === 1 ? "result" : "results";
};

View file

@ -661,3 +661,32 @@ 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;
}
.td-offline-search-results__close-button {
// Prevent the label from rendering white on white.
--bs-btn-color: unset;
}
.td-offline-search-results__expander-button {
// Prevent the label from rendering white on white.
--bs-btn-color: unset;
// Avoid any extra inset.
--bs-btn-padding-x: 0;
--bs-btn-padding-y: 0;
}

View file

@ -0,0 +1 @@
Add page search widget.

View file

@ -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,10 @@ 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.
# In future, we should switch to `wasm-unsafe-eval` but this doesn't yet work in Safari:
# 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"

View file

@ -0,0 +1,10 @@
<div class="td-search td-search--offline">
<div class="td-search__icon"></div>
<input
type="search"
class="td-search__input form-control"
placeholder="Search the spec"
aria-label="Search the spec"
autocomplete="off"
>
</div>

159
package-lock.json generated
View file

@ -12,6 +12,7 @@
"@fullhuman/postcss-purgecss": "^6.0.0",
"autoprefixer": "^10.4.20",
"node-fetch": "^2.6.7",
"pagefind": "^1.4.0",
"postcss": "^8.4.49",
"postcss-cli": "^11.0.0"
}
@ -170,6 +171,90 @@
"node": ">= 8"
}
},
"node_modules/@pagefind/darwin-arm64": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.4.0.tgz",
"integrity": "sha512-2vMqkbv3lbx1Awea90gTaBsvpzgRs7MuSgKDxW0m9oV1GPZCZbZBJg/qL83GIUEN2BFlY46dtUZi54pwH+/pTQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@pagefind/darwin-x64": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.4.0.tgz",
"integrity": "sha512-e7JPIS6L9/cJfow+/IAqknsGqEPjJnVXGjpGm25bnq+NPdoD3c/7fAwr1OXkG4Ocjx6ZGSCijXEV4ryMcH2E3A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@pagefind/freebsd-x64": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@pagefind/freebsd-x64/-/freebsd-x64-1.4.0.tgz",
"integrity": "sha512-WcJVypXSZ+9HpiqZjFXMUobfFfZZ6NzIYtkhQ9eOhZrQpeY5uQFqNWLCk7w9RkMUwBv1HAMDW3YJQl/8OqsV0Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@pagefind/linux-arm64": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.4.0.tgz",
"integrity": "sha512-PIt8dkqt4W06KGmQjONw7EZbhDF+uXI7i0XtRLN1vjCUxM9vGPdtJc2mUyVPevjomrGz5M86M8bqTr6cgDp1Uw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@pagefind/linux-x64": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.4.0.tgz",
"integrity": "sha512-z4oddcWwQ0UHrTHR8psLnVlz6USGJ/eOlDPTDYZ4cI8TK8PgwRUPQZp9D2iJPNIPcS6Qx/E4TebjuGJOyK8Mmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@pagefind/windows-x64": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@pagefind/windows-x64/-/windows-x64-1.4.0.tgz",
"integrity": "sha512-NkT+YAdgS2FPCn8mIA9bQhiBs+xmniMGq1LFPDhcFn0+2yIUEiIG06t7bsZlhdjknEQRTSdT7YitP6fC5qwP0g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -914,6 +999,24 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/pagefind": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pagefind/-/pagefind-1.4.0.tgz",
"integrity": "sha512-z2kY1mQlL4J8q5EIsQkLzQjilovKzfNVhX8De6oyE6uHpfFtyBaqUpcl/XzJC/4fjD8vBDyh1zolimIcVrCn9g==",
"dev": true,
"license": "MIT",
"bin": {
"pagefind": "lib/runner/bin.cjs"
},
"optionalDependencies": {
"@pagefind/darwin-arm64": "1.4.0",
"@pagefind/darwin-x64": "1.4.0",
"@pagefind/freebsd-x64": "1.4.0",
"@pagefind/linux-arm64": "1.4.0",
"@pagefind/linux-x64": "1.4.0",
"@pagefind/windows-x64": "1.4.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -1652,6 +1755,48 @@
"fastq": "^1.6.0"
}
},
"@pagefind/darwin-arm64": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.4.0.tgz",
"integrity": "sha512-2vMqkbv3lbx1Awea90gTaBsvpzgRs7MuSgKDxW0m9oV1GPZCZbZBJg/qL83GIUEN2BFlY46dtUZi54pwH+/pTQ==",
"dev": true,
"optional": true
},
"@pagefind/darwin-x64": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.4.0.tgz",
"integrity": "sha512-e7JPIS6L9/cJfow+/IAqknsGqEPjJnVXGjpGm25bnq+NPdoD3c/7fAwr1OXkG4Ocjx6ZGSCijXEV4ryMcH2E3A==",
"dev": true,
"optional": true
},
"@pagefind/freebsd-x64": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@pagefind/freebsd-x64/-/freebsd-x64-1.4.0.tgz",
"integrity": "sha512-WcJVypXSZ+9HpiqZjFXMUobfFfZZ6NzIYtkhQ9eOhZrQpeY5uQFqNWLCk7w9RkMUwBv1HAMDW3YJQl/8OqsV0Q==",
"dev": true,
"optional": true
},
"@pagefind/linux-arm64": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.4.0.tgz",
"integrity": "sha512-PIt8dkqt4W06KGmQjONw7EZbhDF+uXI7i0XtRLN1vjCUxM9vGPdtJc2mUyVPevjomrGz5M86M8bqTr6cgDp1Uw==",
"dev": true,
"optional": true
},
"@pagefind/linux-x64": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.4.0.tgz",
"integrity": "sha512-z4oddcWwQ0UHrTHR8psLnVlz6USGJ/eOlDPTDYZ4cI8TK8PgwRUPQZp9D2iJPNIPcS6Qx/E4TebjuGJOyK8Mmg==",
"dev": true,
"optional": true
},
"@pagefind/windows-x64": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@pagefind/windows-x64/-/windows-x64-1.4.0.tgz",
"integrity": "sha512-NkT+YAdgS2FPCn8mIA9bQhiBs+xmniMGq1LFPDhcFn0+2yIUEiIG06t7bsZlhdjknEQRTSdT7YitP6fC5qwP0g==",
"dev": true,
"optional": true
},
"@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -2117,6 +2262,20 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true
},
"pagefind": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pagefind/-/pagefind-1.4.0.tgz",
"integrity": "sha512-z2kY1mQlL4J8q5EIsQkLzQjilovKzfNVhX8De6oyE6uHpfFtyBaqUpcl/XzJC/4fjD8vBDyh1zolimIcVrCn9g==",
"dev": true,
"requires": {
"@pagefind/darwin-arm64": "1.4.0",
"@pagefind/darwin-x64": "1.4.0",
"@pagefind/freebsd-x64": "1.4.0",
"@pagefind/linux-arm64": "1.4.0",
"@pagefind/linux-x64": "1.4.0",
"@pagefind/windows-x64": "1.4.0"
}
},
"path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",

View file

@ -5,7 +5,8 @@
"main": "none.js",
"scripts": {
"get-proposals": "node ./scripts/proposals.js",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "echo \"Error: no test specified\" && exit 1",
"pagefind": "pagefind $@"
},
"repository": {
"type": "git",
@ -22,6 +23,7 @@
"@fullhuman/postcss-purgecss": "^6.0.0",
"autoprefixer": "^10.4.20",
"node-fetch": "^2.6.7",
"pagefind": "^1.4.0",
"postcss": "^8.4.49",
"postcss-cli": "^11.0.0"
}