Repository navigation
feat(search): replace stale Algolia DocSearch with self-hosted Pagefind - #754
Conversation
The DocSearch index (`goharbor`, legacy DocSearch v2 on the shared
BH4D9OD16A app) is broken in three ways that cannot be fixed from this
repo, because DocSearch v2 is deprecated and algolia/docsearch-configs
is archived read-only:
* It is frozen at ~April 2022. The live index holds 3327 records
covering only docs/1.10, 2.0.0-2.5.0, edge and main. Docs 2.6.0
through 2.15.0 are absent entirely, though all are published.
* The searched version is neither visible nor selectable. The crawler
config declared attributesForFaceting ["version"], but start_urls
carried no version capture group, so nothing populated it -- a live
facets:["*"] query returns no facets at all. Results silently mix
nine releases with no way to tell them apart.
* It only ever searched /docs/. start_urls scoped the crawl to that
prefix, so the blog, community page, CLI docs and home page were
unreachable even though the search bar renders on every page.
Pagefind builds its index from public/ on every deploy, so it cannot
drift from the published site. Scoping is explicit: every page carries a
`scope` filter -- docs pages get their Harbor version, everything else
gets "site" -- and queries ask for `{scope: {any: [version, "site"]}}`,
returning exactly one release of the docs plus all unversioned pages.
The modal filters with All / Docs / Website, where Docs is a split
button: the left half selects docs, the right half picks the release.
Choosing a release selects Docs in the same action. The release list is
built from pagefind.filters(), so it can only ever offer versions that
were actually built -- unlike the navbar dropdown, which reads
config.toml and can point at a version that 404s. It defaults to the
version of the page being read, or the newest indexed release elsewhere,
and every docs result carries a version chip.
No release is dropped from the index. /docs/edge/ is excluded because it
is a symlink to the same content /docs/main/ is built from; both remain
browsable.
Alpine.js moves from 2.1.2 to 3.14.9, which the search component needs.
Existing usage was three inline x-data components, all v3-compatible;
theme-toggle.html loses its `x-init="init()"` because v3 auto-invokes
init(). This drops IE11, which Pagefind (WASM + dynamic import) could
not support in any case.
.nvmrc goes to v20; Node 14.11 is EOL and cannot run the Pagefind CLI.
Also fixes the version badge in the navbar: `eq $version $latest`
compared a string against the whole versions map, so the "latest" tag
never rendered.
Signed-off-by: Vadim Bauer <1492007+Vad1mo@users.noreply.github.com>
dadbed6 to
fd3295f
Compare
There was a problem hiding this comment.
Pull request overview
Replaces stale Algolia search with a deployment-built Pagefind index and version-aware search modal.
Changes:
- Adds Pagefind indexing and scoped metadata across site content.
- Adds responsive search UI, filters, keyboard navigation, and styling.
- Upgrades Alpine.js and Node; fixes the navbar’s latest-version badge.
Reviewed changes
Copilot reviewed 20 out of 21 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
Makefile |
Integrates Pagefind into build and serve targets. |
.nvmrc |
Upgrades Node to v20. |
.gitignore |
Ignores generated local indexes. |
config.toml |
Upgrades Alpine.js. |
assets/js/search.js |
Implements Pagefind search behavior. |
assets/sass/search.sass |
Styles the search interface. |
assets/sass/style.sass |
Imports search styles. |
assets/sass/dark-mode.sass |
Removes obsolete search-input styling. |
layouts/_default/baseof.html |
Adds the global search modal. |
layouts/_default/single.html |
Indexes general site pages. |
layouts/index.html |
Indexes selected homepage content. |
layouts/partials/blog/content.html |
Indexes blog content. |
layouts/partials/cli-docs/layout.html |
Indexes CLI documentation. |
layouts/partials/css.html |
Removes Algolia CSS. |
layouts/partials/docs/layout.html |
Indexes version-scoped documentation. |
layouts/partials/docs/page-version.html |
Derives documentation versions. |
layouts/partials/javascript.html |
Removes Algolia and loads Alpine v3. |
layouts/partials/navbar.html |
Adds responsive triggers and fixes latest badge. |
layouts/partials/search-bar.html |
Defines desktop and mobile triggers. |
layouts/partials/search.html |
Defines the search modal and results UI. |
layouts/partials/theme-toggle.html |
Adapts initialization for Alpine v3. |
Suppressed comments (1)
layouts/partials/search.html:73
- The active state is only visual for this filter, unlike the Docs button. Expose it with
aria-pressedso assistive technology can identify the selected filter.
<button class="search-filters__pill"
:class="{ 'search-filters__pill--active': activeType === 'Website' }"
@click="setType('Website')"
x-show="websiteTypes().length">Website</button>
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
All eight were reproduced against a running build before fixing, and each fix was re-verified the same way. Focus containment (the worst of them): the modal declared role="dialog" aria-modal="true" without honouring it. Tabbing walked into the obscured page behind it, and because Escape was bound to the dialog element, it then stopped closing the modal -- a keyboard user was stranded. Tab now wraps inside the dialog, Escape is bound to the window, and focus returns to whatever opened it. Stacking: the modal sat at z-index 61 while table-popout.sass occupies 9998-10000. Ctrl/Cmd-K is global, so pressing it while a table was enlarged focused a search field that was completely covered -- typing went somewhere invisible. Search now sits above that stack. Stale results: Pagefind's debounce only cancels a call still inside its window. One that had already begun loading result data ran to completion and repopulated the modal afterwards -- clearing the input mid-search left 20 results on screen for a query that no longer existed. A search token now discards superseded work, and the empty-query path resets `loading` instead of leaving the spinner up. Modified clicks: results are anchors, but @click.prevent swallowed the event and assigned window.location, so Cmd/Ctrl/shift/middle-click could not open a result in a new tab. The browser now handles anchors natively; only keyboard activation navigates manually. Transient failure: `pagefind` was assigned before init() and filters() ran, so one failed load cached a half-built instance that the guard never retried, disabling search until reload. It is assigned only once fully initialised, and cleared on the catch path. Screen readers: arrow-key selection changed a CSS class and nothing else. The input is now a combobox driving a listbox of options via aria-activedescendant, and aria-pressed is on all three filter buttons rather than only Docs. Empty state: under the Website filter, docs are excluded entirely, so suggesting a different Harbor release could not help. That hint is now shown only when docs are in scope. Signed-off-by: Vadim Bauer <1492007+Vad1mo@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 21 changed files in this pull request and generated no new comments.
Suppressed comments (3)
assets/js/search.js:68
- On a cold open, focus is not moved into the modal until the Pagefind import, initialization, and filter request all finish. During that delay focus remains on the obscured trigger, so Tab can move through the background page and bypass
trapFocus. Move the existing$nextTickfocus block to immediately afterthis.open = true, before these awaits, and avoid refocusing the input again when initialization completes.
if (!this.pagefind) {
try {
var pagefind = await import('/pagefind/pagefind.js');
await pagefind.init();
assets/js/search.js:233
- Both asynchronous Pagefind operations can reject (for example, if a lazy index chunk or result fragment fails to load), but this method has no error path. A rejection leaves
loadingtrue indefinitely and produces an unhandled promise rejection, so the modal remains stuck on “Searching…”. Catch failures for the current token, clear/reset the UI, and expose a retryable search error; stale-token failures should remain ignored.
var search = await this.pagefind.debouncedSearch(this.query, opts, 300);
if (search === null || token !== this.searchToken) return;
var loaded = await Promise.all(
search.results.slice(0, 20).map(function (r) { return r.data(); })
);
assets/js/search.js:107
- Closing the dialog clears the UI but does not invalidate an in-flight search. If Escape is pressed while
debouncedSearch()or a resultdata()load is pending, that request still owns the current token and can repopulate results after close; reopening then shows those stale results for an empty query. IncrementsearchTokenwhile closing so pending work fails the existing token checks.
closeModal: function () {
this.open = false;
document.documentElement.classList.remove('has-search-open');
this.query = '';
this.clearResults();
this.loading = false;
Three follow-up findings, two of them regressions introduced by the last commit. Each was reproduced before fixing and re-verified after. Closing the modal did not invalidate an in-flight search. The search token was added last time to discard superseded work, but closeModal() left the pending request holding the current token, so it passed the staleness checks and wrote its results into the closed modal -- which then reopened showing hits for a query the user had never seen. Measured: `results=20 query="" open=false` after Escape. The token is now bumped on close, so pending work is orphaned. Neither Pagefind call had an error path. A lazily fetched index chunk or result fragment that fails to load rejected into nothing, leaving the spinner up permanently and raising an unhandled rejection. Measured: `loading=true spinnerVisible=true unhandledRejections=1`. Both awaits are now wrapped, failures for the current token surface a retryable error, and stale failures stay ignored. On a cold open, focus was moved into the dialog only after the Pagefind import, init and filter request had all resolved -- locally a ~90ms window (focus at 10ms vs init at 99ms), and far longer on a slow connection. Until focus is inside, trapFocus has nothing to contain and Tab walks the obscured page behind an already-visible modal. Focus now happens before those awaits. The error state doubles as a fix for silent degradation: if the index is missing entirely the modal previously collapsed to a bare input with no filters and no explanation, which reads as intentional rather than broken. It now says so and offers a retry that re-runs initialisation. Signed-off-by: Vadim Bauer <1492007+Vad1mo@users.noreply.github.com>
|
Picked up the three suppressed comments from the 10 Aug review — all three were valid, and two were regressions I introduced in 323b441. Reproduced each before fixing, re-verified after. Pushed in bfadd24.
No error path on either Pagefind await. A rejected index chunk or result fragment left the spinner up for good plus an unhandled rejection.
Cold-open focus gap. Focus entered the dialog only after import + init + filters resolved, so
The error state also fixes a silent-degradation problem worth calling out: with the index missing entirely, the modal used to collapse to a bare input with no filters and no explanation, which reads as intentional rather than broken. I hit this myself during testing. It now says "Search is unavailable right now" with a Try again that re-runs initialisation — verified recovering from Regression-checked afterwards: version gating, All/Docs/Website round-trip, combobox/ |
|
great stuff |


The site search is legacy Algolia DocSearch v2 (
layouts/partials/javascript.html), pointed at thegoharborindex on the sharedBH4D9OD16Aapp. It is broken in three ways, all verified against the live index and the archived crawler config (algolia/docsearch-configs/configs/goharbor.json):1. The index is frozen at ~April 2022. It holds 3327 records covering only
docs/1.10,2.0.0–2.5.0,edgeandmain. Docs 2.6.0 through 2.15.0 are absent entirely, though all are published and return 200.2. The searched version is neither visible nor selectable. The crawler config declared
attributesForFaceting: ["version"], butstart_urlswas the bare string"https://goharbor.io/docs"with no(?P<version>…)capture group, so nothing ever populated it — a livefacets:["*"]query returns no facets at all. The frontend passes nofacetFilterseither. Results silently mix nine releases with no way to tell them apart.3. It only ever searched
/docs/.start_urlsscoped the crawl to that prefix (sitemap-discovered URLs are filtered against it). Sampling ~4000 hits across six queries returned zero URLs outside/docs/— no blog, community page, CLI docs or home page — even though the search bar renders on every page.None of this is fixable from this repo: DocSearch v2 is deprecated and
algolia/docsearch-configsis archived read-only, so the crawl cannot be re-triggered or re-scoped by anyone here.Solution
Self-hosted Pagefind, which builds its index from
public/on every deploy — so it cannot drift from the published site.Scoping is explicit. Every page carries a
scopefilter: docs pages get their Harbor version, everything else getssite. Queries ask for{scope: {any: [version, "site"]}}, returning exactly one release of the docs plus all unversioned pages in a single query.The modal filters with All / Docs / Website, where Docs is a split button — the left half selects docs, the right half picks the release:
pagefind.filters(), so it can only offer versions that were actually built. The navbar dropdown, by contrast, readsconfig.tomland can point at a version that 404s.No release is dropped from the index.
/docs/edge/is excluded only because it is a symlink to the same content/docs/main/is built from — indexing both returned every dev page twice. Both remain browsable.What else is in here, and why
x-datacomponents (navbar,theme-toggle,render-image), all v3-compatible as written;theme-toggle.htmldrops itsx-init="init()"because v3 auto-invokesinit(). This drops IE11, which Pagefind (WASM + dynamicimport()) cannot support in any case..nvmrcv14.11.0 → v20. Node 14 is EOL and cannot run the Pagefind CLI.eq $version $latestcompared a string against the wholeparams.versionsmap, so the tag never rendered. Now compares against$latest.harborversion.Hugo stays pinned at 0.74.0 — nothing here needs a newer version. (It does not build on current Hugo, but that is pre-existing:
.Site.IsServerwas removed, andpostcss-cli7.1.2 fails under Hugo's Node permission model. Worth a separate PR.)Build
npx -y pagefind@1.5.2 --site publicruns afterhugoinbuild,production-buildandpreview-build.make servebuilds the index once and serves it fromstatic/for the session.netlify.tomlis unchanged — it already calls these targets.Verification
Built and tested with the pinned Hugo 0.74.0, not a newer local one.
/community/— neither reachable todayedgeexcludedmainpresent.navbar-brand; it was otherwise buried behind the burger menubin/htmltestgives byte-identical output on this branch and on unmodifiedmain(173 pre-existing errors, 0 new)Notes for reviewers
data-pagefind-bodyexists anywhere on a site, pages without it are excluded. That attribute is therefore the mechanism for both inclusion (docs, CLI docs, blog, community, home) and for excludingedge.layouts/is not per-branch —load-docs.shcopies onlydocs/from each release branch — so these template changes apply to every version automatically and need no backport.content/docs/(layouts/partials/docs/page-version.html), not from front matter, so a page cannot drop out of version-scoped search by forgetting to declare a version.