Skip to content

feat(search): replace lunr with Pagefind and a grouped-results search modal - #2093

Closed
haranrk wants to merge 11 commits into
google:mainfrom
haranrk:search-pagefind-modal
Closed

feat(search): replace lunr with Pagefind and a grouped-results search modal#2093
haranrk wants to merge 11 commits into
google:mainfrom
haranrk:search-pagefind-modal

Conversation

@haranrk

@haranrk haranrk commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces the built-in search plugin (lunr.js) with Pagefind, plus a search modal whose markup and behaviour live in this repo.

Two problems, solved together:

Relevance. The lunr index is tokenized with the bare default separator ([\s\-]+ — whitespace and hyphens only), while 60% of the index sits inside <code>. Searching append_event, LlmAgent, google.adk.agents or before_agent_callback currently returns nothing. Separately, Pagefind and lunr both concatenate adjacent inline elements, so the language badge and tab-label strip fuse into single tokens — ADKPython × 154, PythonTypeScriptGoJava × 59, across 193 of 226 pages. A reader searching Python misses all of them.

Interface. Material's dropdown returns a flat list of section rows. This returns results grouped by page with the matching headings nested beneath, like genkit.dev and pydantic.dev/docs (both of which also use Pagefind).

The indexed page set is unchanged

The main risk in swapping search engines is quietly changing what is searchable. It did not happen:

pages URL set
lunr on main 226
Pagefind here 226 identical

Zero pages added, zero dropped. Verified by extracting both sets from CI-equivalent builds (git archive).

Measured effects

before after
search bytes per page load 841 KB gz index + 39 KB worker ~9 KB gz (search.js + search.css)
index / WASM / engine fetched eagerly on every page deferred to first modal open
append_event, LlmAgent, google.adk.agents, before_agent_callback 0 results all return results
fused language tokens 219 across 193 pages 0
build time +2.4s on a ~30s build
CI install +5.4 MB

The index is sharded, so a query fetches only the shards it touches (~162 KB to initialise, ~460 KB for a full cold search). A reader who never searches pays nothing beyond the ~9 KB.

How it works

A on_post_build hook runs the Pagefind binary over the rendered HTML in site_dir. data-pagefind-body on <article> scopes the index — and because Pagefind indexes only pages carrying that attribute, it also keeps the 3,140 generated API-reference files out, matching the current lunr scope exactly. The attribute is conditional on {% if page %}, which keeps it off 404.html (MkDocs renders theme templates without a page in context).

--exclude-selectors ".headerlink, .tabbed-labels, .language-support-tag" fixes the token fusion; tab contents stay indexed, so per-language content remains searchable. --include-characters ".-@#+" keeps google.adk.agents from splitting into three unrelated words.

Pagefind is used as a query API rather than a widget, so result rendering, keyboard handling and styling are ours — which is what makes the page-grouped layout and the deferred loading possible.

Notable details

  • The trigger and the dialog are deliberately split across the </header> boundary. The dialog must sit outside the seven elements instant navigation replaces (so it survives client-side navigation) and outside .md-header, because custom.css's [data-md-color-scheme="slate"] .md-header * at specificity 0-2-0 beats the modal's own rules and flattens every colour in dark mode. Only the split satisfies both.
  • target="_self" on result links is load-bearing. Material intercepts internal links by stripping query and hash and testing the bare URL against its sitemap, so ?pagefind-highlight= links would be intercepted — leaving the dialog open and preventing the arrival highlighter from running. Material's handler bails on a truthy target.
  • Material's __search toggle input stays. toggle/index.ts resolves it eagerly at module scope and throws if missing, which would take down all Material JS.
  • Keyboard: Ctrl/Cmd+K, plus /, s and f rebound — those were bound inside Material's mountSearch, which no longer runs, so they would otherwise have silently stopped working. Suppressed while typing in a field.
  • Accessibility: ARIA combobox/listbox with aria-activedescendant; native <dialog> for focus trapping and Escape.
  • Search-term highlighting on arrival is preserved (pagefind-highlight.js, loaded only when the URL carries the param, and palette-aware via --md-typeset-mark-color).
  • No Node toolchain — Pagefind ships as a Python wheel. pagefind-bin is pinned explicitly because pagefind[bin] only constrains the binary to <2.

