- 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
394 lines
11 KiB
JavaScript
394 lines
11 KiB
JavaScript
// 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();
|
||
}
|
||
});
|
||
})();
|