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 }}
|
{{- $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 }}>
|
<link rel="stylesheet" href="{{ partial "docs/links/resource-precache" $styles }}" {{ template "integrity" $styles }}>
|
||||||
|
|
||||||
<!-- Search -->
|
<!-- Search: handled by Pagefind (see _partials/docs/search.html, injected into the
|
||||||
{{- if default true .Site.Params.BookSearch -}}
|
sidebar via menu.html). The legacy Fuse.js search was removed to avoid double-loading. -->
|
||||||
{{- $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 -->
|
<!-- Service Worker -->
|
||||||
{{- if .Site.Params.BookServiceWorker -}}
|
{{- 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 }}
|
{{ if default true .Site.Params.BookSearch }}
|
||||||
<link href="/pagefind/pagefind-ui.css" rel="stylesheet">
|
<link href="/pagefind/pagefind-ui.css" rel="stylesheet">
|
||||||
<script src="/pagefind/pagefind-ui.js"></script>
|
<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>
|
<div id="search"></div>
|
||||||
<script>
|
<script>
|
||||||
window.addEventListener('DOMContentLoaded', (event) => {
|
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>
|
</script>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "main" }}
|
{{ define "main" }}
|
||||||
<article class="markdown book-article">
|
<article class="markdown book-article" data-pagefind-body>
|
||||||
{{- .Content -}}
|
{{- .Content -}}
|
||||||
</article>
|
</article>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{{ define "main" }}
|
{{ define "main" }}
|
||||||
{{ range sort .Paginator.Pages }}
|
{{ range sort .Paginator.Pages }}
|
||||||
<article class="book-post markdown">
|
<article class="book-post markdown" data-pagefind-body>
|
||||||
<h2>
|
<h2>
|
||||||
<a href="{{ .RelPermalink }}">{{ partial "docs/title" . }}</a>
|
<a href="{{ .RelPermalink }}">{{ partial "docs/title" . }}</a>
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{{ define "main" }}
|
{{ define "main" }}
|
||||||
<article class="markdown book-post">
|
<article class="markdown book-post" data-pagefind-body>
|
||||||
<h1>
|
<h1>
|
||||||
{{ partial "docs/title" . }}
|
{{ partial "docs/title" . }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{{ define "main" }}
|
{{ define "main" }}
|
||||||
{{ range sort .Paginator.Pages }}
|
{{ range sort .Paginator.Pages }}
|
||||||
<article class="markdown book-post">
|
<article class="markdown book-post" data-pagefind-body>
|
||||||
<h2>
|
<h2>
|
||||||
<a href="{{ .RelPermalink }}">{{ partial "docs/title" . }}</a>
|
<a href="{{ .RelPermalink }}">{{ partial "docs/title" . }}</a>
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user