Skip to content
Packages

@docvia/search

Section-level full-text search index for docvia documentation, powered by Orama.

@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 the bundled docvia source and answer queries through a search endpoint. SSR/edge compatible: no static index is shipped to the browser, and the index is derived from the already-bundled virtual:docvia/source content, so there is no filesystem access or compiler at request time. 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

SubpathResolves toPurpose
.package entryEdge-safe runtime search: createFromSource, createSearchHandler, createFetchClient, the static createSearch, and the indexer/extraction APIs.
./nodeNode entrybuildSearchIndex compiles the docs and emits a serialized static index (build time, Node only).

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.

Build the index once per server instance from the docvia source, expose it as an endpoint, and query it from the client. The index lives in server memory and is built from content that is already bundled into the SSR output, so 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 { docs } from "virtual:docvia/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(docs).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 sectionId is the heading's id, 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>;
}
MethodBehavior
buildIndexIndexes a full set of pages from scratch.
updateIndexIncrementally re-indexes changed pages and drops every section belonging to a removed slug.
exportIndexSerializes 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;
  score: number;
}
FieldMeaning
slugSlug of the page the section belongs to.
sectionIdHeading id of the matched section ("_top" for the lead region).
sectionTitleHeading text of the matched section.
pageTitleTitle of the containing page.
contentFull section text, for rendering a highlighted match snippet.
scoreRelevance 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.limit defaults to 10.
  • 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: docviaCollection | docviaSource,
  options?: { defaultLimit?: number },
): Promise<SearchServer>;

Headless server index. Walks every page's rendered content from a docvia source, either a single collection (say docs from virtual:docvia/source) or a whole { collections } source, and builds an in-memory Orama index. 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.

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, as exported by a ?docvia module) into section-level documents. Heading anchors come from props.id; highlighted code (html nodes) is stripped to text. Used internally by createFromSource.

SearchDocument

Re-exported from @docvia/ir:

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})`);
}