Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions docs/.vitepress/theme/components/VPLocalSearchBox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -78,7 +79,7 @@ const searchIndex = computedAsync(async () =>
markRaw(
MiniSearch.loadJSON<Result>((await searchIndexData.value[localeIndex.value]?.())?.default, {
fields: ["title", "titles", "text"],
storeFields: ["title", "titles"],
storeFields: ["title", "titles", "text"],
searchOptions: {
fuzzy: 0.2,
prefix: true,
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] type-annotation-style

The variable searchOpts is typed as Record<string, unknown>. A narrower type like { combineWith: string; filter?: (r: SearchResult) => boolean } would be more precise and catch typos at compile time.

Suggested fix: Consider typing searchOpts more precisely: const searchOpts: { combineWith: string; filter?: (r: SearchResult) => boolean } = { 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);
}

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
Expand Down
182 changes: 182 additions & 0 deletions docs/.vitepress/theme/searchQuery.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
58 changes: 58 additions & 0 deletions docs/.vitepress/theme/searchQuery.ts
Original file line number Diff line number Diff line change
@@ -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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] edge-case

When two quoted phrases appear without whitespace between them (e.g., "foo bar""baz qux"), the regex replacement concatenates the trailing word of the first phrase with the leading word of the second in the query string (producing foo barbaz qux). This feeds a non-existent fused token to MiniSearch AND search, causing it to return zero results even though both phrases exist on the page.

Suggested fix: Pad the replacement with a space: return " " + trimmed (the outer .trim() already strips leading/trailing whitespace), or normalize consecutive spaces in the final query.

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);
});
}
1 change: 1 addition & 0 deletions docs/doc-site.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading