搜索
minidocs 提供搜索对话框:输入框、各种状态的提示、快捷键和键盘操作。用什么搜索引擎、结果长什么样,由你决定:
- 怎么搜:一个
source函数,可以用 Pagefind、Algolia 或任何引擎,返回什么形状的结果都可以 - 怎么渲染:一个
render函数,可以用 Preact、React 或原生 DOM,把结果渲染进容器
完整的例子
下面用 Pagefind 和 Preact。Pagefind 在构建后为生成的 HTML 建索引,不需要服务器;Preact 只用来渲染结果。
安装
pnpm add preact
pnpm add -D pagefind
在构建之后建索引:
// package.json
{
"scripts": {
"build": "astro build && pagefind --site dist"
}
}
Astro 默认的 tsconfig 会原样保留 JSX,要告诉它用 Preact 编译。不需要安装 @astrojs/preact:
// tsconfig.json
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
}
}
放进布局
搜索由两部分组成:打开它的按钮,和对话框本身。按钮用 target 指向 Search.Root 的 id,所以两者可以放在页面的不同位置:
---
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 的索引在构建之后才生成,所以在运行时从站点加载:
// 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:
// 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
attachSearch(root, { source, render });
source(query, { signal, params }) 返回结果数组,可以是 Promise:
- 打开对话框、清空关键词时,
query是空字符串。返回空数组就显示Search.Idle,也可以返回最近访问这类推荐 - 有新的查询开始时,
signal会被取消,过时的结果会被丢掉 params是Search.Root里所有带name的表单控件的值,可以用来做过滤,见下文按分组过滤- 抛出错误时显示
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:
<Search.Trigger target="search" shortcut="Mod+K /">…</Search.Trigger>
<Search.Trigger target="search" shortcut={false}>…</Search.Trigger>
/ 这类不带修饰键的快捷键,在读者正在输入框里打字时不会触发。
按分组过滤
结果的形状由你决定,所以可以把页面在文档树里的位置也写进索引。用 findPath 拿到从根到当前页面的节点,写成 Pagefind 的过滤条件和元数据:
---
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:
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 打开。
限制
- 只支持对话框,还没有直接放在页头、结果以下拉框出现的搜索框