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:
Jakub Zych
2026-09-30 21:57:55 +02:00
parent d89e18bc8e
commit dd11bdb0c7
39 changed files with 3114 additions and 145 deletions

View 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();
}
});
})();