Guardrails

hooks/pagefind_index.py fails the build on: a missing or failing indexer, zero indexable pages, a page-count mismatch, and any --exclude-selectors entry that has stopped matching (a stale-but-valid selector would otherwise silently let fused tokens back in).

scripts/verify_search_index.py runs after the build in both build-docs.yaml and publish-docs.yaml, asserting index scope, that nothing under the generated API-reference subtrees or 404.html is indexed, and that no fused tokens survive. Counts are always measured, never hardcoded.

PAGEFIND_SKIP=1 skips indexing for mkdocs serve authoring loops; it logs a warning, so --strict builds fail while it is set.

Testing

  • mkdocs build --strict clean; all 10 commits build individually (bisect-safe).
  • Verified interactively in Chrome: lazy loading (zero pagefind requests on page load), Ctrl+K, //s/f, Escape, grouped results, arrow-key navigation, aria-activedescendant, full-document navigation on Enter, 167 terms marked on arrival, dark mode, and header layout from 1000px to 1920px.
  • Fused-token fix proven with a control build: identical site indexed without --exclude-selectors → 219 tokens across 193 pages; with → 0.

haranrk added 10 commits August 7, 2026 17:56
The lunr-based `search` plugin ships its entire index to the browser on
every page load, before the reader has typed anything: a 3.54 MB
search_index.json (841 KB gzipped) plus a 39 KB search worker, measured
on a 226-page build of this site. Pagefind builds a sharded index at
build time and the browser fetches only the shards a query touches.

`pagefind[bin]` (a 5.4 MB wheel) rather than `pagefind[extended]`
(52.7 MB): the two differ only in CJK segmentation support, which this
English-only site does not use. Neither needs a Node toolchain - the
wheel carries a prebuilt binary, invoked as `python -m pagefind`.

pagefind-bin is pinned separately from pagefind[bin]. The extra only
declares `pagefind-bin<2,>=1`, and it is the binary, not the small
Python wrapper, that determines the index format, the shipped
pagefind.js and the CLI surface - so an unpinned binary would drift on
every `pip install`, including in CI and on deploy.
Pagefind indexes only the pages carrying `data-pagefind-body`, and
within them only that element's subtree. overrides/main.html puts the
attribute on <article>, which keeps the header, navigation, table of
contents and footer out of the index, and keeps the 3,140 generated
API-reference files under docs/api-reference/ out entirely: MkDocs
copies those verbatim rather than rendering them through the template,
so they never receive the attribute. Exactly one page under that tree
is indexed, the hand-written api-reference/index.md landing page.

The scope assertion compares Pagefind's reported page count against a
count of files carrying the attribute, both measured from the build
that just ran, rather than against a hardcoded number. That number is
not stable across environments: CI builds 226 pages, while a local
checkout with untracked planning documents under docs/superpowers/
builds 234. A hardcoded expectation would fail for every developer.

--exclude-selectors drops the permalink anchor, the pymdownx.tabbed
label strip and the language-support badge before indexing. Pagefind
concatenates adjacent inline elements without a separator, so without
this those three fuse into tokens such as "ADKPython" and
"PythonTypeScriptGoJava": 219 of them across 193 of the 226 indexed
pages, measured on a control build with no exclude selectors. With the
selectors excluded, zero. Tab contents stay indexed, so per-language
content remains searchable.

--include-characters keeps ".-@#+" inside tokens, so dotted and
prefixed identifiers survive tokenization. Without it the identifier
`google.adk.agents` is split into three unrelated words and cannot be
searched as a unit.

PAGEFIND_SKIP=1 skips indexing, for `mkdocs serve` authoring loops
where re-indexing on every save is not worth it. Skipping logs a
warning, so a --strict build cannot silently ship dead search.
The trigger button and the dialog it opens are split across the closing
</header> tag, which looks arbitrary. It is the only placement that
satisfies two constraints that pull in opposite directions, and it is
the least obvious decision on this branch.

