matrix-spec/assets/js/offline-search.js
Johannes Marbach 76455209c2
Some checks are pending
Spec / 🔎 Validate OpenAPI specifications (push) Waiting to run
Spec / 🔎 Check Event schema examples (push) Waiting to run
Spec / 🔎 Check OpenAPI definitions examples (push) Waiting to run
Spec / 🔎 Check JSON Schemas inline examples (push) Waiting to run
Spec / ⚙️ Calculate baseURL for later jobs (push) Waiting to run
Spec / 🐍 Build OpenAPI definitions (push) Blocked by required conditions
Spec / 📢 Run towncrier for changelog (push) Waiting to run
Spec / 📖 Build the spec (push) Blocked by required conditions
Spec / 🔎 Validate generated HTML (push) Blocked by required conditions
Spec / 📖 Build the historical backup spec (push) Blocked by required conditions
Spec / Create release (push) Blocked by required conditions
Spell Check / Spell Check with Typos (push) Waiting to run
Add page search widget (Pagefind) (#2331)
2026-05-27 15:39:35 +00:00

299 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
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("keydown", (event) => {
// Start searching only upon Enter.
if (event.key === "Enter") {
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.
//
disposePopover($targetSearchInput);
//
// Check if we need to do a search at all.
//
const searchQuery = $targetSearchInput.val();
if (searchQuery === "") {
return;
}
//
// Prepare the results popover.
//
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("");
disposePopover($targetSearchInput);
})
)
);
// 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);
// Append a spinner while we're busy.
const $spinner = createSpinner();
$searchResultBody.append($spinner)
// Display the popover.
const popover = new bootstrap.Popover($targetSearchInput[0], {
content: $html[0],
html: true,
customClass: "td-offline-search-results",
placement: "bottom",
});
popover.show();
//
// Kick off the search, load the results and inject them into the popover.
//
// First search all non-changelog pages.
const search = await pagefind.debouncedSearch(searchQuery, { filters: { not: { type: "changelog" } } });
if (search === null) {
// A more recent search call has been made, nothing to do.
return;
}
// Now search the changelog pages and append any results so that changelog
// results are ranked lowest. We cannot use Pagefind's sorting to do this
// because that would override the default ranking within the non-changelog
// results
const changelogSearch = await pagefind.search(searchQuery, { filters: { type: "changelog" } });
const results = [...search.results, ...changelogSearch.results];
if (results.length === 0) {
$searchResultBody.append(
$("<p>").text(`No results found for query "${searchQuery}"`)
);
} else {
await loadAndRenderResults(results, 0, $spinner, $searchResultBody);
}
};
});
})(jQuery);
//
// Helpers
//
const disposePopover = ($targetSearchInput) => {
const popover = bootstrap.Popover.getInstance($targetSearchInput[0]);
if (popover !== null) {
popover.dispose();
}
}
const createSpinner = () => {
return $("<div>")
.addClass("spinner-container")
.append($("<div>")
.addClass("spinner-border")
.attr("role", "status")
.append($("<div>")
.addClass("visually-hidden")
.text("Loading...")))
.append($("<p>")
.text("Loading..."));
}
const loadAndRenderResults = async (results, offset, $spinner, $searchResultBody) => {
// Load and render the first three results and hide the remainder behind a
// button to not freeze the browser by loading results that may not be
// displayed.
const LIMIT = 3;
for (const [index, result] of results.entries()) {
if (index < LIMIT) {
// Insert a container for the result *before* the spinner. This
// will push down the spinner as new content is loaded and keep
// it at the end of the popover.
const $container = $("<div>");
$spinner.before($container);
renderResult(await result.data(), index + offset, $container);
} else if (index === LIMIT) {
const num_hidden_results = results.length - index;
const $loader = $("<button>")
.attr("type", "button")
.addClass("td-offline-search-results__expander-button")
.addClass("btn")
.addClass("btn-sm")
.addClass("btn-link")
.text(`Load more results from ${num_hidden_results} other ${pagesString(num_hidden_results)}`)
.on("click", async () => {
// Remove the button.
$loader.remove();
// Add a spinner while we're busy.
const $spinner = createSpinner();
$searchResultBody.append($spinner)
// Load and render the results.
await loadAndRenderResults(results.slice(LIMIT), LIMIT + offset, $spinner, $searchResultBody);
});
$spinner.before($loader)
}
}
// Remove the spinner now that everything was loaded.
$spinner.remove();
}
const renderResult = (data, index, $container) => {
// Add the main result's page title.
$container.append($("<div>")
.append($("<a>")
.css({
fontSize: "1.2rem",
})
.attr("href", data.url)
.text(data.meta.title))
.append($("<span>")
.addClass("text-body-secondary")
.text(` ${data.sub_results.length} ${resultsString(data.sub_results.length)}`)));
// Render the first 3 subresults per page and wrap the rest
// in a collapsed container.
const LIMIT = 3;
let $wrapper = null;
data.sub_results.forEach((s, index_s) => {
if (index_s === LIMIT) {
const num_hidden_results = data.sub_results.length - index_s;
const wrapper_id = `collapsible-subresults-${index}`;
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 ${data.meta.title}`));
$container.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"));
$container.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) {
$container.append($entry);
} else {
$wrapper.append($entry);
}
});
};
const resultsString = (n) => {
return n === 1 ? "result" : "results";
};
const pagesString = (n) => {
return n === 1 ? "page" : "pages";
};