From 834c6eb0f2210f1e959a36fcc3c96a6749d69a0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Wed, 7 Oct 2026 18:50:33 +0200 Subject: [PATCH] Site: search over the documentation (#60) 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 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- docs/milestones/W1.md | 14 ++++ site/content/dev/milestones/w1.md | 14 ++++ site/content/search.md | 5 ++ site/static/css/site.css | 23 ++++++ site/static/js/search.js | 120 ++++++++++++++++++++++++++++++ site/templates/base.html | 1 + site/templates/dev-index.html | 6 ++ site/templates/guide-index.html | 6 ++ site/templates/macros/search.html | 16 ++++ site/templates/search.html | 46 ++++++++++++ 10 files changed, 251 insertions(+) create mode 100644 site/content/search.md create mode 100644 site/static/js/search.js create mode 100644 site/templates/macros/search.html create mode 100644 site/templates/search.html diff --git a/docs/milestones/W1.md b/docs/milestones/W1.md index 32ea696..6ff169f 100644 --- a/docs/milestones/W1.md +++ b/docs/milestones/W1.md @@ -168,3 +168,17 @@ Q178 left publishing to the maintainer: a merge, then a command typed on the web | No secrets at all; only one of the four | Does nothing and says so; fails and says which are needed | **Not checked:** the real web server and the runner, which wait for the key to be installed: whether the runner reaches the server's SSH port is the first thing the first run will tell. Port forwarding, which `restrict` switches off, was not tried. `from=` was not tried either. + +## Search (issue #60) + +A search over the documentation: the user guide, the how-tos, the questions and answers, and the developer docs. Not the devlog. + +- **The index is the search page itself** (`/search/`, `templates/search.html`): one list item for each page and for each `##` heading of it, with that part's text, written by Zola from the pages' own content when the site is built. Nothing is fetched and nothing typed leaves the browser, so the Content-Security-Policy needs nothing new, and the web server still only runs `zola build`. +- **Without JavaScript** the page is a list of every page and heading of the documentation, each a link. +- **With it**, `js/search.js` filters and ranks the items as you type: every word has to be in the part; a word in a heading counts for most, the words side by side for more than scattered, and the user guide, the how-tos and the FAQ come before the developer docs, the milestones last. A result links to its heading, with the text around the match. +- **The content pages get no script for it:** the navigation has a link, and the index pages of the guide, the how-tos and the developer docs have a box that is a plain form to `/search/?q=`. +- **Size:** about 245 items, about 310 KB of HTML, under 100 KB compressed, loaded only by who searches. + +**Checked** in Chromium with the production Content-Security-Policy on every response, no violation: "probation" (the guide's "Probation and Rollback" first), "safe mode", "rm -r", "how big can a note" (the FAQ's question first), a word that isn't there; typing, following a result to its heading, the box on the guide's index, 390 px wide with no sideways scroll, and JavaScript off. `check_site.py` follows every link of the page, so an index entry can't point at a heading that doesn't exist. + +**Not checked:** other browsers, and a screen reader. diff --git a/site/content/dev/milestones/w1.md b/site/content/dev/milestones/w1.md index 40ece49..89a92b0 100644 --- a/site/content/dev/milestones/w1.md +++ b/site/content/dev/milestones/w1.md @@ -176,3 +176,17 @@ Q178 left publishing to the maintainer: a merge, then a command typed on the web | No secrets at all; only one of the four | Does nothing and says so; fails and says which are needed | **Not checked:** the real web server and the runner, which wait for the key to be installed: whether the runner reaches the server's SSH port is the first thing the first run will tell. Port forwarding, which `restrict` switches off, was not tried. `from=` was not tried either. + +## Search (issue #60) + +A search over the documentation: the user guide, the how-tos, the questions and answers, and the developer docs. Not the devlog. + +- **The index is the search page itself** (`/search/`, `templates/search.html`): one list item for each page and for each `##` heading of it, with that part's text, written by Zola from the pages' own content when the site is built. Nothing is fetched and nothing typed leaves the browser, so the Content-Security-Policy needs nothing new, and the web server still only runs `zola build`. +- **Without JavaScript** the page is a list of every page and heading of the documentation, each a link. +- **With it**, `js/search.js` filters and ranks the items as you type: every word has to be in the part; a word in a heading counts for most, the words side by side for more than scattered, and the user guide, the how-tos and the FAQ come before the developer docs, the milestones last. A result links to its heading, with the text around the match. +- **The content pages get no script for it:** the navigation has a link, and the index pages of the guide, the how-tos and the developer docs have a box that is a plain form to `/search/?q=`. +- **Size:** about 245 items, about 310 KB of HTML, under 100 KB compressed, loaded only by who searches. + +**Checked** in Chromium with the production Content-Security-Policy on every response, no violation: "probation" (the guide's "Probation and Rollback" first), "safe mode", "rm -r", "how big can a note" (the FAQ's question first), a word that isn't there; typing, following a result to its heading, the box on the guide's index, 390 px wide with no sideways scroll, and JavaScript off. `check_site.py` follows every link of the page, so an index entry can't point at a heading that doesn't exist. + +**Not checked:** other browsers, and a screen reader. diff --git a/site/content/search.md b/site/content/search.md new file mode 100644 index 0000000..0cfba1d --- /dev/null +++ b/site/content/search.md @@ -0,0 +1,5 @@ ++++ +title = "Search" +description = "Find a word in the user guide, the how-tos, the questions and answers, and the developer docs." +template = "search.html" ++++ diff --git a/site/static/css/site.css b/site/static/css/site.css index 57479cb..2b78a2f 100644 --- a/site/static/css/site.css +++ b/site/static/css/site.css @@ -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; } diff --git a/site/static/js/search.js b/site/static/js/search.js new file mode 100644 index 0000000..63708c1 --- /dev/null +++ b/site/static/js/search.js @@ -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(); + }); +})(); diff --git a/site/templates/base.html b/site/templates/base.html index adbc756..69bc4c6 100644 --- a/site/templates/base.html +++ b/site/templates/base.html @@ -33,6 +33,7 @@ Developers Downloads Devlog + Search Source diff --git a/site/templates/dev-index.html b/site/templates/dev-index.html index 5d15caf..93c7e70 100644 --- a/site/templates/dev-index.html +++ b/site/templates/dev-index.html @@ -11,6 +11,12 @@
{{ section.content | safe }}
+ +
{% for path in section.subsections %} {% set sub = get_section(path=path) %} diff --git a/site/templates/guide-index.html b/site/templates/guide-index.html index 3edecec..53cae2b 100644 --- a/site/templates/guide-index.html +++ b/site/templates/guide-index.html @@ -11,6 +11,12 @@
{{ section.content | safe }}
+ +
{% for p in section.pages %}
diff --git a/site/templates/macros/search.html b/site/templates/macros/search.html new file mode 100644 index 0000000..3cbc43f --- /dev/null +++ b/site/templates/macros/search.html @@ -0,0 +1,16 @@ +{# One entry of the search page for each part of a page: what comes before its first heading, + then each "## heading" with what follows it. The text is the page's own, tags taken out. #} +{% macro entries(page, group) %} +{% set parts = page.content | split(pat='

{{ page.title }}{{ group }}

{{ page.description }} {{ part | striptags | trim | safe }}

+{% else %} +{% set anchor = part | split(pat='"') | first %} +{% set head = part | split(pat="

") | first %} +{% set heading = head | split(pat='">') | slice(start=1) | join(sep='">') | striptags | trim %} +{% set body = part | split(pat="") | slice(start=1) | join(sep="") | striptags | trim %} +
  • {{ heading | safe }}{{ group }} · {{ page.title }}

    {{ body | safe }}

  • +{% endif %} +{% endfor %} +{% endmacro entries %} diff --git a/site/templates/search.html b/site/templates/search.html new file mode 100644 index 0000000..0e504b6 --- /dev/null +++ b/site/templates/search.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% import "macros/search.html" as search %} +{% block title %}{{ page.title }}: roro9stack{% endblock %} +{% block description %}{{ page.description }}{% endblock %} +{% block head %}{% endblock %} +{% block main %} +{# The whole index is in this page (issue #60): nothing is fetched, and without JavaScript it is + still a list of every page and heading of the documentation. js/search.js filters it. #} +
    +
    + Documentation +

    {{ page.title }}

    +

    {{ page.description }}

    +
    + + +

    + + +
    +

    Every page of the documentation and its headings. With JavaScript on, the box above searches their text.

    + {% set guide = get_section(path="guide/_index.md") %} +

    {{ guide.title }}

      + {% for p in guide.pages %}{{ search::entries(page=p, group="Guide") }}{% endfor %} +
    + {% set howto = get_section(path="howto/_index.md") %} +

    {{ howto.title }}

      + {% for p in howto.pages %}{{ search::entries(page=p, group="How-to") }}{% endfor %} +
    +

    Questions and answers

      + {{ search::entries(page=get_page(path="faq.md"), group="FAQ") }} +
    + {% set dev = get_section(path="dev/_index.md") %} + {% for path in dev.subsections %} + {% set sub = get_section(path=path) %} +

    Developers: {{ sub.title }}

      + {% for p in sub.pages %}{{ search::entries(page=p, group=sub.extra.search | default(value=sub.title)) }}{% endfor %} +
    + {% endfor %} +
    +
    +{% endblock main %} -- 2.53.0