Public Access
Site: search over the documentation (#60) #84
@@ -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 |
|
| 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.
|
**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.
|
||||||
|
|||||||
@@ -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 |
|
| 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.
|
**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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
+++
|
||||||
@@ -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 { 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 td:first-child { white-space: nowrap; width: 1%; padding-right: 16px; }
|
||||||
.prose .keys kbd { white-space: pre; }
|
.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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
<a href="/dev/">Developers</a>
|
<a href="/dev/">Developers</a>
|
||||||
<a href="/downloads/">Downloads</a>
|
<a href="/downloads/">Downloads</a>
|
||||||
<a href="/devlog/">Devlog</a>
|
<a href="/devlog/">Devlog</a>
|
||||||
|
<a href="/search/">Search</a>
|
||||||
<button class="link-btn js-only" id="theme-toggle" type="button">Light</button>
|
<button class="link-btn js-only" id="theme-toggle" type="button">Light</button>
|
||||||
<a class="btn btn-primary n-md" href="{{ config.extra.repo }}">Source</a>
|
<a class="btn btn-primary n-md" href="{{ config.extra.repo }}">Source</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
|
|
||||||
<div class="prose">{{ section.content | safe }}</div>
|
<div class="prose">{{ section.content | safe }}</div>
|
||||||
|
|
||||||
|
<form class="search-form search-mini" action="/search/" method="get" role="search">
|
||||||
|
<label class="sr" for="q">Search the documentation</label>
|
||||||
|
<input id="q" name="q" type="search" autocomplete="off" spellcheck="false" placeholder="Search the documentation">
|
||||||
|
<button class="btn n-md" type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
{% for path in section.subsections %}
|
{% for path in section.subsections %}
|
||||||
{% set sub = get_section(path=path) %}
|
{% set sub = get_section(path=path) %}
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
|
|
||||||
<div class="prose">{{ section.content | safe }}</div>
|
<div class="prose">{{ section.content | safe }}</div>
|
||||||
|
|
||||||
|
<form class="search-form search-mini" action="/search/" method="get" role="search">
|
||||||
|
<label class="sr" for="q">Search the documentation</label>
|
||||||
|
<input id="q" name="q" type="search" autocomplete="off" spellcheck="false" placeholder="Search the documentation">
|
||||||
|
<button class="btn n-md" type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
{% for p in section.pages %}
|
{% for p in section.pages %}
|
||||||
<article class="card n-lg">
|
<article class="card n-lg">
|
||||||
|
|||||||
@@ -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='<h2 id="') %}
|
||||||
|
{% for part in parts %}
|
||||||
|
{% if loop.first %}
|
||||||
|
<li class="s-entry" data-group="{{ group }}"><a href="{{ page.path | safe }}">{{ page.title }}</a><span class="s-where">{{ group }}</span><p class="s-text">{{ page.description }} {{ part | striptags | trim | safe }}</p></li>
|
||||||
|
{% else %}
|
||||||
|
{% set anchor = part | split(pat='"') | first %}
|
||||||
|
{% set head = part | split(pat="</h2>") | first %}
|
||||||
|
{% set heading = head | split(pat='">') | slice(start=1) | join(sep='">') | striptags | trim %}
|
||||||
|
{% set body = part | split(pat="</h2>") | slice(start=1) | join(sep="</h2>") | striptags | trim %}
|
||||||
|
<li class="s-entry s-part" data-group="{{ group }}"><a href="{{ page.path | safe }}#{{ anchor }}">{{ heading | safe }}</a><span class="s-where">{{ group }} · {{ page.title }}</span><p class="s-text">{{ body | safe }}</p></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endmacro entries %}
|
||||||
@@ -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 %}<script src="/js/search.js" defer></script>{% 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. #}
|
||||||
|
<div class="wrap page">
|
||||||
|
<header>
|
||||||
|
<span class="eyebrow">Documentation</span>
|
||||||
|
<h1>{{ page.title }}</h1>
|
||||||
|
<p class="lead">{{ page.description }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="search-form" action="/search/" method="get" role="search">
|
||||||
|
<label for="q">Search the guide, the how-tos, the FAQ and the developer docs</label>
|
||||||
|
<input id="q" name="q" type="search" autocomplete="off" spellcheck="false" placeholder="Probation, Safe Mode, rm -r, ...">
|
||||||
|
<button class="btn btn-primary n-md" type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
<p class="s-status muted" id="s-status" role="status" aria-live="polite"></p>
|
||||||
|
<ol class="s-results" id="s-results" hidden></ol>
|
||||||
|
|
||||||
|
<div class="s-index" id="s-index">
|
||||||
|
<p class="muted s-nojs">Every page of the documentation and its headings. With JavaScript on, the box above searches their text.</p>
|
||||||
|
{% set guide = get_section(path="guide/_index.md") %}
|
||||||
|
<section><h2>{{ guide.title }}</h2><ul>
|
||||||
|
{% for p in guide.pages %}{{ search::entries(page=p, group="Guide") }}{% endfor %}
|
||||||
|
</ul></section>
|
||||||
|
{% set howto = get_section(path="howto/_index.md") %}
|
||||||
|
<section><h2>{{ howto.title }}</h2><ul>
|
||||||
|
{% for p in howto.pages %}{{ search::entries(page=p, group="How-to") }}{% endfor %}
|
||||||
|
</ul></section>
|
||||||
|
<section><h2>Questions and answers</h2><ul>
|
||||||
|
{{ search::entries(page=get_page(path="faq.md"), group="FAQ") }}
|
||||||
|
</ul></section>
|
||||||
|
{% set dev = get_section(path="dev/_index.md") %}
|
||||||
|
{% for path in dev.subsections %}
|
||||||
|
{% set sub = get_section(path=path) %}
|
||||||
|
<section><h2>Developers: {{ sub.title }}</h2><ul>
|
||||||
|
{% for p in sub.pages %}{{ search::entries(page=p, group=sub.extra.search | default(value=sub.title)) }}{% endfor %}
|
||||||
|
</ul></section>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock main %}
|
||||||
Reference in New Issue
Block a user