Docs index
Turn markdown into a ZBSearch index, and query it in the browser.
@zbsearch/docs-index is the engine-facing half of the documentation integrations: it turns markdown into
search records, builds a ZBSearch index from them, and queries that index in the browser.
It is the shared core behind the Docusaurus, Starlight and VitePress plugins. Reach for it directly when you are wiring ZBSearch into a framework none of those covers.
Installation
npm install @zbsearch/docs-indexBuilding an index
import { buildIndex, dialectOf, parseMarkdown } from '@zbsearch/docs-index/node';
import { HIERARCHY_SEPARATOR, type SearchRecord } from '@zbsearch/docs-index';
const file = 'docs/intro.md';
const parsed = parseMarkdown(await readFile(file, 'utf8'), { dialect: dialectOf(file) });
const records: SearchRecord[] = parsed.sections.map((section) => ({
title: parsed.title ?? 'Introduction',
section: section.heading,
hierarchy: ['Guides', 'Introduction', ...section.ancestors].join(HIERARCHY_SEPARATOR),
content: section.content,
url: section.anchor ? `/docs/intro#${section.anchor}` : '/docs/intro',
category: 'Docs',
path: section.ancestors.join(HIERARCHY_SEPARATOR),
}));
const payload = await buildIndex(records, 'english');Serialize payload to JSON and ship it with your site.
parseMarkdown
Splits a document into one section per heading, so a hit can land on a heading rather than a whole page.
Front matter, fenced code, MDX import/export statements, JSX and HTML are removed first: code blocks
match poorly and drown prose in punctuation, and JSX contributes prop names rather than words. Anchors follow
the rules Docusaurus, Starlight and VitePress all use, including explicit {#custom-id} syntax and
de-duplication of repeated headings.
Each section reports its heading, anchor, level, ancestors and plain-text content.
The dialect option decides how the source is read, and defaults to md - plain CommonMark. Pass mdx, or
let dialectOf(filePath) pick it from the extension, for files whose import and export lines carry their
ESM meaning. The distinction matters in both directions: a .md paragraph may legitimately begin with the
word "export", and MDX disables indented code blocks, so reading a .md file as mdx can turn an indented
line into a heading. The built-in Docusaurus, Starlight and VitePress integrations set this for you.
buildIndex
Returns a JSON-serializable payload carrying the index, the language it was tokenized with, and a version stamp the client checks before loading.
Only title, section, hierarchy and content are tokenized. url, category and path ride along
untouched and come back on every hit - which keeps permalinks out of the inverted index, where they would add
weight and noise without ever improving a match.
Searching in the browser
import { createIndexLoader, createSearcher } from '@zbsearch/docs-index';
const loadIndex = createIndexLoader(async () => (await fetch('/zbsearch-index.json')).json());
const searcher = createSearcher(loadIndex, {
boost: { title: 4, section: 3, hierarchy: 1.5, content: 1 },
maxResults: 12,
tolerance: 1,
threshold: 0,
snippetLength: 140,
});createIndexLoader fetches and rehydrates at most once per page session. Concurrent callers share one
promise, so prefetching on hover and then opening the dialog does not fetch twice, and a failed attempt is
forgotten so the next one retries.
ZBSearch itself is imported dynamically inside the loader, which keeps the engine out of your main bundle until someone actually searches.
The resulting searcher is exactly the shape
@zbsearch/searchbox-react and
@zbsearch/searchbox-vue both expect, so they drop together.
Entry points
| Entry point | Contents |
|---|---|
@zbsearch/docs-index | Record shape, schema, defaults, and the browser-side loader and searcher |
@zbsearch/docs-index/node | parseMarkdown, stripInlineMarkup and buildIndex |
The split matters: the node entry point pulls in a markdown parser you do not want in a browser bundle.