Files
libertytoolsio/themes/hugo-book/layouts/_partials/docs/search.html
T
noether 0d24df31ad search: replace Fuse.js with Pagefind (Default UI) + full UI polish
Remove the legacy Fuse.js search from html-head.html (it double-loaded
with the new Pagefind UI; backup kept at html-head.html.bkup) and add a
Pagefind Default-UI partial (search.html) injected into the sidebar via
menu.html.

Index scoping: tag the article element with data-pagefind-body in
baseof.html, term.html, and posts/{single,list}.html so Pagefind indexes
page content only (pages without the tag are skipped; 404.html stays
untagged).

search.html (all overrides #search-scoped, since pagefind-ui.css loads in
<body> after the head CSS):
  - compact input/magnifier/clear-button fitted to the sidebar; clear
    button uses fixed px (not %) because the results drawer is an in-flow
    child of the form, which stretches the form to results height
  - hide the empty .result-thumb placeholder (no images in the index) that
    was pushing result text ~90px right
  - restyle matched-term <mark> from UA yellow to subdued slate
  - keyboard navigation (Pagefind 1.5.2 has none; stock Enter is a silent
    no-op): capture-phase keydown implements ArrowUp/Down highlighting with
    wraparound across top-level hits AND their nested sub-results (each sub
    highlights its own .result-nested row), Enter opens the highlighted
    result (or the top result), and a fingerprint-aware MutationObserver
    keeps the highlight across Pagefind's async re-renders without
    resetting it on every DOM patch
2026-10-11 08:59:18 -06:00

185 lines
9.7 KiB
HTML

