@docvia/search
Section-level full-text search index for docvia documentation, powered by Orama.
On this page
@docvia/search provides section-level full-text search over compiled docvia documentation, powered by Orama. It supports two modes:
- Headless server search (recommended). Build the index in memory on the server from a
loader()source and answer queries through a search endpoint. Each page'sstructuredDatais extracted at compile time, so there is no filesystem access or compiler at request time and nothing is shipped to the browser. This mirrors Fumadocs' server search. - Static index. Serialize the index to a string at build time and search it in the browser. For fully static sites with no server.
Install
pnpm add @docvia/search
Package exports
| Subpath | Resolves to | Purpose |
|---|---|---|
. | package entry | Edge-safe runtime search: createFromSource, createSearchHandler, createFetchClient, the static createSearch, and the indexer/extraction APIs. |
./node | Node entry | buildSearchIndex compiles the docs and emits a serialized static index (build time, Node only). |
./package.json | package.json | Package metadata. |
This package ships no binary.
Concepts
The index is section-level, not page-level. A page is split at each heading: everything between one heading and the next becomes a SearchDocument. This means a search result points directly at the relevant section of a page rather than just the page itself.
Headless server search (recommended)
Build the index once per server instance from your loader() source, expose it as an endpoint, and query it from the client. It runs anywhere the server runs, on Node or the edge (Cloudflare Workers, and so on).
// src/routes/api/search/+server.ts (SvelteKit)
import { createFromSource, createSearchHandler } from "@docvia/search";
import { source } from "$lib/source";
import type { RequestHandler } from "./$types";
// Dynamic: runs in the worker, not prerendered.
export const prerender = false;
// Build the index lazily on first request, then reuse it.
let handler: Promise<(request: Request) => Promise<Response>> | null = null;
const getHandler = () =>
(handler ??= createFromSource(source).then(createSearchHandler));
export const GET: RequestHandler = async ({ request }) =>
(await getHandler())(request);
// On the client: query the endpoint (debounce at the call site).
import { createFetchClient } from "@docvia/search";
const searcher = createFetchClient("/api/search");
const results = await searcher.search("incremental rebuild", { limit: 8 });
createSearchHandler returns a framework-agnostic Web Request handler, so the same server code drops into a Next.js route handler, a Hono route, or any Web-standard server.
API reference
extractTextFromIR
function extractTextFromIR(children: readonly IRNode[]): string;
Recursively walks an array of IR nodes and concatenates their textual content. Plain text nodes contribute their text; code blocks contribute their value. The result is the flat, searchable text for a region.
extractSections
function extractSections(doc: IRDocument): SearchDocument[];
Splits a single IRDocument into one SearchDocument per heading region.
- The
sectionIdis the heading'sid, or"_top"for the region preceding the first heading. - Empty sections (no extracted text) are dropped.
interface SearchIndexer
interface SearchIndexer {
buildIndex(pages: readonly IRDocument[]): Promise<void>;
updateIndex(changed: readonly IRDocument[], removed: readonly string[]): Promise<void>;
exportIndex(): Promise<string>;
}
| Method | Behavior |
|---|---|
buildIndex | Indexes a full set of pages from scratch. |
updateIndex | Incrementally re-indexes changed pages and drops every section belonging to a removed slug. |
exportIndex | Serializes the current index to a string suitable for shipping to the client. |
createSearchIndexer
function createSearchIndexer(): Promise<SearchIndexer>;
Creates a SearchIndexer. The Orama schema is:
{
sectionTitle: "string",
pageTitle: "string",
content: "string",
slug: "string",
sectionId: "string",
depth: "number"
}
The indexer maintains an internal slug → ids map so updateIndex can find and remove every section document that belongs to a changed or removed slug.
interface SearchResult
interface SearchResult {
slug: string;
sectionId: string;
sectionTitle: string;
pageTitle: string;
content: string;
url?: string;
score: number;
}
| Field | Meaning |
|---|---|
slug | Slug of the page the section belongs to. |
sectionId | Heading id of the matched section ("_top" for the lead region). |
sectionTitle | Heading text of the matched section. |
pageTitle | Title of the containing page. |
content | Full section text, for rendering a highlighted match snippet. |
url | Link to the section (page URL plus #heading), when the page URL is known. Set by createFromSource. |
score | Relevance score from Orama. |
createSearch
function createSearch(indexData: string): Promise<{
search(query: string, options?: { limit?: number }): Promise<SearchResult[]>;
}>;
Deserializes the string produced by exportIndex() and returns a client-side search helper (static mode).
options.limitdefaults to10.- Field boosts during ranking:
sectionTitle×3,pageTitle×2,content×1, so a query that matches a heading ranks above one that only matches body text.
createFromSource
function createFromSource(
source: LoaderOutput | docviaCollection | docviaSource,
options?: {
defaultLimit?: number;
records?: Iterable<SearchRecord>;
},
): Promise<SearchServer>;
interface SearchRecord {
id: string;
title: string;
url: string;
body: string;
section?: string; // heading shown in results; defaults to title
}
Headless server index. Walks every page of a loader() source, calls page.data.load() and indexes its compile-time structuredData into an in-memory Orama index. Legacy config collections (one collection or a whole { collections } source) also work; pages without structuredData fall back to their rendered content. Returns a SearchServer with search(query, { limit }) and a size (indexed section count). Call once per server instance and cache the promise. Edge-safe: no filesystem, no compiler.
records adds non-Markdown entries (component specs, API symbols) to the same index, so one query searches both:
const server = await createFromSource(source, {
records: [{ id: "api:docvia", title: "docvia()", url: "/docs/packages/build/vite", body: "The Vite plugin." }],
});
createSearchHandler
function createSearchHandler(
server: SearchServer,
): (request: Request) => Promise<Response>;
Wraps a SearchServer as a framework-agnostic Web Request handler. Reads ?query= (or ?q=) and an optional ?limit=, and responds with the SearchResult[] as JSON.
createFetchClient
function createFetchClient(endpoint?: string): {
search(query: string, options?: { limit?: number }): Promise<SearchResult[]>;
};
Client helper for headless mode: queries a search endpoint backed by createSearchHandler (default "/api/search") and returns the same SearchResult[] as the static client. Debounce calls at the call site.
extractSectionsFromContent
function extractSectionsFromContent(
content: RenderOutput | readonly RenderOutput[] | undefined,
page: { slug: string; pageTitle: string },
): SearchDocument[];
The runtime counterpart to extractSections: splits a page's rendered content (a RenderOutput tree) into section-level documents. Heading anchors come from props.id; highlighted code (html nodes) is stripped to text. createFromSource uses it only for pages without structuredData.
SearchDocument
Re-exported from @docvia/core:
interface SearchDocument {
slug: string;
sectionId: string;
sectionTitle: string;
content: string;
depth: number;
pageTitle: string;
}
A single indexable section. depth is the heading depth of the section.
Usage
Build the index (build time)
import { createSearchIndexer } from "@docvia/search";
const indexer = await createSearchIndexer();
await indexer.buildIndex(allDocuments);
const serialized = await indexer.exportIndex();
// Persist `serialized`, for example as a static asset.
Incremental updates (dev / watch)
// `changedDocs` are re-parsed IRDocuments; `removedSlugs` were deleted.
await indexer.updateIndex(changedDocs, removedSlugs);
const serialized = await indexer.exportIndex();
Search (client side)
import { createSearch } from "@docvia/search";
const { search } = await createSearch(serialized);
const results = await search("incremental rebuild", { limit: 5 });
for (const r of results) {
console.log(`${r.pageTitle} › ${r.sectionTitle} (#${r.sectionId})`);
}