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 (`