Public Access
A search page whose index is the page itself: one item for each page and each heading of the guide, the how-tos, the FAQ and the developer docs, written by Zola from the pages' own content. A small script filters and ranks them as you type. Nothing is fetched, so the Content-Security-Policy needs nothing new; without JavaScript the page is a list of every heading. The navigation gets a link, and the documentation's index pages a box that is a plain form to /search/?q=. The devlog is not searched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
@@ -263,3 +263,26 @@ footer small { display: block; max-width: 760px; }
|
||||
.prose .keys td { padding: 4px 8px 4px 0; border-bottom: 0; font-size: 15px; }
|
||||
.prose .keys td:first-child { white-space: nowrap; width: 1%; padding-right: 16px; }
|
||||
.prose .keys kbd { white-space: pre; }
|
||||
|
||||
/* Search (issue #60): the search page's box, its results and its index; the small box on the
|
||||
documentation's index pages. */
|
||||
.search-form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 24px 0 8px; max-width: 720px; }
|
||||
.search-form label { flex-basis: 100%; font: 400 14px/20px var(--mono); color: var(--muted); }
|
||||
.search-form input { flex: 1 1 220px; min-width: 0; min-height: 44px; padding: 0 12px; font: 400 16px/24px var(--sans); color: var(--ink); background: var(--s2); border: 1px solid var(--line); }
|
||||
.search-form input:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
|
||||
.search-mini { margin: 16px 0 32px; max-width: 520px; }
|
||||
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
.s-status { min-height: 24px; margin: 8px 0; }
|
||||
.s-results, .s-index ul { list-style: none; margin: 0; padding: 0; max-width: 720px; }
|
||||
.s-results li { padding: 16px 0; border-top: 1px solid var(--line); }
|
||||
.s-results a { font: 500 18px/24px var(--sans); }
|
||||
.s-results p { margin: 4px 0 0; color: var(--muted); overflow-wrap: anywhere; }
|
||||
.s-results mark { background: none; color: var(--orangetext); font-weight: 600; }
|
||||
.s-where { display: block; font: 400 12px/16px var(--mono); color: var(--muted); }
|
||||
.s-index section { margin: 0 0 32px; }
|
||||
.s-index h2 { font: 500 14px/20px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--cyantext); }
|
||||
.s-index li { padding: 2px 0; }
|
||||
.s-index li.s-part { padding-left: 20px; }
|
||||
.s-index .s-where, .s-index .s-text { display: none; }
|
||||
.s-index a:hover { text-decoration: underline; text-underline-offset: 4px; }
|
||||
.js .s-nojs { display: none; }
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// The search page (issue #60). The index is the page itself: one list item for each page of the
|
||||
// documentation and each of its headings, with that part's text. This filters and ranks them.
|
||||
// Nothing is fetched, and nothing typed here leaves the browser.
|
||||
(function () {
|
||||
// Where a match counts for more: what a user came for before what a developer wrote down.
|
||||
var weight = { "Guide": 1.4, "How-to": 1.3, "FAQ": 1.3, "Decisions": 0.8, "Milestones": 0.5 };
|
||||
var kMax = 40, kAround = 90;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var input = document.getElementById("q"), results = document.getElementById("s-results");
|
||||
var status = document.getElementById("s-status"), index = document.getElementById("s-index");
|
||||
if (!input || !results || !index) return;
|
||||
|
||||
var entries = Array.prototype.map.call(index.querySelectorAll(".s-entry"), function (li) {
|
||||
var link = li.querySelector("a"), where = li.querySelector(".s-where"), text = li.querySelector(".s-text");
|
||||
var body = text ? text.textContent.replace(/\s+/g, " ").trim() : "";
|
||||
return {
|
||||
href: link.getAttribute("href"), title: link.textContent, where: where ? where.textContent : "",
|
||||
body: body, titleLow: link.textContent.toLowerCase(), whereLow: (where ? where.textContent : "").toLowerCase(),
|
||||
bodyLow: body.toLowerCase(), weight: weight[li.getAttribute("data-group")] || 1
|
||||
};
|
||||
});
|
||||
|
||||
function count(hay, word) {
|
||||
var n = 0, at = hay.indexOf(word);
|
||||
while (at >= 0 && n < 5) { n++; at = hay.indexOf(word, at + word.length); }
|
||||
return n;
|
||||
}
|
||||
|
||||
function search(words) {
|
||||
var hits = [], phrase = words.join(" ");
|
||||
entries.forEach(function (e) {
|
||||
// The words as typed, side by side, count for more than the same words scattered.
|
||||
var score = words.length > 1 ? (e.titleLow.indexOf(phrase) >= 0 ? 30 : 0) + (e.bodyLow.indexOf(phrase) >= 0 ? 12 : 0) : 0;
|
||||
if (e.titleLow === phrase) score += 20;
|
||||
for (var i = 0; i < words.length; i++) {
|
||||
var w = words[i], inTitle = e.titleLow.indexOf(w) >= 0, inWhere = e.whereLow.indexOf(w) >= 0, n = count(e.bodyLow, w);
|
||||
if (!inTitle && !inWhere && !n) return; // every word has to be there
|
||||
score += (inTitle ? 20 : 0) + (inWhere ? 3 : 0) + n;
|
||||
}
|
||||
hits.push({ entry: e, score: score * e.weight });
|
||||
});
|
||||
hits.sort(function (a, b) { return b.score - a.score; });
|
||||
return hits;
|
||||
}
|
||||
|
||||
// The text around the first word found, with every word marked.
|
||||
function snippet(e, words) {
|
||||
var p = document.createElement("p"), first = -1;
|
||||
words.forEach(function (w) {
|
||||
var at = e.bodyLow.indexOf(w);
|
||||
if (at >= 0 && (first < 0 || at < first)) first = at;
|
||||
});
|
||||
var from = Math.max(0, (first < 0 ? 0 : first) - kAround), to = Math.min(e.body.length, from + 2 * kAround + 40);
|
||||
if (from > 0) { var space = e.body.indexOf(" ", from); if (space >= 0 && space < from + 20) from = space + 1; }
|
||||
var piece = e.body.slice(from, to), low = piece.toLowerCase(), at = 0;
|
||||
if (from > 0) p.appendChild(document.createTextNode("… "));
|
||||
while (at < piece.length) {
|
||||
var next = -1, len = 0;
|
||||
words.forEach(function (w) {
|
||||
var i = low.indexOf(w, at);
|
||||
if (i >= 0 && (next < 0 || i < next)) { next = i; len = w.length; }
|
||||
});
|
||||
if (next < 0) { p.appendChild(document.createTextNode(piece.slice(at))); break; }
|
||||
if (next > at) p.appendChild(document.createTextNode(piece.slice(at, next)));
|
||||
var mark = document.createElement("mark");
|
||||
mark.textContent = piece.slice(next, next + len);
|
||||
p.appendChild(mark);
|
||||
at = next + len;
|
||||
}
|
||||
if (to < e.body.length) p.appendChild(document.createTextNode(" …"));
|
||||
return p;
|
||||
}
|
||||
|
||||
function show() {
|
||||
var q = input.value.trim(), words = q.toLowerCase().split(/\s+/).filter(function (w) { return w.length > 0; });
|
||||
while (results.firstChild) results.removeChild(results.firstChild);
|
||||
try { history.replaceState(null, "", q ? "?q=" + encodeURIComponent(q) : location.pathname); } catch (e) { /* a file: page */ }
|
||||
if (!words.length) {
|
||||
results.hidden = true;
|
||||
index.hidden = false;
|
||||
status.textContent = "";
|
||||
return;
|
||||
}
|
||||
var hits = search(words);
|
||||
hits.slice(0, kMax).forEach(function (h) {
|
||||
var li = document.createElement("li"), a = document.createElement("a"), where = document.createElement("span");
|
||||
a.href = h.entry.href;
|
||||
a.className = "accent-link";
|
||||
a.textContent = h.entry.title;
|
||||
where.className = "s-where";
|
||||
where.textContent = h.entry.where;
|
||||
li.appendChild(a);
|
||||
li.appendChild(where);
|
||||
li.appendChild(snippet(h.entry, words));
|
||||
results.appendChild(li);
|
||||
});
|
||||
results.hidden = false;
|
||||
index.hidden = true;
|
||||
status.textContent = !hits.length ? "Nothing found for “" + q + "”. Every word has to be on the page."
|
||||
: (hits.length > kMax ? "The first " + kMax + " of " + hits.length : hits.length === 1 ? "1 place" : hits.length + " places") + " for “" + q + "”.";
|
||||
}
|
||||
|
||||
var timer = 0;
|
||||
input.addEventListener("input", function () {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(show, 80);
|
||||
});
|
||||
input.form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
show();
|
||||
});
|
||||
var asked = /[?&]q=([^&]*)/.exec(location.search);
|
||||
if (asked) {
|
||||
try { input.value = decodeURIComponent(asked[1].replace(/\+/g, " ")); } catch (e) { /* a bad escape: an empty box */ }
|
||||
}
|
||||
show();
|
||||
input.focus();
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user