import { escape } from "html-escaper";
/** A single result rendered in the search dialog. */
export interface SearchHit {
url: string;
/** Title, possibly containing `` highlight markup. */
title: string;
/** Excerpt, possibly containing `` highlight markup. */
excerpt: string;
/** Ancestor section labels for the breadcrumb, e.g. `["Guides", "Auth"]`. */
breadcrumb?: string[];
/** Top-level section label, used by the filter pills. */
section?: string;
/** Plain-text page content, used to render the preview pane. */
content?: string;
/**
* Docs version the hit belongs to (`""` = current). Local indexes and the
* hosted Algolia/Typesense adapters set it; other providers leave it unset.
*/
version?: string;
}
/** A category pill with its result count. */
export interface SectionCount {
label: string;
count: number;
}
/** What every provider returns: ranked hits plus section facet counts. */
export interface SearchResult {
hits: SearchHit[];
sections: SectionCount[];
}
/** A configured query function — the common contract every provider returns. */
export type SearchFn = (
query: string,
options?: {
section?: string;
locale?: string;
/** Docs version to scope to (`""` = current); omitted disables it. */
version?: string;
}
) => Promise;
/** A document in the client-loaded `blume-search.json` index. */
export interface IndexedDocument {
route: string;
title: string;
description: string;
content: string;
breadcrumb?: string[];
section?: string;
locale?: string;
version?: string;
}
/** Max results surfaced in the dialog. */
export const SEARCH_LIMIT = 12;
/**
* How many ranked matches the static providers pull before filtering, so the
* section pills can count across more than just the visible page.
*/
export const RESULT_POOL = 48;
const REGEXP_SPECIAL = /[$()*+.?[\\\]^{|}]/gu;
const WORD_BREAK = /\s+/u;
/** Split a query into escaped, non-empty search tokens. */
const queryTokens = (query: string): string[] =>
query
.trim()
.split(WORD_BREAK)
.filter(Boolean)
.map((token) => token.replaceAll(REGEXP_SPECIAL, String.raw`\$&`));
/**
* Wrap query matches in ``, HTML-escaping the source text. Matching runs
* on the *raw* text and escaping on each segment — matching after escaping
* would let a query like "amp" or "lt" mark the inside of an entity produced
* from the source (`&` in "a & b"), corrupting the rendered excerpt.
*/
export const highlight = (text: string, query: string): string => {
const tokens = queryTokens(query);
if (tokens.length === 0) {
return escape(text);
}
const pattern = new RegExp(`(${tokens.join("|")})`, "giu");
return text
.split(pattern)
.map((segment, index) =>
index % 2 === 1 ? `${escape(segment)}` : escape(segment)
)
.join("");
};
// Either a tag-shaped run — an opening `<` with a letter or `/` after it,
// through the closing `>` (or end of string for an unterminated tag) — or a
// lone `<`. A run can't span a later `<` (`[^<>]`), so between the two
// alternatives every `<` in the input lands inside a captured run.
const ANGLE_RUN = /(?<\/?[a-z][^<>]*>?|<)/iu;
const BARE_MARK = /^<\/?mark>$/iu;
/**
* Reduce provider-supplied excerpt markup to the `` highlighting the
* dialog expects. Remote excerpts (Pagefind's index, hosted engines) are
* rendered via `innerHTML`, so the output alphabet is pinned: bare
* ``/`` tags (attributes make even a mark untrusted), text, and
* entities. Tag-shaped runs are dropped; every other `<` is escaped, which
* renders identically but can't be parsed as markup (`