Search
minidocs provides the search dialog: the input, messages for each state, the keyboard shortcut and keyboard navigation. Which search engine to use and how results look are up to you:
- How to search: a
sourcefunction, using Pagefind, Algolia or any engine, returning results of any shape - How to render: a
renderfunction, using Preact, React or plain DOM, rendering the results into a container
A complete example
This example uses Pagefind and Preact. Pagefind indexes the generated HTML after the build and needs no server; Preact only renders the results.
Installing
pnpm add preact
pnpm add -D pagefind
Build the index after the site:
// package.json
{
"scripts": {
"build": "astro build && pagefind --site dist"
}
}
Astro’s default tsconfig leaves JSX as it is, so tell it to compile JSX with Preact. @astrojs/preact is not needed:
// tsconfig.json
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
}
}
In the layout
Search comes in two pieces: the button that opens it, and the dialog itself. The button points to the id of Search.Root with target, so the two can sit in different places on the page:
---
import { Search } from '@minidocs/astro/components';
---
<!-- In the header -->
<Search.Trigger target="search">Search <Search.Shortcut /></Search.Trigger>
<!-- Anywhere, e.g. at the end of the body -->
<Search.Root id="search">
<Search.Dialog label="Search docs">
<Search.Input label="Search" placeholder="Search docs" />
<Search.Results label="Search results" />
<Search.Idle>Type to start searching</Search.Idle>
<Search.Loading>Searching…</Search.Loading>
<Search.Empty>No results for “<Search.Query />”</Search.Empty>
<Search.Error>{import.meta.env.DEV ? 'The dev server has no search index' : 'Search is unavailable right now. Please try again later.'}</Search.Error>
<Search.Found>Top <Search.Count /> results</Search.Found>
</Search.Dialog>
</Search.Root>
<script>
import { attachSearch } from '@minidocs/astro/behaviors';
import { renderResults } from '../lib/search/results';
import { preload, source } from '../lib/search/source';
const root = document.getElementById('search')!;
attachSearch(root, { source, render: renderResults });
root.addEventListener('md:search-open', preload, { once: true });
</script>
The md:search-open event fires when the dialog opens, used here to start downloading the index early. Several buttons on a page can point to the same Search.Root.
How to search
source receives the query and returns results. Pagefind’s index only exists after the build, so it is loaded from the site at runtime:
// src/lib/search/source.ts
import type { SearchSource } from '@minidocs/astro/behaviors';
export interface Hit {
href: string;
title: string;
excerpt: string;
}
interface Pagefind {
init(): Promise<void>;
search(query: string): Promise<{ results: { data(): Promise<{ url: string; excerpt: string; meta: { title?: string } }> }[] }>;
}
let pagefind: Promise<Pagefind> | undefined;
const load = (): Promise<Pagefind> =>
(pagefind ??= import(/* @vite-ignore */ `${import.meta.env.BASE_URL}pagefind/pagefind.js`));
// Starts downloading the index as the dialog opens, so the first query does not wait for it
export const preload = () => {
load()
.then((pagefind) => pagefind.init())
.catch(() => {});
};
export const source: SearchSource<Hit> = async (query) => {
if (!query) return [];
const { search } = await load();
const { results } = await search(query);
const pages = await Promise.all(results.slice(0, 10).map((result) => result.data()));
return pages.map((page) => ({ href: page.url, title: page.meta.title ?? page.url, excerpt: page.excerpt }));
};
How to render
render renders the results into a container, with any renderer. Elements with role="option" are the options readers can select with the keyboard. splitMarks splits the <mark> tags in Pagefind’s excerpts into plain and matched text, so no HTML is inserted directly:
// src/lib/search/results.tsx
import { splitMarks } from '@minidocs/astro/behaviors';
import { render } from 'preact';
import type { Hit } from './source';
const Marked = ({ html }: { html: string }) =>
splitMarks(html).map((piece) => (piece.marked ? <mark>{piece.text}</mark> : piece.text));
const Results = ({ hits }: { hits: Hit[] }) =>
hits.map((hit) => (
<a key={hit.href} role="option" href={hit.href}>
<span class="title">{hit.title}</span>
<Marked html={hit.excerpt} />
</a>
));
export function renderResults(hits: Hit[], container: HTMLElement) {
render(<Results hits={hits} />, container);
}
Components
| Component | What it is |
|---|---|
Search.Trigger |
The button that opens the dialog, with target set to the id of Search.Root |
Search.Shortcut |
The shortcut hint inside the button, ⌘K on a Mac and Ctrl K elsewhere |
Search.Root |
The root element, which needs an id |
Search.Dialog |
The dialog |
Search.Input |
The input |
Search.Results |
The container passed to render |
Search.Idle, Loading, Found, Empty, Error |
What shows in each state |
Search.Query, Search.Count |
The current query and the number of results, inside the state components |
source and render
attachSearch(root, { source, render });
source(query, { signal, params }) returns an array of results, or a Promise of one:
- When the dialog opens or the query is cleared,
queryis an empty string. Return an empty array to showSearch.Idle, or suggestions such as recently visited pages signalis aborted once a newer query starts, and outdated results are droppedparamsholds the values of every named form control insideSearch.Root, for filters, see Filtering by group below- Throwing shows
Search.Error
render(results, container, { query }) renders the results into container, and query is the query they belong to. Elements with role="option" inside the container are the options, selected with ↑ and ↓ and opened with Enter. To group options, wrap them in a role="group" with an aria-label.
States
| State | When | Shows |
|---|---|---|
idle |
Nothing typed | Search.Idle |
loading |
The first query has no results yet | Search.Loading |
found |
There are results | Search.Results and Search.Found |
empty |
No results | Search.Empty |
error |
source threw |
Search.Error |
The current state is in the data-status of Search.Root. When there are results and the reader keeps typing, they stay until the new ones arrive, so nothing flashes.
Keyboard shortcut
Search.Trigger opens with ⌘K by default, or Ctrl K outside Apple platforms. Change it with shortcut, separating several shortcuts with spaces, where Mod means ⌘ or Ctrl:
<Search.Trigger target="search" shortcut="Mod+K /">…</Search.Trigger>
<Search.Trigger target="search" shortcut={false}>…</Search.Trigger>
Shortcuts without a modifier, such as /, do not fire while the reader is typing in an input.
Filtering by group
Results take any shape you like, so the position of a page in the document tree can go into the index too. Get the nodes from the root to the current page with findPath, and write them as Pagefind filters and metadata:
---
const path = findPath(tree, id);
const groups = path.slice(0, -1).map((node) => node.label);
---
<Prose
data-pagefind-body
data-pagefind-filter={`section:${path[0]?.label}`}
data-pagefind-meta={`path:${groups.join(' › ')}`}
>
data-pagefind-bodyindexes only the content, not the sidebar, header and other parts every page shares- In results,
page.meta.pathis the position, such as “Guides › Page Layout” - Put a group of radio buttons named
sectioninsideSearch.Root;sourcereads the selected group fromparams.get('section')and passes it to Pagefind’sfilters
Another engine or renderer
To change the engine, change only source: call the engine’s client and map its results into your own shape.
To change the renderer, change only render, such as with React:
import { createRoot, type Root } from 'react-dom/client';
let root: Root | undefined;
export function renderResults(hits: Hit[], container: HTMLElement, { query }: { query: string }) {
root ??= createRoot(container);
root.render(<Results hits={hits} query={query} />);
}
Styles
The default styles are part of @minidocs/astro/styles.css.
Results are rendered in the browser, so scoped <style> in .astro files does not reach them. Style results with Tailwind classes, <style is:global> or a separate CSS file.
In the dev server
Pagefind’s index only exists after the build, so search shows Search.Error in the dev server. To try search locally, build first and open the site with astro preview.
Limitations
- Only the dialog is supported. There is no search box in the header with results in a dropdown yet