Background
最开始我找的是 FlexSearch,但是发现它的教程是基于 requirejs 的,尝试了下导入失败。在逛 Svelte Discord 的时候,发现有人推荐了 Minisearch。文档非常简单,于是就决定用它了。
Install
npm install minisearch
Prepare Data
我的需求是可以通过关键词搜索技术周报,也就是几十个 md 文件。因为是托管在 vercel 上的静态站点,所以我写个小脚本,将所有 md 文件的内容提取出来,格式化后保存到一个 search.json 文件中,放到 static 静态资源目录下,这样在客户端可以直接通过 fetch 读取 json 数据,MiniSearch 在客户端内存中加载文档。
// tools/build-search.js
import * as path from 'path';
import glob from 'fast-glob';
import { readFileSync, writeFileSync } from 'fs';
const root = process.cwd();
async function importNew(filename) {
const file = path.join(root, 'weekly-news', filename);
let str = readFileSync(file, 'utf8');
const rows = str.split('\n');
const title = rows.shift().slice(1).trim();
const blocks = [];
rows.forEach((row) => {
if (row.startsWith('>')) row = row.slice(1);
if (row.startsWith('-')) row = row.slice(1);
row = row.trim();
row = row.replace(/^\d+\./g, '');
row = row.replace(/^\d+、/g, '');
row = row.replace(/\]\(.*?\)/g, ']');
row = row.replace('[', '').replace(']', '');
let x = row.trim();
if (x.length < 2) return;
if (x.startsWith('##')) return;
if (x.startsWith('作者介绍')) return;
if (x.startsWith('![')) return;
if (x.startsWith('```')) return;
if (x.startsWith('- ')) return;
if (x.startsWith('—')) x = x.slice(1);
if (x.startsWith('!')) x = x.slice(1);
blocks.push(x);
});
return {
id: filename.replace('.md', ''),
title,
blocks
};
}
async function getSearchData() {
const dir = path.join(root, 'weekly-news');
let files = await glob('*.md', {
cwd: dir,
onlyFiles: true,
ignore: ['README.md']
});
return await Promise.all(files.map(importNew));
}
// generate search.json
const data = await getSearchData();
writeFileSync(path.join(root, 'static/search.json'), JSON.stringify(data));
因为每周才更新一篇周报,在 git push 推送到 vercel 前,先本地npm run search
生成一下 search.json 文件,然后再发布即可。
44 篇周报,生成了 1700 多条索引记录,130kb 大小。我觉得能够接受,即使再更新两年,问题也不大。
Index Documents
新建一个库文件,比如 src/lib/search.js
。
import MiniSearch from 'minisearch';
let miniSearch = new MiniSearch({
fields: ['text'], // fields to index for full-text search
storeFields: ['title', 'text'] // fields to return with search results
});
// only init once
export async function init() {
if (miniSearch._nextId === 0) {
const http = await fetch('/search.json');
if (http.ok) {
const documents = [];
const blocks = await http.json();
blocks.forEach((item) => {
item.blocks.forEach((block, index) => {
documents.push({
id: item.id + '-' + index,
title: item.title,
text: block
});
});
});
miniSearch.addAll(documents);
}
}
}
export function searchText(text) {
const res = miniSearch.search(text, { prefix: true });
const titles = [];
const results = [];
res.forEach((item) => {
if (!titles.includes(item.title)) {
titles.push(item.title);
results.push(item);
}
});
return results;
}
miniSearch 有一个_nextId 属性,正好可以用来判断是否已经索引过文档。每个文档记录必须有一个唯一的 id,这里我用了路由字段,也就是 md 的文件名。
Use in SvelteKit
搜索框是一个 Modal 组件,放在src/routes/__layout.svelte
中,点击后弹出 Modal。
在 onMount 中初始化索引,为了避免重复索引导致报错,所以在 init 函数中,只在没有索引时才下载 json 并索引全部文档。
<script>
import { Search } from 'lucide-svelte';
import { init, searchText } from '$lib/search.js';
import { onMount } from 'svelte';
import { modalStore } from '@skeletonlabs/skeleton';
let text = '';
let results = [];
let elemDocSearch; //dom element to search results
function onSearch() {
results = searchText(text);
}
//init search index
onMount(async () => {
await init();
});
// focus first anchor element
function onKeyDown(event) {
if (['Enter', 'ArrowDown'].includes(event.code)) {
const queryFirstAnchorElement = elemDocSearch.querySelector('a');
if (queryFirstAnchorElement) queryFirstAnchorElement.focus();
}
}
</script>
<div
bind:this={elemDocSearch}
class="card bg-surface-100/60 dark:bg-surface-500/30 backdrop-blur-lg overflow-hidden w-full max-w-[800px] shadow-xl mt-8 mb-auto"
>
<header class="p-4 bg-surface-300-600-token flex items-center">
<Search />
<input
class="bg-transparent border-0 ring-0 focus:ring-0 w-full px-4 text-lg focus:border-0 hover:border-0 hover:ring-0 outline-none"
type="search"
placeholder="Search..."
bind:value={text}
on:input={onSearch}
on:keydown={onKeyDown}
name="search"
/>
</header>
{#if results.length > 0}
<nav class="list-nav overflow-x-auto max-h-[480px] hide-scrollbar space-y-4 p-4" tabindex="-1">
{#each results as item}
<a
href={`/weekly-news/${item.id.split('-')[0]}`}
on:click={() => {
modalStore.close();
}}
class="block hover:card hover:variant-soft"
>
<div class="space-y-2">
<h4 class="h5 md:h4 block underline">{item.title}</h4>
<div class="text-sm md:text-base break-words whitespace-normal">
{item.text}
</div>
</div>
</a>
{/each}
</nav>
{:else}
<div class="p-4">
{#if text !== ''}
<p>No results found for <code class="code">{text}</code>.</p>
{:else}
<p>Please search something.</p>
{/if}
</div>
{/if}
<footer class="hidden md:flex items-center gap-2 bg-surface-300-600-token p-4 text-xs font-bold">
<div><kbd class="kbd">Esc</kbd> to close</div>
<div><kbd class="kbd">Tab</kbd> to navigate</div>
<div><kbd class="kbd">Enter</kbd> to select</div>
</footer>
</div>
Demo
点击顶部导航栏的搜索图标,弹出搜索框,输入关键字,回车或者点击搜索结果,即可跳转到对应的周报。
What I Learned
- 练习了下从 md 文件中提取结构化信息。
- 使用 miniSearch 进行客户端内存中的全文搜索。
- 使用 Skeleton 的 Modal 组件。
- Sveltekit 的键盘事件。