minidocs

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 source function, using Pagefind, Algolia or any engine, returning results of any shape
  • How to render: a render function, 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.

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, query is an empty string. Return an empty array to show Search.Idle, or suggestions such as recently visited pages
  • signal is aborted once a newer query starts, and outdated results are dropped
  • params holds the values of every named form control inside Search.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-body indexes only the content, not the sidebar, header and other parts every page shares
  • In results, page.meta.path is the position, such as “Guides › Page Layout”
  • Put a group of radio buttons named section inside Search.Root; source reads the selected group from params.get('section') and passes it to Pagefind’s filters

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
On this page
Search in
Type to start searching