From f958fcb9dd4bc68bc8880f4c2a5bd8b090f23803 Mon Sep 17 00:00:00 2001 From: Hector Martinez Date: Wed, 29 Jul 2026 11:14:29 +0200 Subject: [PATCH] feat(#5630): add scoped search filters to docs site Override VitePress's VPLocalSearchBox with scope pills that filter search results by path prefix. Extract the filtering predicate into a tested utility. Add a vitepress type augmentation for the scopes config field. Make scope checkboxes keyboard-accessible and visible to assistive tech. Scope stylelint exemptions to inline comments instead of global rule disables. Declare hoisted vitepress transitive deps explicitly in website/package.json. Closes #5630 Co-Authored-By: Claude Opus 4.6 Signed-off-by: Hector Martinez --- .stylelintrc.json | 2 +- AGENTS.md | 2 +- docs/doc-site.md | 1 + vite.config.ts | 1 + website/.vitepress/config.ts | 74 +- website/.vitepress/search.d.ts | 9 + .../theme/components/VPLocalSearchBox.vue | 953 ++++++++++++++++++ website/.vitepress/theme/searchScopes.test.ts | 56 + website/.vitepress/theme/searchScopes.ts | 27 + website/package-lock.json | 4 + website/package.json | 6 +- 11 files changed, 1111 insertions(+), 24 deletions(-) create mode 100644 website/.vitepress/search.d.ts create mode 100644 website/.vitepress/theme/components/VPLocalSearchBox.vue create mode 100644 website/.vitepress/theme/searchScopes.test.ts create mode 100644 website/.vitepress/theme/searchScopes.ts diff --git a/.stylelintrc.json b/.stylelintrc.json index f8f6b7eb29..4b760c23ad 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -1,5 +1,5 @@ { - "extends": ["stylelint-config-standard", "stylelint-config-html/svelte"], + "extends": ["stylelint-config-standard", "stylelint-config-html/svelte", "stylelint-config-html/vue"], "rules": { "custom-property-pattern": null, "selector-class-pattern": null diff --git a/AGENTS.md b/AGENTS.md index 0f7294d0c2..301d576205 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ Fullsend is a platform for fully autonomous agentic development for Git-hosted o - You **must** read and follow [COMMITS.md](COMMITS.md) when writing or reviewing commit messages and PR titles. Getting the prefix right is not optional — GoReleaser uses PR titles to build release notes. Breaking changes **must** carry the `!` suffix in both commit messages and PR titles; a missing `!` is an important-severity review finding. - This repository requires a [Developer Certificate of Origin (DCO)](https://developercertificate.org/). Human-proposed commits **must** be signed off: use `git commit -s` (or add `Signed-off-by: Your Name ` as a trailer). Human-driven agent sessions (e.g., using Claude Code locally) should also sign off — the human directing the session is the one certifying the DCO. **Autonomous agent commits are exempt** and must never supply the DCO with `-s` or with `Signed-off-by`. These agents commit using the GitHub App's bot identity, which the [Probot DCO app](https://github.com/apps/dco) auto-skips. - Never commit secrets (tokens, API keys, PEM keys, gcloud credentials) or sensitive data (GCP project names, service account identifiers, Model Armor template names, internal hostnames). Use environment variables with no defaults for sensitive values. -- When adding a new doc under `docs/`, check `website/.vitepress/config.ts` sidebar config. Sections using `getMarkdownFiles()` are auto-discovered. All other sections need a manual `{ text, link }` entry. +- When adding a new doc under `docs/`, check `website/.vitepress/config.ts` sidebar config. Sections using `getMarkdownFiles()` are auto-discovered. All other sections need a manual `{ text, link }` entry. Also add the new folder's prefix to `search.options.scopes` in the same file so the folder's pages are reachable when search scope pills are active. ## Topic-specific guidance diff --git a/docs/doc-site.md b/docs/doc-site.md index b05838d3b5..bbc4150ee9 100644 --- a/docs/doc-site.md +++ b/docs/doc-site.md @@ -27,6 +27,7 @@ A `prebuild` hook runs `git submodule update --init` before the VitePress build, - `website/.vitepress/config.ts` defines the sidebar navigation and markdown processing - `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. ## Submodules diff --git a/vite.config.ts b/vite.config.ts index bebb651cb4..c49388de28 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -111,6 +111,7 @@ export default defineConfig(({ command }) => ({ environment: "jsdom", include: [ "admin/src/**/*.test.ts", + "../website/.vitepress/**/*.test.ts", ], passWithNoTests: true, }, diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index fca9a7d9cd..bb9e9b624d 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -342,32 +342,64 @@ export default defineConfig({ search: { provider: "local", + options: { + scopes: [ + { label: "Guides", prefixes: ["/docs/guides/", "/docs/agents/", "/docs/cli/"] }, + { + label: "Design Docs", + prefixes: [ + "/docs/problems/", + "/docs/ADRs/", + "/docs/superpowers/", + "/docs/plans/", + "/docs/normative/", + "/docs/spikes/", + ], + }, + { label: "Experiments", prefixes: ["/docs/experiments/"] }, + { label: "Contributing", prefixes: ["/docs/contributing/"] }, + { label: "Others", prefixes: [], others: true }, + ], + }, }, }, vite: { resolve: { - alias: { - "vue/server-renderer": path.resolve( - __dirname, - "..", - "node_modules", - "vue", - "server-renderer", - "index.mjs", - ), - vue: path.resolve(__dirname, "..", "node_modules", "vue"), - // Use mermaid's pre-bundled ESM build; the default entry (mermaid.core.mjs) - // externalizes dayjs (CJS-only), which breaks under noExternal: [/./]. - mermaid: path.resolve( - __dirname, - "..", - "node_modules", - "mermaid", - "dist", - "mermaid.esm.mjs", - ), - }, + alias: [ + { + find: /^.*\/VPLocalSearchBox\.vue$/, + replacement: fileURLToPath( + new URL("./theme/components/VPLocalSearchBox.vue", import.meta.url), + ), + }, + { + find: "vue/server-renderer", + replacement: path.resolve( + __dirname, + "..", + "node_modules", + "vue", + "server-renderer", + "index.mjs", + ), + }, + { + find: "vue", + replacement: path.resolve(__dirname, "..", "node_modules", "vue"), + }, + { + find: "mermaid", + replacement: path.resolve( + __dirname, + "..", + "node_modules", + "mermaid", + "dist", + "mermaid.esm.mjs", + ), + }, + ], // Prevent VitePress SSR from resolving CJS packages in the // repo-root node_modules (which causes ESM default-import // failures on Node 22 for packages like entities, estree-walker). diff --git a/website/.vitepress/search.d.ts b/website/.vitepress/search.d.ts new file mode 100644 index 0000000000..ccb9e53954 --- /dev/null +++ b/website/.vitepress/search.d.ts @@ -0,0 +1,9 @@ +import "vitepress"; + +declare module "vitepress" { + namespace DefaultTheme { + interface LocalSearchOptions { + scopes?: { label: string; prefixes: string[] }[]; + } + } +} diff --git a/website/.vitepress/theme/components/VPLocalSearchBox.vue b/website/.vitepress/theme/components/VPLocalSearchBox.vue new file mode 100644 index 0000000000..63048810a1 --- /dev/null +++ b/website/.vitepress/theme/components/VPLocalSearchBox.vue @@ -0,0 +1,953 @@ + + + + + + diff --git a/website/.vitepress/theme/searchScopes.test.ts b/website/.vitepress/theme/searchScopes.test.ts new file mode 100644 index 0000000000..233be672c7 --- /dev/null +++ b/website/.vitepress/theme/searchScopes.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { matchesActiveScopes } from "./searchScopes"; + +const scopes = [ + { label: "Guides", prefixes: ["/docs/guides/", "/docs/cli/"] }, + { label: "Design Docs", prefixes: ["/docs/problems/", "/docs/ADRs/"] }, + { label: "Experiments", prefixes: ["/docs/experiments/"] }, + { label: "Others", prefixes: [], others: true }, +]; + +describe("matchesActiveScopes", () => { + it("returns true for any id when no scopes are active", () => { + expect(matchesActiveScopes("/docs/guides/setup", scopes, new Set())).toBe(true); + expect(matchesActiveScopes("/random/page", scopes, new Set())).toBe(true); + }); + + it("filters to a single active scope", () => { + const active = new Set([0]); + expect(matchesActiveScopes("/docs/guides/setup", scopes, active)).toBe(true); + expect(matchesActiveScopes("/docs/cli/install", scopes, active)).toBe(true); + expect(matchesActiveScopes("/docs/problems/auth", scopes, active)).toBe(false); + }); + + it("combines prefixes from multiple active scopes (OR)", () => { + const active = new Set([0, 2]); + expect(matchesActiveScopes("/docs/guides/setup", scopes, active)).toBe(true); + expect(matchesActiveScopes("/docs/experiments/wip", scopes, active)).toBe(true); + expect(matchesActiveScopes("/docs/ADRs/001", scopes, active)).toBe(false); + }); + + it("returns false when id matches no active prefix", () => { + const active = new Set([1]); + expect(matchesActiveScopes("/docs/guides/setup", scopes, active)).toBe(false); + expect(matchesActiveScopes("/unrelated/page", scopes, active)).toBe(false); + }); + + it("handles an out-of-bounds scope index gracefully", () => { + const active = new Set([99]); + expect(matchesActiveScopes("/docs/guides/setup", scopes, active)).toBe(false); + }); + + it("others scope matches pages not covered by any prefix", () => { + const active = new Set([3]); + expect(matchesActiveScopes("/docs/vision", scopes, active)).toBe(true); + expect(matchesActiveScopes("/docs/architecture", scopes, active)).toBe(true); + expect(matchesActiveScopes("/docs/guides/setup", scopes, active)).toBe(false); + expect(matchesActiveScopes("/docs/ADRs/001", scopes, active)).toBe(false); + }); + + it("others combined with a regular scope matches both", () => { + const active = new Set([0, 3]); + expect(matchesActiveScopes("/docs/guides/setup", scopes, active)).toBe(true); + expect(matchesActiveScopes("/docs/vision", scopes, active)).toBe(true); + expect(matchesActiveScopes("/docs/ADRs/001", scopes, active)).toBe(false); + }); +}); diff --git a/website/.vitepress/theme/searchScopes.ts b/website/.vitepress/theme/searchScopes.ts new file mode 100644 index 0000000000..ced68ce3dc --- /dev/null +++ b/website/.vitepress/theme/searchScopes.ts @@ -0,0 +1,27 @@ +export interface SearchScope { + label: string; + prefixes: string[]; + others?: boolean; +} + +export function matchesActiveScopes( + id: string, + scopes: SearchScope[], + activeIndices: Set, +): boolean { + if (activeIndices.size === 0) return true; + + const hasOthers = [...activeIndices].some((i) => scopes[i]?.others); + const prefixes = [...activeIndices].flatMap((i) => + scopes[i]?.others ? [] : scopes[i]?.prefixes || [], + ); + + if (prefixes.some((p) => id.startsWith(p))) return true; + + if (hasOthers) { + const allPrefixes = scopes.flatMap((s) => (s.others ? [] : s.prefixes)); + return !allPrefixes.some((p) => id.startsWith(p)); + } + + return false; +} diff --git a/website/package-lock.json b/website/package-lock.json index fa10e32b2b..de3117f102 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -8,7 +8,11 @@ "name": "fullsend-docs", "version": "0.22.0", "dependencies": { + "@vueuse/core": "^12.4.0", + "@vueuse/integrations": "^12.4.0", + "mark.js": "8.11.1", "mermaid": "~11.16.0", + "minisearch": "^7.1.1", "vitepress": "^1.6.3", "vue": "^3.5.13" }, diff --git a/website/package.json b/website/package.json index 211d1a6008..15d635f1aa 100644 --- a/website/package.json +++ b/website/package.json @@ -11,8 +11,12 @@ "preview": "vitepress preview" }, "dependencies": { + "@vueuse/core": "^12.4.0", + "@vueuse/integrations": "^12.4.0", + "mark.js": "8.11.1", "mermaid": "~11.16.0", - "vitepress": "^1.6.3", + "minisearch": "^7.1.1", + "vitepress": "1.6.4", "vue": "^3.5.13" }, "engines": {