diff --git a/docs/.vitepress/theme/components/VPLocalSearchBox.vue b/docs/.vitepress/theme/components/VPLocalSearchBox.vue index 63048810a1..eb4842d41a 100644 --- a/docs/.vitepress/theme/components/VPLocalSearchBox.vue +++ b/docs/.vitepress/theme/components/VPLocalSearchBox.vue @@ -39,6 +39,7 @@ import { useData } from "vitepress"; import { LRUCache } from "vitepress/dist/client/theme-default/support/lru"; import { createSearchTranslate } from "vitepress/dist/client/theme-default/support/translation"; import { matchesActiveScopes } from "../searchScopes"; +import { filterByPhrases, parseSearchQuery } from "../searchQuery"; const emit = defineEmits<{ (e: "close"): void; @@ -78,7 +79,7 @@ const searchIndex = computedAsync(async () => markRaw( MiniSearch.loadJSON((await searchIndexData.value[localeIndex.value]?.())?.default, { fields: ["title", "titles", "text"], - storeFields: ["title", "titles"], + storeFields: ["title", "titles", "text"], searchOptions: { fuzzy: 0.2, prefix: true, @@ -174,17 +175,26 @@ debouncedWatch( if (!index) return; - // Search + // Search — use AND so multi-word queries require all terms on the page. + // Quoted substrings trigger exact-phrase post-filtering. const active = activeScopes.value; const scopeList = scopes.value; - const searchOpts = - active.size > 0 - ? { - filter: (r: SearchResult) => matchesActiveScopes(r.id, scopeList, active), - } - : {}; - results.value = index.search(filterTextValue, searchOpts).slice(0, 16) as (SearchResult & - Result)[]; + const { query, phrases } = parseSearchQuery(filterTextValue); + + const searchOpts: { combineWith: string; filter?: (r: SearchResult) => boolean } = { + combineWith: "AND", + }; + if (active.size > 0) { + searchOpts.filter = (r: SearchResult) => matchesActiveScopes(r.id, scopeList, active); + } + + let searchResults = index.search(query, searchOpts).slice(0, 16) as (SearchResult & Result)[]; + + // Post-filter for exact phrase matches when the query contained quotes. + // Uses the stored text from the search index — no async page rendering needed. + searchResults = filterByPhrases(searchResults, phrases); + + results.value = searchResults; enableNoResults.value = true; // Highlighting diff --git a/docs/.vitepress/theme/searchQuery.test.ts b/docs/.vitepress/theme/searchQuery.test.ts new file mode 100644 index 0000000000..9448537773 --- /dev/null +++ b/docs/.vitepress/theme/searchQuery.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import { filterByPhrases, parseSearchQuery, textContainsPhrases } from "./searchQuery"; + +describe("parseSearchQuery", () => { + it("returns the raw query and no phrases when there are no quotes", () => { + expect(parseSearchQuery("eval scenario")).toEqual({ + query: "eval scenario", + phrases: [], + }); + }); + + it("extracts a single quoted phrase", () => { + expect(parseSearchQuery('"eval scenario"')).toEqual({ + query: "eval scenario", + phrases: ["eval scenario"], + }); + }); + + it("extracts a phrase surrounded by unquoted terms", () => { + expect(parseSearchQuery('harness "eval scenario" config')).toEqual({ + query: "harness eval scenario config", + phrases: ["eval scenario"], + }); + }); + + it("extracts multiple quoted phrases", () => { + expect(parseSearchQuery('"eval scenario" "harness config"')).toEqual({ + query: "eval scenario harness config", + phrases: ["eval scenario", "harness config"], + }); + }); + + it("ignores empty quoted strings", () => { + expect(parseSearchQuery('foo "" bar')).toEqual({ + query: 'foo "" bar', + phrases: [], + }); + }); + + it("treats unmatched quotes as literal characters", () => { + expect(parseSearchQuery('"eval scenario')).toEqual({ + query: '"eval scenario', + phrases: [], + }); + }); + + it("handles a single unquoted word", () => { + expect(parseSearchQuery("harness")).toEqual({ + query: "harness", + phrases: [], + }); + }); + + it("handles an empty string", () => { + expect(parseSearchQuery("")).toEqual({ + query: "", + phrases: [], + }); + }); + + it("trims whitespace inside quoted phrases", () => { + expect(parseSearchQuery('" eval scenario "')).toEqual({ + query: "eval scenario", + phrases: ["eval scenario"], + }); + }); + + it("handles a quoted single word", () => { + expect(parseSearchQuery('"harness"')).toEqual({ + query: "harness", + phrases: ["harness"], + }); + }); + + it("separates adjacent quoted phrases without whitespace", () => { + expect(parseSearchQuery('"foo bar""baz qux"')).toEqual({ + query: "foo bar baz qux", + phrases: ["foo bar", "baz qux"], + }); + }); +}); + +describe("textContainsPhrases", () => { + it("returns true when there are no phrases", () => { + expect(textContainsPhrases("any text", [])).toBe(true); + }); + + it("returns true when the phrase appears in the text", () => { + expect( + textContainsPhrases("The eval scenario runner starts here.", ["eval scenario"]), + ).toBe(true); + }); + + it("returns false when the phrase does not appear adjacent", () => { + expect( + textContainsPhrases("The eval of each scenario is different.", ["eval scenario"]), + ).toBe(false); + }); + + it("matches case-insensitively", () => { + expect(textContainsPhrases("The Eval Scenario runner.", ["eval scenario"])).toBe(true); + }); + + it("requires all phrases to match", () => { + expect( + textContainsPhrases("eval scenario and harness config details", [ + "eval scenario", + "harness config", + ]), + ).toBe(true); + + expect( + textContainsPhrases("eval scenario but no harness here", [ + "eval scenario", + "harness config", + ]), + ).toBe(false); + }); + + it("returns true for a single-word phrase that appears in text", () => { + expect(textContainsPhrases("the harness is ready", ["harness"])).toBe(true); + }); + + it("returns false when text is empty and phrases are not", () => { + expect(textContainsPhrases("", ["eval scenario"])).toBe(false); + }); +}); + +describe("filterByPhrases", () => { + const results = [ + { title: "Getting Started", titles: ["Guides"], text: "The eval scenario runner starts here." }, + { title: "Config Reference", titles: ["Guides"], text: "Harness config and eval options." }, + { title: "Eval Overview", titles: ["Concepts"], text: "Each scenario runs independently." }, + ]; + + it("returns all results when there are no phrases", () => { + expect(filterByPhrases(results, [])).toEqual(results); + }); + + it("keeps only results whose text contains the exact phrase", () => { + const filtered = filterByPhrases(results, ["eval scenario"]); + expect(filtered).toHaveLength(1); + expect(filtered[0].title).toBe("Getting Started"); + }); + + it("requires all phrases to match", () => { + const filtered = filterByPhrases(results, ["eval scenario", "harness config"]); + expect(filtered).toHaveLength(0); + }); + + it("matches phrases against titles as well as text", () => { + const filtered = filterByPhrases(results, ["eval overview"]); + expect(filtered).toHaveLength(1); + expect(filtered[0].title).toBe("Eval Overview"); + }); + + it("matches phrases spanning title and text content", () => { + const filtered = filterByPhrases( + [{ title: "Scenario", titles: ["Eval"], text: "runner starts here" }], + ["eval scenario"], + ); + expect(filtered).toHaveLength(1); + }); + + it("keeps results with no text content (graceful degradation)", () => { + const sparse = [{ title: "", titles: [], text: undefined as unknown as string }]; + expect(filterByPhrases(sparse, ["anything"])).toHaveLength(1); + }); + + it("is case-insensitive", () => { + const filtered = filterByPhrases(results, ["EVAL SCENARIO"]); + expect(filtered).toHaveLength(1); + expect(filtered[0].title).toBe("Getting Started"); + }); + + it("works end-to-end with parseSearchQuery", () => { + const { phrases } = parseSearchQuery('"eval scenario" guide'); + const filtered = filterByPhrases(results, phrases); + expect(filtered).toHaveLength(1); + expect(filtered[0].title).toBe("Getting Started"); + }); +}); diff --git a/docs/.vitepress/theme/searchQuery.ts b/docs/.vitepress/theme/searchQuery.ts new file mode 100644 index 0000000000..84a896bb0d --- /dev/null +++ b/docs/.vitepress/theme/searchQuery.ts @@ -0,0 +1,58 @@ +export interface ParsedQuery { + /** The query string with double-quoted phrases stripped of their quotes. */ + query: string; + /** Exact phrases extracted from double-quoted substrings. */ + phrases: string[]; +} + +/** + * Parses a search query string, extracting double-quoted phrases. + * + * Returns the cleaned query (quotes removed, words kept for AND matching) + * and an array of exact phrases that must appear adjacent in results. + * + * Examples: + * parseSearchQuery('eval scenario') + * => { query: 'eval scenario', phrases: [] } + * parseSearchQuery('"eval scenario"') + * => { query: 'eval scenario', phrases: ['eval scenario'] } + * parseSearchQuery('harness "eval scenario" config') + * => { query: 'harness eval scenario config', phrases: ['eval scenario'] } + */ +export function parseSearchQuery(raw: string): ParsedQuery { + const phrases: string[] = []; + const query = raw.replace(/"([^"]+)"/g, (_match, phrase: string) => { + const trimmed = phrase.trim(); + if (trimmed) phrases.push(trimmed); + // Pad with spaces so adjacent quoted phrases don't fuse tokens + // (e.g. "foo bar""baz qux" → "foo bar baz qux", not "foo barbaz qux"). + return " " + trimmed + " "; + }); + return { query: query.replace(/\s+/g, " ").trim(), phrases }; +} + +/** + * Returns true when every phrase appears as a case-insensitive + * substring in the given text. + */ +export function textContainsPhrases(text: string, phrases: string[]): boolean { + if (phrases.length === 0) return true; + const lower = text.toLowerCase(); + return phrases.every((p) => lower.includes(p.toLowerCase())); +} + +/** + * Filters search results by requiring every phrase to appear in the + * result's concatenated title + text content. Results with no text + * content are kept (graceful degradation). + */ +export function filterByPhrases< + T extends { text?: string; title?: string; titles?: string[] }, +>(results: T[], phrases: string[]): T[] { + if (phrases.length === 0) return results; + return results.filter((r) => { + const content = [...(r.titles || []), r.title || "", r.text || ""].join(" "); + if (!content.trim()) return true; + return textContainsPhrases(content, phrases); + }); +} diff --git a/docs/doc-site.md b/docs/doc-site.md index e48fcd6f1e..7743e4c3b2 100644 --- a/docs/doc-site.md +++ b/docs/doc-site.md @@ -26,6 +26,7 @@ The `docs:build` script runs `git submodule update --init` before the VitePress - `getMarkdownFiles()` auto-discovers markdown files and subdirectory READMEs for dynamic sidebar sections (ADRs, experiments, design docs, specs, plans) - Symlinks connect submodule content into `docs/` (e.g. `docs/experiments` -> `../experiments`) - The `search.options.scopes` array in `config.ts` defines the scope pills shown in the search modal. Each scope has a `label` and a list of `prefixes` (path prefixes like `/docs/guides/`). When a user activates a scope, search results are filtered to pages whose path starts with one of the scope's prefixes. Every `docs/` subfolder that produces rendered pages must appear in at least one scope; otherwise its pages become unreachable when any scope pill is active. +- Multi-word search queries use **AND** semantics — all terms must appear on a page for it to match. Wrapping words in double quotes (e.g. `"eval scenario"`) enables exact-phrase matching: only pages containing the quoted words adjacent and in order are returned. ## Submodules