# 搜索

minidocs 提供搜索对话框：输入框、各种状态的提示、快捷键和键盘操作。用什么搜索引擎、结果长什么样，由你决定：

- **怎么搜**：一个 `source` 函数，可以用 Pagefind、Algolia 或任何引擎，返回什么形状的结果都可以
- **怎么渲染**：一个 `render` 函数，可以用 Preact、React 或原生 DOM，把结果渲染进容器

## 完整的例子

下面用 Pagefind 和 Preact。Pagefind 在构建后为生成的 HTML 建索引，不需要服务器；Preact 只用来渲染结果。

### 安装

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

在构建之后建索引：

```json
// package.json
{
  "scripts": {
    "build": "astro build && pagefind --site dist"
  }
}
```

Astro 默认的 tsconfig 会原样保留 JSX，要告诉它用 Preact 编译。不需要安装 `@astrojs/preact`：

```json
// tsconfig.json
{
  "extends": "astro/tsconfigs/strict",
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "preact"
  }
}
```

### 放进布局

搜索由两部分组成：打开它的按钮，和对话框本身。按钮用 `target` 指向 `Search.Root` 的 id，所以两者可以放在页面的不同位置：

```astro
---
import { Search } from '@minidocs/astro/components';
---

<!-- In the header -->
<Search.Trigger target="search">搜索 <Search.Shortcut /></Search.Trigger>

<!-- Anywhere, e.g. at the end of the body -->
<Search.Root id="search">
  <Search.Dialog label="搜索文档">
    <Search.Input label="搜索" placeholder="搜索文档" />
    <Search.Results label="搜索结果" />
    <Search.Idle>输入关键词开始搜索</Search.Idle>
    <Search.Loading>搜索中…</Search.Loading>
    <Search.Empty>没有找到与“<Search.Query />”相关的内容</Search.Empty>
    <Search.Error>{import.meta.env.DEV ? '开发服务器里没有搜索索引' : '搜索暂时不可用，请稍后再试'}</Search.Error>
    <Search.Found>前 <Search.Count /> 条结果</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>
```

`md:search-open` 事件在对话框打开时触发，这里用它提前下载索引。一个页面可以有多个按钮指向同一个 `Search.Root`。

### 怎么搜

`source` 收到关键词，返回结果。Pagefind 的索引在构建之后才生成，所以在运行时从站点加载：

```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 }));
};
```

### 怎么渲染

`render` 把结果渲染进容器，用什么渲染器都可以。带 `role="option"` 的元素就是可以用键盘选中的选项。`splitMarks` 把 Pagefind 摘要里的 `<mark>` 拆成普通文字和匹配的文字，不需要直接插入 HTML：

```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);
}
```

## 组件

| 组件                                              | 作用                                      |
| ----------------------------------------------- | --------------------------------------- |
| `Search.Trigger`                                | 打开对话框的按钮，`target` 是 `Search.Root` 的 id  |
| `Search.Shortcut`                               | 放在按钮里的快捷键提示，在 Mac 上显示 ⌘K，其他系统上显示 Ctrl K |
| `Search.Root`                                   | 根元素，必须有 id                              |
| `Search.Dialog`                                 | 对话框                                     |
| `Search.Input`                                  | 输入框                                     |
| `Search.Results`                                | 交给 `render` 的容器                         |
| `Search.Idle`、`Loading`、`Found`、`Empty`、`Error` | 各个状态下显示的内容                              |
| `Search.Query`、`Search.Count`                   | 当前的关键词和结果数量，放在状态组件里                     |

## `source` 和 `render`

```ts
attachSearch(root, { source, render });
```

`source(query, { signal, params })` 返回结果数组，可以是 Promise：

- 打开对话框、清空关键词时，`query` 是空字符串。返回空数组就显示 `Search.Idle`，也可以返回最近访问这类推荐
- 有新的查询开始时，`signal` 会被取消，过时的结果会被丢掉
- `params` 是 `Search.Root` 里所有带 `name` 的表单控件的值，可以用来做过滤，见下文[按分组过滤](#%E6%8C%89%E5%88%86%E7%BB%84%E8%BF%87%E6%BB%A4)
- 抛出错误时显示 `Search.Error`

`render(results, container, { query })` 把结果渲染进 `container`，`query` 是这些结果对应的关键词。容器里带 `role="option"` 的元素是选项，可以用 `↑`、`↓` 选择，回车打开。需要分组时，用带 `aria-label` 的 `role="group"` 把选项包起来。

## 状态

| 状态        | 什么时候           | 显示                                |
| --------- | -------------- | --------------------------------- |
| `idle`    | 没有关键词          | `Search.Idle`                     |
| `loading` | 第一次查询还没有结果     | `Search.Loading`                  |
| `found`   | 有结果            | `Search.Results` 和 `Search.Found` |
| `empty`   | 没有结果           | `Search.Empty`                    |
| `error`   | `source` 抛出了错误 | `Search.Error`                    |

当前的状态写在 `Search.Root` 的 `data-status` 上。已经有结果时继续输入，旧的结果会保留到新结果出来，界面不会闪。

## 快捷键

`Search.Trigger` 默认的快捷键是 ⌘K（其他系统上是 Ctrl K）。用 `shortcut` 修改，多个快捷键用空格分隔，`Mod` 表示 ⌘ 或 Ctrl：

```astro
<Search.Trigger target="search" shortcut="Mod+K /">…</Search.Trigger>
<Search.Trigger target="search" shortcut={false}>…</Search.Trigger>
```

`/` 这类不带修饰键的快捷键，在读者正在输入框里打字时不会触发。

## 按分组过滤

结果的形状由你决定，所以可以把页面在文档树里的位置也写进索引。用 `findPath` 拿到从根到当前页面的节点，写成 Pagefind 的过滤条件和元数据：

```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` 只索引正文，不索引侧边栏、页头这些每页都有的内容
- 搜索结果里，`page.meta.path` 就是「指南 › 页面布局」这样的位置
- 在 `Search.Root` 里放一组 `name="section"` 的单选框，`source` 从 `params.get('section')` 读到选中的分组，交给 Pagefind 的 `filters`

## 换成别的引擎或渲染器

换引擎只改 `source`：调用引擎的客户端，把结果映射成你自己的形状。

换渲染器只改 `render`，比如用 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} />);
}
```

## 样式

默认样式在 `@minidocs/astro/styles.css` 里。

结果是在浏览器里渲染的，`.astro` 文件里的作用域 `<style>` 管不到它们。给结果写样式时，用 Tailwind 的 class、`<style is:global>` 或者单独的 CSS 文件。

## 在开发服务器里

Pagefind 的索引在构建之后才生成，开发服务器里搜索会显示 `Search.Error`。想在本地试搜索，先构建，再用 `astro preview` 打开。

## 限制

- 只支持对话框，还没有直接放在页头、结果以下拉框出现的搜索框