{{ if default true .Site.Params.BookSearch }}
<link href="/pagefind/pagefind-ui.css" rel="stylesheet">
<script src="/pagefind/pagefind-ui.js"></script>
<style>
/* Pagefind Default UI -> fit the sidebar. The #search id prefix gives id+class
specificity that beats pagefind-ui.css's class-only rules no matter where that
stylesheet loads (it loads in <body>, after the head CSS). */
#search .pagefind-ui__search-input { font-size: 0.85em; height: 2.4em; min-height: 2.4em; padding: 0 2.6em 0 2.4em; }
/* Magnifier: re-center it in the shrunken input; input padding-left 2.4em clears
the icon's right edge. */
#search .pagefind-ui__form:before { top: 7px; left: 12px; width: 14px; height: 14px; }
/* Fade the magnifier out while the input has content (no :has() -> icon stays). */
#search .pagefind-ui__form:has(.pagefind-ui__search-input:not(:placeholder-shown)):before { opacity: 0; }
/* Clear button: the form is the absolute containing block and in 1.5.2 the results
drawer is an in-flow child of it, so the form grows to the full results height —
use fixed dimensions keyed to the INPUT (form top == input top). */
#search .pagefind-ui__search-clear { top: 3px; right: 3px; height: 23px; font-size: 11px; padding: 0 6px; }
/* Results clipping: stack the drawer, allow shrink in the narrow sidebar. */
#search .pagefind-ui__drawer { flex-direction: column; gap: 0.4em; }
#search .pagefind-ui__results-area { min-width: 0; }
#search .pagefind-ui__result-excerpt { min-width: 0; }
/* No images exist in this index, but Pagefind still renders an 81px empty
.result-thumb on the left of every row, pushing the text ~90px right (the
"right-justified" look). Hide it so results fill the pane from the left. */
#search .pagefind-ui__result-thumb { display: none; }
#search .pagefind-ui__result-title,
#search .pagefind-ui__result-link { font-size: 1em; overflow-wrap: anywhere; }
#search .pagefind-ui__result-excerpt{ font-size: 0.88em; overflow-wrap: anywhere; word-break: break-word; }
#search .pagefind-ui__message { font-size: 0.8em; }
/* Keyboard-navigation highlight. Must be unmistakable on the dark theme
(bg #000, text #EEE): a solid slate fill + a bright 2px inset outline + white,
bold text. The class is applied to ONE node per selection: a sub's
.result-nested wrapper, or a top hit's .result-title line (see rowFor()). */
#search .pf-kbd-selected {
background: rgba(100,116,139,0.5);
outline: 2px solid #cbd5e1;
outline-offset: -2px;
color: #ffffff;
border-radius: 4px;
}
#search .pf-kbd-selected,
#search .pf-kbd-selected .pagefind-ui__result-title,
#search .pf-kbd-selected .pagefind-ui__result-link {
color: #ffffff;
font-weight: 600;
}
/* Matched-term <mark>: pagefind-ui.css resets `mark { all: revert }`, so matches
otherwise render the UA default harsh yellow. Restyle to a subdued slate on the
dark theme, scoped to the search results so page-content marks are untouched. */
#search .pagefind-ui__result mark {
background: rgba(100,116,139,0.45);
color: #ffffff;
border-radius: 2px;
padding: 0 1px;
font-weight: 600;
}
</style>
<div id="search"></div>
<script>
window.addEventListener('DOMContentLoaded', (event) => {
new PagefindUI({
element: "#search",
showSubResults: true,
focusOnSlash: true, // press "/" anywhere to focus the search box
excerptLength: 20 // shorter excerpts fit the narrow sidebar
});
// --- Keyboard navigation (Pagefind Default UI 1.5.2 has none) ---
// ArrowDown/ArrowUp move a highlight over the result rows (wraparound,
// sub-result rows included — DOM order equals visual order); Enter opens the
// highlighted row, or the top result when nothing is highlighted. (Stock
// 1.5.2 Enter is a silent no-op — verified in the built pagefind-ui.js.)
//
// Two fixes over the naive version, both needed for a PHYSICAL keyboard:
// (1) The link set is re-read from the LIVE DOM on every key press. Caching
// a NodeList that Pagefind's async re-render later detaches means the
// highlight/Enter act on a dead node and nothing appears.
// (2) The MutationObserver reset is fingerprinted. Pagefind patches the
// results subtree (excerpts, Svelte reactivity) right after a key press;
// an unguarded childList observer would fire and clear the selection the
// user just made. We only reset when the actual set of result hrefs
// changes (a new query / rebuilt list), and otherwise re-assert the
// highlight on the live row so a rebuilt <li> never loses it.
(function () {
var searchEl = document.getElementById('search');
if (!searchEl) return;
var SELECTED = 'pf-kbd-selected';
var index = -1;
var selFp = null; // fingerprint of the link set when we last applied a selection
function liveLinks() {
var resultsEl = searchEl.querySelector('.pagefind-ui__results');
if (!resultsEl) return [];
// Only interactive when actually rendered/visible. offsetParent is
// non-null here (it resolves to the form), but also accept a visible
// bounding box so a position:fixed sidebar can never null it out.
var visible = (resultsEl.offsetParent !== null) ||
(resultsEl.getBoundingClientRect().height > 0);
if (!visible) return [];
return Array.from(searchEl.querySelectorAll('.pagefind-ui__results .pagefind-ui__result-link'));
}
function fp(links) {
return links.map(function (l) { return l.getAttribute('href'); }).join('\u0001');
}
function rowFor(link) {
if (!link.closest) return null;
// Sub-results: highlight this sub's OWN wrapper block, so each
// sub-topic (e.g. hacking -> anonymity / osint / courses) gets its
// own row rather than all of them re-lighting the parent <li>.
var nested = link.closest('.pagefind-ui__result-nested');
if (nested) return nested;
// Top-level hit: highlight just its title line. Highlighting the
// whole .pagefind-ui__result would also swallow its nested subs.
return link.closest('.pagefind-ui__result-title') ||
link.closest('.pagefind-ui__result');
}
function clearAll() {
// The class can sit on a .result-nested, a .result-title, or a
// .result — clear it wherever it landed.
searchEl.querySelectorAll('.' + SELECTED)
.forEach(function (r) { r.classList.remove(SELECTED); });
}
function deselect() {
clearAll();
index = -1;
selFp = null;
}
function move(dir) {
var links = liveLinks();
var n = links.length;
if (!n) return;
index = index < 0 ? (dir > 0 ? 0 : n - 1) : (index + dir + n) % n;
clearAll();
var row = rowFor(links[index]);
if (row) {
row.classList.add(SELECTED);
row.scrollIntoView({ block: 'nearest' });
}
selFp = fp(links);
}
// Capture phase on #search so this runs BEFORE Svelte's bubbling
// keydown/submit handlers on the input.
searchEl.addEventListener('keydown', function (e) {
var input = searchEl.querySelector('.pagefind-ui__search-input');
if (e.target !== input) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
if (!liveLinks().length) return; // no results yet: leave default alone
e.preventDefault();
move(e.key === 'ArrowDown' ? 1 : -1);
} else if (e.key === 'Enter') {
var links = liveLinks();
if (!links.length) return;
// Use the highlighted row, unless the selection went stale (the
// result set changed under us and the MO hasn't caught up yet).
var stale = (index < 0 || index >= links.length) ||
(selFp !== null && selFp !== fp(links));
var href = stale
? links[0].getAttribute('href')
: links[index].getAttribute('href');
if (href) {
e.preventDefault();
e.stopPropagation();
location.href = href;
}
}
}, true);
// Keep the selection in sync with Pagefind re-renders without nuking it.
var mo = new MutationObserver(function () {
if (index < 0) return; // nothing selected -> nothing to keep in sync
var links = liveLinks();
if (fp(links) !== selFp) { deselect(); return; } // result set changed
// Same result set, but Svelte may have rebuilt the <li> nodes:
// re-assert the highlight on the live row at the current index.
clearAll();
var row = rowFor(links[index]);
if (row) row.classList.add(SELECTED);
});
mo.observe(searchEl, { childList: true, subtree: true });
})();
});
</script>
{{ end }}