From 2275f964dcae23d5c70f6b3285e75324b7addfa5 Mon Sep 17 00:00:00 2001 From: fullsend-code <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:09:20 +0000 Subject: [PATCH 1/3] feat(#6645): use AND semantics for docs site multi-word search Multi-word queries in the vendored VPLocalSearchBox now require all terms to appear on the same page (combineWith: 'AND') instead of matching any term independently (the MiniSearch default of OR). Quoted phrases like "eval scenario" trigger exact-phrase post-filtering: after the AND search, page modules are rendered and their plain text is checked for the adjacent phrase. Results whose page text cannot be loaded are kept (graceful degradation). Changes: - searchQuery.ts: parseSearchQuery() extracts double-quoted phrases; textContainsPhrases() checks case-insensitive substring matches. - VPLocalSearchBox.vue: search call uses combineWith 'AND', parses query for phrases, post-filters via loadPageText(). - searchQuery.test.ts: 17 unit tests covering query parsing and phrase matching. Closes #6645 --- .../theme/components/VPLocalSearchBox.vue | 76 +++++++++-- docs/.vitepress/theme/searchQuery.test.ts | 120 ++++++++++++++++++ docs/.vitepress/theme/searchQuery.ts | 40 ++++++ 3 files changed, 227 insertions(+), 9 deletions(-) create mode 100644 docs/.vitepress/theme/searchQuery.test.ts create mode 100644 docs/.vitepress/theme/searchQuery.ts diff --git a/docs/.vitepress/theme/components/VPLocalSearchBox.vue b/docs/.vitepress/theme/components/VPLocalSearchBox.vue index 63048810a1..e9c939af57 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 { parseSearchQuery, textContainsPhrases } from "../searchQuery"; const emit = defineEmits<{ (e: "close"): void; @@ -174,17 +175,41 @@ 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: Record = { 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. + if (phrases.length > 0 && searchResults.length > 0) { + const pageIds = [...new Set(searchResults.map((r) => r.id.slice(0, r.id.indexOf("#"))))]; + const pageTextMap = new Map(); + await Promise.all( + pageIds.map(async (pid) => { + const text = await loadPageText(pid); + if (text) pageTextMap.set(pid, text); + }), + ); + if (canceled) return; + + searchResults = searchResults.filter((r) => { + const pid = r.id.slice(0, r.id.indexOf("#")); + const pageText = pageTextMap.get(pid); + // Keep results whose page text could not be loaded (graceful degradation). + if (!pageText) return true; + return textContainsPhrases(pageText, phrases); + }); + } + + results.value = searchResults; enableNoResults.value = true; // Highlighting @@ -277,6 +302,39 @@ async function fetchExcerpt(id: string) { } } +/** Render a page module and return its plain-text content for phrase matching. */ +async function loadPageText(pageId: string): Promise { + const file = pathToFile(pageId); + if (!file) return ""; + try { + const mod = await import(/*@vite-ignore*/ file); + const comp = mod.default ?? mod; + if (!comp?.render && !comp?.setup) return ""; + const app = createApp(comp); + app.config.warnHandler = () => {}; + app.provide(dataSymbol, vitePressData); + Object.defineProperties(app.config.globalProperties, { + $frontmatter: { + get() { + return vitePressData.frontmatter.value; + }, + }, + $params: { + get() { + return vitePressData.page.value.params; + }, + }, + }); + const div = document.createElement("div"); + app.mount(div); + const text = div.textContent || ""; + app.unmount(); + return text; + } catch { + return ""; + } +} + /* Search input focus */ const searchInput = ref(); diff --git a/docs/.vitepress/theme/searchQuery.test.ts b/docs/.vitepress/theme/searchQuery.test.ts new file mode 100644 index 0000000000..5eeadbdb61 --- /dev/null +++ b/docs/.vitepress/theme/searchQuery.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { 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"], + }); + }); +}); + +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); + }); +}); diff --git a/docs/.vitepress/theme/searchQuery.ts b/docs/.vitepress/theme/searchQuery.ts new file mode 100644 index 0000000000..72ad70b013 --- /dev/null +++ b/docs/.vitepress/theme/searchQuery.ts @@ -0,0 +1,40 @@ +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); + return trimmed; + }); + return { query: query.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())); +} From 3917041010f54a4c8df54132b855da6194068ce7 Mon Sep 17 00:00:00 2001 From: fullsend-fix <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:33:32 +0000 Subject: [PATCH 2/3] fix: address review feedback on PR #6799 - Fix adjacent quoted phrases fusing tokens by padding replacements with spaces and normalizing whitespace in the final query string (e.g. "foo bar""baz qux" now produces "foo bar baz qux") - Narrow searchOpts type from Record to an explicit { combineWith: string; filter?: ... } for compile-time safety - Document AND semantics and exact-phrase matching in docs/doc-site.md - Add test for adjacent quoted phrases edge case Addresses review feedback on #6799 --- docs/.vitepress/theme/components/VPLocalSearchBox.vue | 4 +++- docs/.vitepress/theme/searchQuery.test.ts | 7 +++++++ docs/.vitepress/theme/searchQuery.ts | 6 ++++-- docs/doc-site.md | 1 + 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/.vitepress/theme/components/VPLocalSearchBox.vue b/docs/.vitepress/theme/components/VPLocalSearchBox.vue index e9c939af57..9c1695ec83 100644 --- a/docs/.vitepress/theme/components/VPLocalSearchBox.vue +++ b/docs/.vitepress/theme/components/VPLocalSearchBox.vue @@ -181,7 +181,9 @@ debouncedWatch( const scopeList = scopes.value; const { query, phrases } = parseSearchQuery(filterTextValue); - const searchOpts: Record = { combineWith: "AND" }; + const searchOpts: { combineWith: string; filter?: (r: SearchResult) => boolean } = { + combineWith: "AND", + }; if (active.size > 0) { searchOpts.filter = (r: SearchResult) => matchesActiveScopes(r.id, scopeList, active); } diff --git a/docs/.vitepress/theme/searchQuery.test.ts b/docs/.vitepress/theme/searchQuery.test.ts index 5eeadbdb61..ef3638dfa6 100644 --- a/docs/.vitepress/theme/searchQuery.test.ts +++ b/docs/.vitepress/theme/searchQuery.test.ts @@ -71,6 +71,13 @@ describe("parseSearchQuery", () => { 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", () => { diff --git a/docs/.vitepress/theme/searchQuery.ts b/docs/.vitepress/theme/searchQuery.ts index 72ad70b013..87e4a5bb67 100644 --- a/docs/.vitepress/theme/searchQuery.ts +++ b/docs/.vitepress/theme/searchQuery.ts @@ -24,9 +24,11 @@ export function parseSearchQuery(raw: string): ParsedQuery { const query = raw.replace(/"([^"]+)"/g, (_match, phrase: string) => { const trimmed = phrase.trim(); if (trimmed) phrases.push(trimmed); - return 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.trim(), phrases }; + return { query: query.replace(/\s+/g, " ").trim(), 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 From 20303e882ce1237e1c8130238774b6c33befa431 Mon Sep 17 00:00:00 2001 From: Marta Anon Date: Mon, 31 Aug 2026 18:44:38 +0200 Subject: [PATCH 3/3] fix: use stored index text for exact-phrase matching loadPageText() silently failed for every page because VitePress components need runtime context not provided in the detached render. The catch returned "" and graceful degradation kept all results, making the phrase filter a no-op. Replace with filterByPhrases() that checks against the text already stored in the MiniSearch index (added "text" to storeFields). This is synchronous, testable, and doesn't depend on page rendering. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Marta Anon --- .../theme/components/VPLocalSearchBox.vue | 58 ++----------------- docs/.vitepress/theme/searchQuery.test.ts | 57 +++++++++++++++++- docs/.vitepress/theme/searchQuery.ts | 16 +++++ 3 files changed, 76 insertions(+), 55 deletions(-) diff --git a/docs/.vitepress/theme/components/VPLocalSearchBox.vue b/docs/.vitepress/theme/components/VPLocalSearchBox.vue index 9c1695ec83..eb4842d41a 100644 --- a/docs/.vitepress/theme/components/VPLocalSearchBox.vue +++ b/docs/.vitepress/theme/components/VPLocalSearchBox.vue @@ -39,7 +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 { parseSearchQuery, textContainsPhrases } from "../searchQuery"; +import { filterByPhrases, parseSearchQuery } from "../searchQuery"; const emit = defineEmits<{ (e: "close"): void; @@ -79,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, @@ -191,25 +191,8 @@ debouncedWatch( let searchResults = index.search(query, searchOpts).slice(0, 16) as (SearchResult & Result)[]; // Post-filter for exact phrase matches when the query contained quotes. - if (phrases.length > 0 && searchResults.length > 0) { - const pageIds = [...new Set(searchResults.map((r) => r.id.slice(0, r.id.indexOf("#"))))]; - const pageTextMap = new Map(); - await Promise.all( - pageIds.map(async (pid) => { - const text = await loadPageText(pid); - if (text) pageTextMap.set(pid, text); - }), - ); - if (canceled) return; - - searchResults = searchResults.filter((r) => { - const pid = r.id.slice(0, r.id.indexOf("#")); - const pageText = pageTextMap.get(pid); - // Keep results whose page text could not be loaded (graceful degradation). - if (!pageText) return true; - return textContainsPhrases(pageText, phrases); - }); - } + // Uses the stored text from the search index — no async page rendering needed. + searchResults = filterByPhrases(searchResults, phrases); results.value = searchResults; enableNoResults.value = true; @@ -304,39 +287,6 @@ async function fetchExcerpt(id: string) { } } -/** Render a page module and return its plain-text content for phrase matching. */ -async function loadPageText(pageId: string): Promise { - const file = pathToFile(pageId); - if (!file) return ""; - try { - const mod = await import(/*@vite-ignore*/ file); - const comp = mod.default ?? mod; - if (!comp?.render && !comp?.setup) return ""; - const app = createApp(comp); - app.config.warnHandler = () => {}; - app.provide(dataSymbol, vitePressData); - Object.defineProperties(app.config.globalProperties, { - $frontmatter: { - get() { - return vitePressData.frontmatter.value; - }, - }, - $params: { - get() { - return vitePressData.page.value.params; - }, - }, - }); - const div = document.createElement("div"); - app.mount(div); - const text = div.textContent || ""; - app.unmount(); - return text; - } catch { - return ""; - } -} - /* Search input focus */ const searchInput = ref(); diff --git a/docs/.vitepress/theme/searchQuery.test.ts b/docs/.vitepress/theme/searchQuery.test.ts index ef3638dfa6..9448537773 100644 --- a/docs/.vitepress/theme/searchQuery.test.ts +++ b/docs/.vitepress/theme/searchQuery.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseSearchQuery, textContainsPhrases } from "./searchQuery"; +import { filterByPhrases, parseSearchQuery, textContainsPhrases } from "./searchQuery"; describe("parseSearchQuery", () => { it("returns the raw query and no phrases when there are no quotes", () => { @@ -125,3 +125,58 @@ describe("textContainsPhrases", () => { 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 index 87e4a5bb67..84a896bb0d 100644 --- a/docs/.vitepress/theme/searchQuery.ts +++ b/docs/.vitepress/theme/searchQuery.ts @@ -40,3 +40,19 @@ export function textContainsPhrases(text: string, phrases: string[]): boolean { 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); + }); +}