# 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

```sh
pnpm add preact
pnpm add -D pagefind
```

Build the index after the site:

```json
// 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:

```json
// 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:

```astro
---
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:

```ts
// 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:

```tsx
// 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`

```ts
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](#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:

```astro
<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:

```astro
---
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:

```tsx
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
