minidocs

搜索

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 打开。

限制

  • 只支持对话框,还没有直接放在页头、结果以下拉框出现的搜索框
本页目录
搜索范围
输入关键词开始搜索