First, the dialog must stay outside the seven elements Material's
instant navigation replaces on every client-side navigation: announce,
container, header-topic, outdated, logo, skip and tabs. Anything inside
those is torn out and rebuilt, taking its event listeners with it, and
the dialog would stop responding after the first in-site link.

Second, the dialog must stay outside .md-header. custom.css declares
`[data-md-color-scheme="slate"] .md-header *`, a blanket descendant
selector at specificity 0-2-0 that beats the modal's own 0-1-1 rules
and flattened the excerpt text, the section links and the <mark> accent
on matched terms in dark mode. That rule is load-bearing for the real
header, so the dialog moves out from under it rather than being fought
with higher-specificity overrides.

base.html renders the header block immediately before
<div class="md-container" data-md-component="container">, so including
the dialog after </header> makes it a sibling of both: outside
.md-header, and outside every element instant navigation replaces. The
trigger is a header control and is meant to follow header colours, so
it stays inside.
What the reader gains: the search payload downloaded on every page
load, before any query is typed, drops from 3.58 MB to zero - a
3.54 MB search_index.json plus a 39 KB worker, measured on a 226-page
build. Pagefind fetches index shards only once there is a query.

What does not change: which pages are searchable. lunr indexed 226
pages, Pagefind indexes 226, and the two URL sets are identical, so
nothing findable before becomes unfindable.

What the reader loses, for now: Material bound its global `/`, `s` and
`f` search shortcuts inside mountSearch(), which no longer runs, so
those three keys go inert. A later commit in this series rebinds them
to the new dialog.

The `#__search` toggle input base.html renders stays, even though
nothing reads it any more. Material's toggle/index.ts resolves it
eagerly at module scope, alongside the drawer toggle, and its
getElement() throws a ReferenceError when the selector matches
nothing. Removing the input would therefore take down every Material
behaviour on the page, not just search.
build-docs.yaml is `on: pull_request` only, so on its own the verifier
never guards the path that actually publishes the site.
publish-docs.yaml runs `mkdocs gh-deploy --force`, which builds and
pushes in a single step, leaving no point at which the workflow can
inspect the output before it is live. An explicit `mkdocs build
--strict` in front of it produces a site/ the verifier can run
against, so check_never_indexed and check_fused_tokens now guard the
deploy path as well as pull requests.

CONTRIBUTING.md picks up PAGEFIND_SKIP, which was until now only
discoverable from a comment in mkdocs.yml.
Removing the lunr search plugin also removed Material's global keyboard
shortcuts, which were bound inside mountSearch(). Rebinding them keeps
existing muscle memory working rather than silently dropping three
shortcuts that readers of this site may already use.

Also corrects the hook docstring, which still described the lunr plugin
as enabled.
@netlify

netlify Bot commented Aug 7, 2026

Copy link
Copy Markdown

Deploy Preview for adk-docs-preview ready!

Name Link
🔨 Latest commit 602698a
🔍 Latest deploy log https://app.netlify.com/projects/adk-docs-preview/deploys/6a761f897b17ae00085406e1
😎 Deploy Preview https://deploy-preview-2093--adk-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

The trigger advertised Ctrl+K to every visitor, but the handler in
search.js has always accepted Meta as well as Control, so the shortcut
worked on macOS while the label denied it. aria-keyshortcuts was wrong
there for the same reason.

The hint is still server-rendered as Ctrl, so the HTML is identical for
every visitor and stays cacheable, and an inline script immediately
after the button corrects it during parse. It cannot live in search.js:
that is a plain <script src> at the end of <body>, measured finishing
6ms after first paint on a throttled connection, so Mac readers would
watch Ctrl repaint to Cmd. The inline version swaps 117ms before first
paint at 200KB/s with 300ms latency.

The hint ships with an inline display:none that the script clears once
the label is correct, which also hands responsive control back to the
stylesheet so it stays hidden below 60em. With JavaScript off it stays
hidden, which is right: the shortcut would not work either.

This mirrors how Starlight solves it on genkit.dev, including finding
the modifier through the nested <kbd> structure rather than adding an
attribute for it.
@haranrk

haranrk commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #2096

@haranrk haranrk closed this Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant