使用Pagefind为VitePress文档添加离线全文搜索能力

4,131 阅读5分钟

前言

VitePress 相信大家都或多或少听说过或者用过了

默认 UI相比 VuePress2.x 好看,启动速度也快(由Vite驱动,当然VuePress也可以切换构建引擎至Vite)

做内容定制也相对简单,笔者的很多静态文档站点(使用VuePress1.x),文章内容多的时候启动非常的慢,于是就从之前的 VuePress 迁移到了 VitePress,并做了一个博客主题 @sugarat/theme => 之前也有过介绍一个简约风的VitePress博客主题

但是 VitePress 官方目前还没有内置开箱即用的搜索能力(相关PR还在施工中)

文档里首推使用 Algolia DocSearch, 这个需要申请,流程相对较慢,公司内网文档也无法接入使用。

推荐的另一个方案是使用 vitepress-plugin-search 基于 flexsearch 实现,但是默认的UI较丑(与 Algolia 的UI差距较大),对中文没有提供开箱即用的支持,需要进行一定的配置

目前常用的除了 flexsearch,还有 MiniSearch

笔者之前在逛GitHub的时候还发现了一个 Pagefind(基于Rust实现,检索生成后的HTML页面内容,然后自动构建索引文件)

感觉挺有意思的,就研究使用了一番,然后将其做成了一个可直接使用的 VItePress 插件(也就是本文将介绍的)

本文主要演示下 接入步骤和效果,再简单介绍Pagefind,最后讲解一下插件原理

接入后效果

只需要2步即可完成接入

① 安装 vitepress-plugin-pagefind

npm i vitepress-plugin-pagefind
# or
yarn add vitepress-plugin-pagefind
# or
pnpm add vitepress-plugin-pagefind

② 在.vitepress/config.ts引入使用

import { defineConfig } from 'vitepress'
import { pagefindPlugin } from 'vitepress-plugin-pagefind'
export default defineConfig({
  vite:{
    plugins:[pagefindPlugin()],
  }
})

UI如下(power by vue-command-palette

搜索按钮搜索框

Pagefind介绍

Pagefind是一个完全静态的搜索库,旨在在大型网站上表现良好,同时尽可能地减少用户带宽的使用,且不需要进行任何基础设施托管。

Pagefind在Hugo,Eleventy,Jekyll,Next,Astro,SvelteKit或任何其他SSG之后运行。安装过程始终相同:Pagefind仅需要一个包含构建的静态文件的文件夹,因此在大多数情况下,无需进行配置即可开始使用。

索引后,Pagefind会向您的构建文件添加静态搜索包,该包公开了可以在站点任何位置使用的JavaScript搜索API。Pagefind还提供了一个可无需配置即可使用的预构建UI

总结:框架无关,直接解析静态站点的产物,然后生成索引文件,提供开箱即用的API和组件,支持开箱即用的多语言站点,零配置

生成内容索引

可直接通过npx调用,指定构建后的产物目录即可

以 vitepress 默认产物目录为例

npx pagefind --source docs/.vitepress/dist

一般毫秒级就完成了页面内容的分析与pagefind需要的资源转换

默认会自动扫描指定目录下所有的html资源,将带有data-pagefind-body属性的元素作为索引的位置,否则的话使用<body>作为索引位置

会自动识别 html 中的 lang 属性,使用对应的分词策略处理,目前已经内置多种语言支持

生成的相关文件默认在_pagefind目录中,内容如下

使用内置搜索UI

在生成索引的过程中,pagefind也会把内置的搜索框UI相关资源放入其中

在页面中只需要导入相应的js/css资源即可

<link href="/_pagefind/pagefind-ui.css" rel="stylesheet">
<script src="/_pagefind/pagefind-ui.js" type="text/javascript"></script>

<div id="search"></div>
<script>
    window.addEventListener('DOMContentLoaded', (event) => {
        new PagefindUI({ element: "#search" });
    });
</script>

搜索框样式如下

使用JS API

默认的搜索框样式不满足的话可以,自定义搜索框逻辑,通过JS API调用搜索能力

// 1. Initializing Pagefind
const pagefind = await import("/_pagefind/pagefind.js");
// 2. search docs
const search = await pagefind.search("hello");
// 3. Loading a result
const oneResult = await search.results[0].data();

搜索结果格式如下

interface SearchResult {
  url: string;
  excerpt: string;
  filters: Record<string,any>;
  meta: Record<string,any>;
  content: string;
  word_count: number;
}

一些不足

  • 仅针对构建后的产物进行索引,开发环境下无法工作
  • 对中文和日语等支持相对英语会差一点(区别见下截图)
  • 不支跳转至标题

插件实现原理解析

这部分主要介绍 vitepress-plugin-pagefind 的关键实现部分(细节可看源码,如读者有更好的实现思路可以评论区交流)

几个关键步骤:

  1. 替换默认搜索组件
  2. 目标元素上插入检索的标识data-pagefind-body
  3. 插入运行时的script脚本

替换默认搜索组件

通过查看默认布局组件Layout.vue源码其中搜索组件是被VPNavBarSearch.vue引入

咱只需要通过插件添加一个 alias 规则,将其指向自定义的组件即可

这个使用插件的 config 钩子即可

export function pagefindPlugin() {
  return {
    name: 'vitepress-plugin-pagefind',
    enforce: 'pre',
    config: () => ({
      resolve: {
        alias: {
          './VPNavBarSearch.vue': 'vitepress-plugin-pagefind/Search.vue'
        }
      }
    }),
  }
}

添加索引标识

由于vitepress的默认 body元素中包含 navBar,footer,sidebar等等等内容

默认情况下每个页面的代码中都会包含这些公共内容,因此会导致检索出的内容有很多重复信息

理论上只需要检索用户编写的 markdown内容生成的部分

也就是VPContent.vue组件渲染的内容(当然里面包含了3种情况VPHomeVPPageVPDoc这里可以不做区分)

这个通过插件的transform钩子处理一下,构建时替换源码中VPContent的内容

export function pagefindPlugin() {
  return {
    // ... other code
    transform(code, id) {
      if (id.endsWith('theme-default/Layout.vue')) {
        return code.replace('<VPContent>', '<VPContent data-pagefind-body>')
      }
      return code
    }    
  }
}

运行时的脚本注入

因为相关资源是在Build之后才会生成,所以直接在源码中 import 会提示 module not found 导致构建失败

咱可以在搜索组件里写一段脚本,在页面运行后append到页面中,这段逻辑可以写到onBeforeMount周期函数中

import { onBeforeMount } from 'vue'

const addInlineScript = () => {
  const scriptText = `import('/_pagefind/pagefind.js')
        .then((module) => {
          window.__pagefind__ = module
        })
        .catch(() => {
          console.log('not load /_pagefind/pagefind.js')
        })`
  const inlineScript = document.createElement('script')
  inlineScript.innerHTML = scriptText
  document.head.appendChild(inlineScript)
}

onBeforeMount(() => {
  addInlineScript()
})

pagefind我这里采用import(source)动态导入,组件搜索直接使用window.__pagefind__来进行API的调用

最后

目前这一版插件主要是将pagefind做了一个简单的内置,没有对搜索结果进行优化,也不支持多级标题的跳转

后续是准备优化一下插件的本身实现和功能

  • 插件内部的hack实现替换为 VitePress 的 Build Hooks
  • 搜索内容的输入输出支持自定义的转换
  • 跳转支持标题
  • ...

欢迎各位在评论区交流想法