基于 Pagefind 实现静态博客站内搜索
立泉Jekyll 生成的静态博客并不支持站内搜索,以往实现要么使用搜索引擎的site:mudan.me
限定站点,要么搭配服务器自建全文搜索服务,但前者索引更新缓慢,后者则太“重”,总之对于倾向免费托管的静态博客并没有特别好的方案。
直到 2022 年 Pagefind 出现,一个基于 Rust 和 WebAssembly 的轻量静态搜索工具。先使用它的命令行工具扫描站点生成索引文件和对应的检索 API,所谓 API 其实是 Wasm 程序提供的 Javascript 函数接口,搜索时在 Web 页面调用会自动访问索引文件生成结果。
这种模式只是从浏览器访问云端静态资源,搜索算法在本地执行,不需要通常意义上的“搜索服务器”,而且它会尽量降低检索时传输的数据量以节省带宽。官方描述是 10000 个页面的全文搜索包括下载 Wasm 程序在内传输的数据量不超过 400KB,大部分网站会在 100KB 左右。
索引
生成索引需要先安装 Pagefind,官方提供 npm 包,可以通过 npx 直接使用:
# 扫描 --site 指定的目录,将索引输出到 --output-path 指定的目录下
npx -y pagefind --site _site --output-path npm/pagefind
# 注意此命令并不会清除已有数据,而是覆盖
# 所以每次执行前需要手动删除上次的索引,否则可能越积越多
或下载预编译的二进制文件安装到本机,运行速度更快。注意如果要处理中文必须安装 pagefind_extended 而不是默认的 pagefind,否则搜索匹配效果会很差,上面 npx 使用的就是 extended 版本。
# 解压后把 pagefind_extended 可执行文件添加到环境变量中,以方便在 Terminal 中使用
# 对 macOS,只需将其软链接到 /usr/local/bin 中
ln -s /Users/apqx/Documents/MyApp/pagefind_extended-v1.1.1-x86_64-apple-darwin/pagefind_extended /usr/local/bin/pagefind
# 生成索引,安装到本机省去了 npx 检测网络的步骤,执行时更快
pagefind --site _site --output-path npm/pagefind
执行后会在./npm/pagefind
目录中生成以下内容:
# 索引分页
|-fragment/
|-index/
# 搜索 API
|-pagefind.js
# 默认 UI
|-pagefind-ui.js
|-pagefind-ui.css
# 模块化 UI
|-pagefind-modular-ui.js
|-pagefind-modular-ui.css
# 其它索引项和 Wasm 程序文件
...
注意到里面有名为*-ui
的 JS 和 CSS,它们是 Pagefind 预置的搜索界面,简单两步即可嵌入站点:
<!-- 引入UI资源 -->
<link href="/pagefind/pagefind-ui.css" rel="stylesheet">
<script src="/pagefind/pagefind-ui.js"></script>
<div id="search"></div>
<script>
// 创建UI插入点
window.addEventListener('DOMContentLoaded', (event) => {
new PagefindUI({ element: "#search", showSubResults: true });
});
</script>
pagefind-ui
大概长这样,搜索结果包含下级标题和摘要,关键样式通过 CSS 全局变量自定义,比如背景色对应--pagefind-ui-background
。
至于pagefind-modular-ui
则是模块化的 UI 组件,搜索框和搜索结果分开,可以自由插入到指定位置,配置更加灵活,详细信息参考这里。
一般这些文件应该和博客主站放在一起,但对于考虑国内访问的海外站点,通常会把它们单独放到大陆的服务器或 OSS 上优化访问速度。
搜索
如果对预置 UI 不满意可以使用 Pagefind 提供的 JS 函数接口手动调用搜索 API,自行处理搜索逻辑和结果展示。
// pagefind.js 文件 URL,我在部署时把它放到国内 OSS 上,与主站分离
const pagefindUrl: string = isDebug() ? "/npm/pagefind/pagefind.js" : "https://apqx.***.com/blog/pagefind/pagefind.js"
// 动态加载
const pagefind = await import(pagefindUrl)
// 初始化
pagefind.init()
// 执行搜索,获取结果
const pagefindResult = await this.pagefind.search(key)
配合 Material Design 做出的搜索 Dialog:
注意图中提示,Pagefind 为实现轻量化是对单词建立索引,而它的中文分词并不完善,一些人名之类的低频词组并不能被正确识别,这个时候可以尝试在“单字”之间加空格,它会搜索同时包含这些“字”的结果,匹配准确度更好。
Webpack
如上所述调用搜索 API 很简单,只需要import
对应的 JS Module,但在 Webpack 中import
的 JS 必须是本地文件 且会被直接打包而非运行时从指定位置加载,导致 Pagefind 搜索函数不能正确访问到云端索引。这个问题困扰我很久,毕竟不是 Web 开发者,按照文档指引却被一个不显眼的细节卡住…不断试错,Google 长串 Error 翻好多页才终于找到与之相关的一篇文章。
正确做法仅仅是添加一个webpackIgnore
注释,告诉 Webpack 不要打包import
的这个pagefind.js
文件,留到运行时加载。
const pagefind = await import(/*webpackIgnore: true*/ pagefindUrl)
就是这么简单🙄。
未完待续
Pagefind 提供的基础搜索对满足博客的简单需求已经足够,此外它支持限定索引范围、配置索引元数据、搜索过滤、搜索排序、搜索多个源等高阶功能,配置得当甚至可以替代 Jekyll 的 Tag 标签和 Pagination 分页。
这是我最近考虑要做的事情,分页,用它还是 Jekyll 的原生功能呢。