Files
summercms/internal/docsite/theme/assets/search.js
Jakub Zych dd11bdb0c7 feat(11.1-02): add the documentation theme, chroma highlighting and docs:serve
- WinterCMS-style shell: header with search and theme toggle, grouped
  sidebar, on-page TOC, pager, page actions, callouts, heading
  permalinks, footer and a 404 page
- fenced code highlighted at build time by chroma/v2 into tok-* classes,
  with a copy button; no inline script, style or handler
- vendored DM Sans/DM Mono fonts and Lucide icons with their licences
- client-side search over search-index.json built with textContent only
- summer docs:serve builds into a temp dir, serves on loopback by default,
  returns 404.html with status 404 and rebuilds on change
2026-09-30 21:57:55 +02:00

394 lines
11 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.
// SummerCMS docs: client-side search over search-index.json.
// The index is fetched lazily on the first open and cached. Result rows are
// built with createElement and textContent only; indexed text is never
// parsed as HTML.
(function () {
"use strict";
var MAX_RESULTS = 20;
var QUERY_KEY = "summer-docs-query";
var dialog = document.getElementById("search");
var input = document.getElementById("search-input");
var list = document.getElementById("search-results");
var status = document.getElementById("search-status");
var live = document.getElementById("search-live");
var trigger = document.querySelector(".search-trigger");
if (!dialog || !input || !list || !status || typeof dialog.showModal !== "function") {
return;
}
var items = null; // null until loaded
var loading = null; // the pending load promise
var loadError = ""; // "server" or "index" after a failed load
var results = [];
var activeIndex = -1;
var isMac = /mac/i.test((navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || "");
var kbd = trigger && trigger.querySelector("kbd");
if (kbd && isMac) {
kbd.textContent = "⌘ K";
}
try {
input.value = window.sessionStorage.getItem(QUERY_KEY) || "";
} catch (e) {
// No session storage: start empty.
}
function el(tag, className, text) {
var node = document.createElement(tag);
if (className) {
node.className = className;
}
if (text !== undefined) {
node.textContent = text;
}
return node;
}
// appendRich appends text in which `code spans` become <code> elements.
function appendRich(parent, text) {
var parts = text.split("`");
for (var i = 0; i < parts.length; i++) {
if (!parts[i]) {
continue;
}
parent.appendChild(i % 2 ? el("code", "", parts[i]) : document.createTextNode(parts[i]));
}
}
function setStatus(title, body) {
status.textContent = "";
if (title) {
status.appendChild(el("p", "search-status-title", title));
}
if (body) {
var p = el("p");
appendRich(p, body);
status.appendChild(p);
}
}
function announce(text) {
if (live) {
live.textContent = text;
}
}
function clearResults() {
results = [];
activeIndex = -1;
list.textContent = "";
input.setAttribute("aria-expanded", "false");
input.setAttribute("aria-activedescendant", "");
}
function load() {
if (items || loading) {
return loading || Promise.resolve();
}
setStatus("", "Loading the search index…");
loading = fetch(list.getAttribute("data-index"))
.then(
function (res) {
if (!res.ok) {
throw new Error("index");
}
return res.json().catch(function () {
throw new Error("index");
});
},
function () {
throw new Error("server");
}
)
.then(function (data) {
items = buildItems(data);
loadError = "";
})
.catch(function (err) {
loadError = err && err.message === "server" ? "server" : "index";
})
.then(function () {
loading = null;
update();
});
return loading;
}
// buildItems flattens the index: one item per page (title hit) and one
// per section entry (heading and text hits).
function buildItems(data) {
if (!data || !Array.isArray(data.p) || !Array.isArray(data.e)) {
throw new Error("index");
}
var out = [];
data.p.forEach(function (page) {
out.push({ url: page.u, title: page.t, section: page.s, heading: page.t, text: "", pageHit: true });
});
data.e.forEach(function (entry) {
var page = data.p[entry.p];
if (!page) {
return;
}
out.push({
url: page.u + (entry.a ? "#" + entry.a : ""),
title: page.t,
section: page.s,
heading: entry.h || page.t,
text: entry.x || "",
pageHit: false
});
});
return out;
}
function tokenize(q) {
return q.toLowerCase().split(/\s+/).filter(Boolean);
}
// search ranks with AND-token matching: a title hit outranks a heading
// hit, which outranks a text hit.
function search(tokens) {
var scored = [];
for (var i = 0; i < items.length; i++) {
var it = items[i];
var title = it.title.toLowerCase();
var heading = it.pageHit ? "" : it.heading.toLowerCase();
var text = it.text.toLowerCase();
var score = 0;
var all = true;
for (var j = 0; j < tokens.length; j++) {
var t = tokens[j];
var s = 0;
if (title.indexOf(t) >= 0) {
s += 100;
}
if (heading.indexOf(t) >= 0) {
s += 10;
}
if (text.indexOf(t) >= 0) {
s += 1;
}
if (!s) {
all = false;
break;
}
score += s;
}
if (all) {
if (it.pageHit) {
score += 5;
}
scored.push({ item: it, score: score, order: i });
}
}
scored.sort(function (a, b) {
return b.score - a.score || a.order - b.order;
});
return scored.slice(0, MAX_RESULTS).map(function (s) {
return s.item;
});
}
// appendMarked appends text with every token occurrence wrapped in a
// <mark> element.
function appendMarked(parent, text, tokens) {
var lower = text.toLowerCase();
var pos = 0;
while (pos < text.length) {
var best = -1;
var len = 0;
for (var i = 0; i < tokens.length; i++) {
var at = lower.indexOf(tokens[i], pos);
if (at >= 0 && (best < 0 || at < best || (at === best && tokens[i].length > len))) {
best = at;
len = tokens[i].length;
}
}
if (best < 0) {
parent.appendChild(document.createTextNode(text.slice(pos)));
return;
}
if (best > pos) {
parent.appendChild(document.createTextNode(text.slice(pos, best)));
}
parent.appendChild(el("mark", "", text.slice(best, best + len)));
pos = best + len;
}
}
// excerpt starts the text near its first match.
function excerpt(text, tokens) {
var lower = text.toLowerCase();
var first = -1;
tokens.forEach(function (t) {
var at = lower.indexOf(t);
if (at >= 0 && (first < 0 || at < first)) {
first = at;
}
});
if (first > 60) {
var start = text.lastIndexOf(" ", first - 40);
return "…" + text.slice(start > 0 ? start + 1 : first - 40);
}
return text;
}
function render(tokens) {
clearResults();
results = search(tokens);
if (!results.length) {
setStatus('No results for "' + input.value.trim() + '"', "Check the spelling, or try a shorter term such as a module or command name.");
announce("No results");
return;
}
setStatus("", "");
results.forEach(function (it, i) {
var li = el("li", "search-result");
li.id = "search-result-" + i;
li.setAttribute("role", "option");
li.setAttribute("aria-selected", "false");
li.appendChild(el("div", "search-result-path", it.section + " › " + it.title));
var heading = el("div", "search-result-heading");
appendMarked(heading, it.heading, tokens);
li.appendChild(heading);
if (it.text) {
var ex = el("div", "search-result-excerpt");
appendMarked(ex, excerpt(it.text, tokens), tokens);
li.appendChild(ex);
}
li.addEventListener("click", function () {
go(i);
});
li.addEventListener("mousemove", function () {
setActive(i);
});
list.appendChild(li);
});
input.setAttribute("aria-expanded", "true");
setActive(0);
announce(results.length === 1 ? "1 result" : results.length + " results");
}
function setActive(i) {
var rows = list.children;
if (!rows.length) {
return;
}
if (activeIndex >= 0 && rows[activeIndex]) {
rows[activeIndex].setAttribute("aria-selected", "false");
}
activeIndex = (i + rows.length) % rows.length;
var row = rows[activeIndex];
row.setAttribute("aria-selected", "true");
input.setAttribute("aria-activedescendant", row.id);
if (row.scrollIntoView) {
row.scrollIntoView({ block: "nearest" });
}
}
function go(i) {
var it = results[i];
if (!it) {
return;
}
dialog.close();
window.location.href = it.url;
}
function update() {
try {
window.sessionStorage.setItem(QUERY_KEY, input.value);
} catch (e) {
// Session-only query is optional.
}
if (loadError === "server") {
clearResults();
setStatus("", "Search needs a web server. Run `summer docs:serve` and open the address it prints.");
return;
}
if (loadError === "index") {
clearResults();
setStatus("", "The search index could not be loaded. Reload the page to try again.");
return;
}
if (!items) {
clearResults();
setStatus("", "Loading the search index…");
return;
}
var tokens = tokenize(input.value);
if (!tokens.length) {
clearResults();
setStatus("Search the documentation", "Type a module name, a command or a topic, for example `surf`, `migrate` or `relations`.");
announce("");
return;
}
render(tokens);
}
function open() {
if (dialog.open) {
return;
}
dialog.showModal();
input.focus();
input.select();
if (!items && !loadError) {
load();
}
update();
}
if (trigger) {
trigger.addEventListener("click", open);
}
dialog.addEventListener("close", function () {
if (trigger) {
trigger.focus();
}
});
// A click on the backdrop (outside the dialog box) closes the dialog.
dialog.addEventListener("click", function (e) {
if (e.target === dialog) {
dialog.close();
}
});
input.addEventListener("input", update);
input.addEventListener("keydown", function (e) {
if (e.key === "ArrowDown") {
e.preventDefault();
setActive(activeIndex + 1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive(activeIndex - 1);
} else if (e.key === "Enter") {
e.preventDefault();
go(activeIndex);
}
});
function inTextField(target) {
if (!target) {
return false;
}
var tag = target.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable;
}
document.addEventListener("keydown", function (e) {
if ((e.metaKey || e.ctrlKey) && !e.altKey && (e.key === "k" || e.key === "K")) {
e.preventDefault();
open();
} else if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey && !inTextField(e.target) && !dialog.open) {
e.preventDefault();
open();
}
});
})();