import { type Kysely } from 'kysely'; import type { Database } from '../db/schema.js'; /** * The reading half of search: the excerpt a result is shown with. * * The *matching* half is not here, deliberately — it is `ItemFilters.search` in `items.ts`, shared * with the admin's list and its status facets so there is one predicate rather than one per caller. * What lives here is the part only a results page needs, and only after the rows are known. */ /** * How much text an excerpt carries. * * Long enough for the phrase to sit in a sentence, short enough that ten of them are a page a * visitor can scan. Measured in characters rather than words because the window is cut around a * match position, and a word count would have to be resolved back to one anyway. */ export declare const EXCERPT_LENGTH = 200; /** * A window of an item's text around the first occurrence of the search term. * * Pure and exported for its own tests: string arithmetic that only ever runs inside a route is * reachable by no suite in this repo, which is the lesson `scaleSizes` cost a release to learn. * * Three things it deliberately does *not* do: * * - **No highlighting.** The excerpt is plain text, and a consumer renders it with `set:text` or * as a text node. Returning `` would make this the one delivery field that must be trusted * as HTML, on a value assembled from stored content — the sanitiser exists so that no such value * exists. A consumer wanting a highlight can find the term itself; it knows what it searched for. * - **No sentence detection.** A window cut at a word boundary reads fine and works in every * language; hunting for a full stop finds abbreviations, decimals and none of the punctuation a * language without full stops uses. * - **No match count or position score.** See `applyItemSort`'s bands: a `LIKE` answers whether a * term appears, and arithmetic on top of that is a ranking that looks principled and is not. */ export declare function buildExcerpt(text: string, term: string): string; /** * Excerpts for a page of results, in one query. * * One `in` rather than one lookup per result, following `ancestorPaths` — a search page is exactly * the shape that turns into an N+1 without anyone noticing, because it is correct at every size * somebody tests it at. * * Items with no row come back absent rather than empty: a missing row means the item has never been * indexed, and a caller that cannot tell that from "indexed, holds no prose" cannot diagnose a * database that has not been reindexed since the migration. */ export declare function loadSearchExcerpts(db: Kysely, itemIds: string[], term: string): Promise>; /** * Turn what somebody typed into an FTS5 `MATCH` expression, or null if there is nothing to match. * * **This is a boundary, not a convenience.** FTS5's query language is a language: `AND`, `OR`, `NOT`, * `NEAR`, quotes, parentheses, `*`, and column filters all mean something in it. Passing a search box * straight through means an editor typing `C++` or `"open house` or a bare `AND` gets a SQL error * rather than a result — not a security hole, since parameters are still bound, but a search that * breaks on ordinary punctuation is a search nobody trusts. Every token is therefore extracted and * re-quoted rather than escaped in place: the same allowlist-serialiser argument `sanitizeHtml` * makes, one layer down. * * Tokenising is `searchTokens`, in `searchTerms.js`, and it lives there rather than here because a * *consumer* needs the identical split to highlight the excerpt it is handed — `pure.ts` re-exports * it, which this module could never be. Two copies of the rule fail silently and in the direction * nobody checks: results that are right with a `` around the wrong span. * * Terms are ANDed, because that is what a second word means to somebody narrowing a search. The * **last** token gets a `*`, so typing "schol" finds "scholarship" while the editor is still typing; * earlier tokens do not, since a completed word is a word they meant. * * Returns null for input with no tokens at all (`!!!`, or whitespace). That is deliberately not the * same as matching nothing — the caller still runs the title and path predicates, so searching `?` * finds a page called `?` rather than erroring. */ export declare function toMatchQuery(input: string): string | null; /** * Item ids matching a search, best first, for the ordering pass only. * * Matching does **not** depend on this: `ItemFilters.search` carries its own FTS predicate, so a * caller that never calls this still narrows correctly, and the facet counts beside it agree because * they share that predicate. What this adds is `bm25`, which cannot be reached from inside the shared * builder — a correlated `MATCH` would re-run the full-text query once per row, which is the one * shape worse than the scan this replaced. * * Capped, and the cap is **reported rather than silently applied** — a search matching more than * `MAX_RANKED` items ranks the strongest `MAX_RANKED` and leaves the rest in path order, which is a * fact a caller may want to say out loud. Bounded because the ids travel back into the query as one * delimited parameter. */ export declare const MAX_RANKED = 500; export declare function rankedSearchIds(db: Kysely, search: string): Promise<{ ids: string[]; truncated: boolean; }>; export interface SearchIndexStatus { /** Every content item, whatever its status. */ items: number; /** Those with no row in the index — invisible to search until a reindex. */ unindexed: number; } /** * How much of the site search can actually see. * * The one question that distinguishes "this site has nothing about badgers" from "nobody has run * `npm run db:reindex` since the migration". Those look identical from a results page — both are an * empty list — and the second is a state a deployment can sit in indefinitely without a single * error anywhere. Settings → System reports it for that reason: an operator should be able to tell * them apart without reading the code. * * One query rather than two, with the conditional written as `sum(case …)` because it is the * counting idiom every dialect here shares — `count(… filter where …)` is not. */ export declare function searchIndexStatus(db: Kysely): Promise;