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
This commit is contained in:
@@ -32,13 +32,8 @@ https://github.com/alex-shpak/hugo-book
|
||||
{{- $styles := resources.Get "book.scss" | resources.ExecuteAsTemplate "book.scss" . | css.Sass | resources.Minify | resources.Fingerprint }}
|
||||
<link rel="stylesheet" href="{{ partial "docs/links/resource-precache" $styles }}" {{ template "integrity" $styles }}>
|
||||
|
||||
<!-- Search -->
|
||||
{{- if default true .Site.Params.BookSearch -}}
|
||||
{{- $searchJSFile := printf "%s.search.js" .Language.Lang }}
|
||||
{{- $searchJS := resources.Get "search.js" | resources.ExecuteAsTemplate $searchJSFile . | resources.Minify | resources.Fingerprint }}
|
||||
<script defer src="{{ "fuse.min.js" | relURL }}"></script>
|
||||
<script defer src="{{ partial "docs/links/resource-precache" $searchJS }}" {{ template "integrity" $searchJS }}></script>
|
||||
{{ end -}}
|
||||
<!-- Search: handled by Pagefind (see _partials/docs/search.html, injected into the
|
||||
sidebar via menu.html). The legacy Fuse.js search was removed to avoid double-loading. -->
|
||||
|
||||
<!-- Service Worker -->
|
||||
{{- if .Site.Params.BookServiceWorker -}}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
{{ "<!--" | safeHTML }}
|
||||
Made with Book Theme
|
||||
https://github.com/alex-shpak/hugo-book
|
||||
{{ "-->" | safeHTML }}
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="{{ or .Description .Summary | plainify | htmlUnescape }}">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#343a40">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
|
||||
{{- with .Page.Params.BookHref -}}
|
||||
<meta http-equiv="Refresh" content="0; url='{{ . }}'" />
|
||||
{{- end -}}
|
||||
|
||||
{{ partial "opengraph.html" . }}
|
||||
{{ partial "schema.html" . }}
|
||||
|
||||
<title>{{ partial "docs/html-head-title" . }}</title>
|
||||
{{ partial "docs/html-head-favicon" . }}
|
||||
|
||||
{{- $manifest := resources.Get "manifest.json" | resources.ExecuteAsTemplate "manifest.json" . }}
|
||||
<link rel="manifest" href="{{ $manifest.RelPermalink }}">
|
||||
<link rel="canonical" href="{{ .Permalink }}">
|
||||
|
||||
{{- range .Translations }}
|
||||
<link rel="alternate" hreflang="{{ default .Site.LanguageCode .Language.Lang }}" href="{{ .Permalink }}" title="{{ partial "docs/title" . }}">
|
||||
{{- end -}}
|
||||
|
||||
<!-- Theme stylesheet, you can customize scss by creating `assets/custom.scss` in your website -->
|
||||
{{- $styles := resources.Get "book.scss" | resources.ExecuteAsTemplate "book.scss" . | css.Sass | resources.Minify | resources.Fingerprint }}
|
||||
<link rel="stylesheet" href="{{ partial "docs/links/resource-precache" $styles }}" {{ template "integrity" $styles }}>
|
||||
|
||||
<!-- Search -->
|
||||
{{- if default true .Site.Params.BookSearch -}}
|
||||
{{- $searchJSFile := printf "%s.search.js" .Language.Lang }}
|
||||
{{- $searchJS := resources.Get "search.js" | resources.ExecuteAsTemplate $searchJSFile . | resources.Minify | resources.Fingerprint }}
|
||||
<script defer src="{{ "fuse.min.js" | relURL }}"></script>
|
||||
<script defer src="{{ partial "docs/links/resource-precache" $searchJS }}" {{ template "integrity" $searchJS }}></script>
|
||||
{{ end -}}
|
||||
|
||||
<!-- Service Worker -->
|
||||
{{- if .Site.Params.BookServiceWorker -}}
|
||||
{{- $swJS := resources.Get "sw-register.js" | resources.ExecuteAsTemplate "sw.js" . | resources.Minify | resources.Fingerprint }}
|
||||
<script defer src="{{ $swJS.RelPermalink }}" {{ template "integrity" $swJS }}></script>
|
||||
{{ end -}}
|
||||
|
||||
{{ partial "google_analytics.html" . }}
|
||||
|
||||
<!-- RSS -->
|
||||
{{- with .OutputFormats.Get "rss" -}}
|
||||
{{ printf `<link rel="%s" type="%s" href="%s" title="%s" />` .Rel .MediaType.Type .Permalink $.Site.Title | safeHTML }}
|
||||
{{ end -}}
|
||||
|
||||
{{- define "integrity" -}}
|
||||
{{- if (urls.Parse .Permalink).Host -}}
|
||||
integrity="{{ .Data.Integrity }}" crossorigin="anonymous"
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
@@ -1,10 +1,184 @@
|
||||
{{ 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 });
|
||||
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 }}
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
{{ end }}
|
||||
|
||||
{{ define "main" }}
|
||||
<article class="markdown book-article">
|
||||
<article class="markdown book-article" data-pagefind-body>
|
||||
{{- .Content -}}
|
||||
</article>
|
||||
{{ end }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{{ define "main" }}
|
||||
{{ range sort .Paginator.Pages }}
|
||||
<article class="book-post markdown">
|
||||
<article class="book-post markdown" data-pagefind-body>
|
||||
<h2>
|
||||
<a href="{{ .RelPermalink }}">{{ partial "docs/title" . }}</a>
|
||||
</h2>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{ define "main" }}
|
||||
<article class="markdown book-post">
|
||||
<article class="markdown book-post" data-pagefind-body>
|
||||
<h1>
|
||||
{{ partial "docs/title" . }}
|
||||
</h1>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{{ define "main" }}
|
||||
{{ range sort .Paginator.Pages }}
|
||||
<article class="markdown book-post">
|
||||
<article class="markdown book-post" data-pagefind-body>
|
||||
<h2>
|
||||
<a href="{{ .RelPermalink }}">{{ partial "docs/title" . }}</a>
|
||||
</h2>
|
||||
|
||||
Reference in New Issue
Block a user