search: { enabled: true } in the config writes a JSON index of the site's text with every build. Reading it:
// src/search.ts
interface SearchDocument {
url: string;
anchor: string;
title: string;
heading: string;
content: string;
breadcrumb: string;
}
const input = document.querySelector<HTMLInputElement>('#search');
const results = document.querySelector<HTMLUListElement>('#results');
const meta = document.querySelector<HTMLMetaElement>('meta[name="stati:search-index"]');
let documents: SearchDocument[] | undefined;
async function search(query: string): Promise<SearchDocument[]> {
// The index's file name changes with every build; every page names it in this meta tag
documents ??= (await (await fetch(meta!.content)).json()).documents as SearchDocument[];
const words = query.toLowerCase();
return documents.filter((doc) => `${doc.title} ${doc.heading} ${doc.content}`.toLowerCase().includes(words));
}
input?.addEventListener('input', async () => {
if (!results || !meta) return;
const query = input.value.trim();
const hits = query.length < 2 ? [] : await search(query);
results.replaceChildren(
...hits.slice(0, 10).map((doc) => {
const link = document.createElement('a');
link.href = doc.anchor ? `${doc.url}#${doc.anchor}` : doc.url;
link.textContent = doc.breadcrumb;
const item = document.createElement('li');
item.append(link);
return item;
}),
);
});
<input id="search" type="search" placeholder="Search">
<ul id="results"></ul>
- A document is either a whole page (
level: 1, emptyanchor) or the section under one heading, with its text incontent. The home page and drafts aren't in it. - Build the results with
textContent. The index holds your own text, but text can have a<in it too. - For typo tolerance and ranking, hand the same documents to MiniSearch or Fuse.js. The filters on this site's lists are MiniSearch over this index.
In the Stati docs: Auto-injected meta tag, Search index structure and Implementing client-side search.