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
This commit is contained in:
393
internal/docsite/theme/assets/search.js
Normal file
393
internal/docsite/theme/assets/search.js
Normal file
@@ -0,0 +1,393 @@
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user