diff --git a/package-lock.json b/package-lock.json index 733410de64..47b7875954 100644 --- a/package-lock.json +++ b/package-lock.json @@ -597,14 +597,6 @@ "node": ">=14.17.0" } }, - "node_modules/@docsearch/css": { - "version": "4.6.0", - "license": "MIT" - }, - "node_modules/@docsearch/js": { - "version": "4.6.0", - "license": "MIT" - }, "node_modules/@emnapi/core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", @@ -10166,6 +10158,12 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "license": "MIT" + }, "node_modules/mitt": { "version": "3.0.1", "dev": true, @@ -17202,8 +17200,6 @@ "packages/stacks-docs": { "name": "@stackoverflow/stacks-docs", "dependencies": { - "@docsearch/css": "^4.3.2", - "@docsearch/js": "^4.3.2", "@hbsnow/rehype-sectionize": "^1.0.7", "@stackoverflow/stacks": "*", "@stackoverflow/stacks-editor": "*", @@ -17214,6 +17210,7 @@ "@stefanprobst/rehype-extract-toc": "^3.0.0", "better-auth": "^1.6.23", "highlight.js": "^11.11.1", + "minisearch": "^7.2.0", "rehype-autolink-headings": "^7.1.0", "rehype-slug": "^6.0.0", "turndown": "^7.2.2", diff --git a/packages/stacks-docs/package.json b/packages/stacks-docs/package.json index 49e0b906e5..944ed7b650 100644 --- a/packages/stacks-docs/package.json +++ b/packages/stacks-docs/package.json @@ -12,6 +12,7 @@ "check": "npm run build -w @stackoverflow/stacks-svelte && npm run build:package -w @stackoverflow/stacks-email && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "npm run check && eslint .", + "test": "vitest run src/lib", "format": "prettier --write .", "init-private-content": "if [ -d src/docs/private/.git ]; then git -C src/docs/private pull --ff-only; elif [ -s ~/.ssh/id_rsa ]; then GIT_SSH_COMMAND=\"ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no\" git clone --depth 1 git@github.com:StackEng/StacksDocsPrivate.git src/docs/private; else echo 'Skipping private docs; no SSH key found'; fi" }, @@ -24,8 +25,6 @@ "vite": "^7.3.3" }, "dependencies": { - "@docsearch/css": "^4.3.2", - "@docsearch/js": "^4.3.2", "@stackoverflow/stacks-email": "*", "@hbsnow/rehype-sectionize": "^1.0.7", "@stackoverflow/stacks": "*", @@ -36,6 +35,7 @@ "@stefanprobst/rehype-extract-toc": "^3.0.0", "better-auth": "^1.6.23", "highlight.js": "^11.11.1", + "minisearch": "^7.2.0", "rehype-autolink-headings": "^7.1.0", "rehype-slug": "^6.0.0", "turndown": "^7.2.2", diff --git a/packages/stacks-docs/src/app.css b/packages/stacks-docs/src/app.css index 091d05e7b0..144383921b 100644 --- a/packages/stacks-docs/src/app.css +++ b/packages/stacks-docs/src/app.css @@ -620,12 +620,3 @@ h1 { .hmn6 .s-editor-resizable { min-height: var(--su640) !important; } - -/* Agolia Docsearch */ - -.DocSearch-Container { - z-index: 9999 !important; -} -.DocSearch-Footer { - margin: 0; -} diff --git a/packages/stacks-docs/src/app.d.ts b/packages/stacks-docs/src/app.d.ts index 7443ebd580..de4dcf9c75 100644 --- a/packages/stacks-docs/src/app.d.ts +++ b/packages/stacks-docs/src/app.d.ts @@ -33,6 +33,14 @@ declare global { [key: string]: unknown; }; + type DocsSearchDocument = { + id: string; + title: string; + description: string; + path: string; + text: string; + }; + namespace App { interface Locals { session?: unknown; diff --git a/packages/stacks-docs/src/components/Search.svelte b/packages/stacks-docs/src/components/Search.svelte index 66e2707fe3..83428d2f28 100644 --- a/packages/stacks-docs/src/components/Search.svelte +++ b/packages/stacks-docs/src/components/Search.svelte @@ -1,52 +1,223 @@ - -{#if searchEnabled} - -{/if} + + + + + + {#snippet header()} + Search documentation + {/snippet} + + {#snippet body()} +
+ +
+ +

{resultStatus}

+ +
+ {#if isLoading} + + {#snippet description()} + Preparing the documentation index. + {/snippet} + + {:else if loadError} + + {#snippet description()} + Close and reopen search to try again. + {/snippet} + + {:else if results.length} + + {:else if hasQuery} + + {#snippet description()} + Try a different search term. + {/snippet} + + {:else} +

+ Search components, guidelines, and foundations. +

+ {/if} +
+ {/snippet} +
+ + diff --git a/packages/stacks-docs/src/lib/searchDocuments.ts b/packages/stacks-docs/src/lib/searchDocuments.ts new file mode 100644 index 0000000000..8720f3a83c --- /dev/null +++ b/packages/stacks-docs/src/lib/searchDocuments.ts @@ -0,0 +1,134 @@ +import type { Component } from "svelte"; +import { render } from "svelte/server"; +import { getSearchText } from "$lib/searchText"; + +// The generated index is public, so private docs must never match this glob. +const mdFiles = import.meta.glob("$docs/public/**/*.md"); + +type NavItem = { + slug: string; + title?: string; + description?: string; + externalUrl?: string; + private?: boolean; + items?: NavItem[]; + [key: string]: unknown; +}; + +type Structure = { + navigation?: NavItem[]; +}; + +function getNavTrail( + { navigation = [] }: Structure, + path: string[] +): NavItem[] { + const trail: NavItem[] = []; + let currentLevel: { items?: NavItem[] } | NavItem = { items: navigation }; + + for (const slug of path) { + const next = currentLevel.items?.find((item) => item.slug === slug); + if (!next) return trail; + + trail.push(next); + currentLevel = next; + } + + return trail; +} + +function getSearchPath(filePath: string): string { + return filePath + .replace("/src/docs/public/", "/") + .replace(/\/index\.md$/, "/") + .replace(/\.md$/, ""); +} + +function getSearchTitle( + metadata: DocsMetadata, + structure: Structure, + path: string +): string { + if (metadata?.title) return getSearchText(metadata.title); + + const trail = getNavTrail(structure, path.split("/").filter(Boolean)); + if (trail.length) { + return getSearchText( + trail.map((item) => item.title ?? item.slug).join(" > ") + ); + } + + return path; +} + +function getSearchDescription( + metadata: DocsMetadata, + structure: Structure, + path: string +): string { + if (metadata?.description) return getSearchText(metadata.description); + + const trail = getNavTrail(structure, path.split("/").filter(Boolean)); + return getSearchText(trail.at(-1)?.description ?? ""); +} + +const routeSearchDocuments: DocsSearchDocument[] = [ + // Non-markdown docs routes are not included in the glob above. + { + id: "route:/resources/icons", + title: "Resources > Icons & Spots", + description: + "Search and browse Stack Overflow icons and spot illustrations.", + path: "/resources/icons", + text: "icons icon spots spot illustrations illustration figma github svg svelte", + }, +]; + +async function getSearchDocuments( + structure: Structure +): Promise { + const markdownDocuments = await Promise.all( + Object.entries(mdFiles).map(async ([path, doc]) => { + try { + const page = (await doc()) as { + default: Component; + metadata: DocsMetadata; + }; + const searchPath = getSearchPath(path); + const text = getSearchText(render(page.default).body); + + return { + id: path, + title: getSearchTitle(page.metadata, structure, searchPath), + description: getSearchDescription( + page.metadata, + structure, + searchPath + ), + path: searchPath, + text, + }; + } catch (err) { + console.error( + `Failed to build search document for ${path}:`, + err + ); + return null; + } + }) + ); + + return [ + ...markdownDocuments.filter( + (document): document is DocsSearchDocument => document !== null + ), + ...routeSearchDocuments, + ]; +} + +export function getSearchDocumentsPromise(structure: Structure) { + return getSearchDocuments(structure).catch((err) => { + console.error("Failed to build search documents:", err); + return routeSearchDocuments; + }); +} diff --git a/packages/stacks-docs/src/lib/searchResults.test.ts b/packages/stacks-docs/src/lib/searchResults.test.ts new file mode 100644 index 0000000000..40c481a76e --- /dev/null +++ b/packages/stacks-docs/src/lib/searchResults.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { limitSearchResults } from "./searchResults"; + +describe("limitSearchResults", () => { + it("retains the total count when limiting displayed results", () => { + const resultSet = limitSearchResults(Array.from({ length: 12 }), 8); + + expect(resultSet.results).toHaveLength(8); + expect(resultSet.total).toBe(12); + }); +}); diff --git a/packages/stacks-docs/src/lib/searchResults.ts b/packages/stacks-docs/src/lib/searchResults.ts new file mode 100644 index 0000000000..02551c01f3 --- /dev/null +++ b/packages/stacks-docs/src/lib/searchResults.ts @@ -0,0 +1,6 @@ +export function limitSearchResults(results: T[], limit = 8) { + return { + results: results.slice(0, limit), + total: results.length, + }; +} diff --git a/packages/stacks-docs/src/lib/searchText.test.ts b/packages/stacks-docs/src/lib/searchText.test.ts new file mode 100644 index 0000000000..b6112d99bc --- /dev/null +++ b/packages/stacks-docs/src/lib/searchText.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { getSearchText } from "./searchText"; + +describe("getSearchText", () => { + it("removes rendered markup while preserving HTML examples", () => { + const text = getSearchText(` +

Use this button:

+
<button class="s-btn" aria-pressed="false">Save</button>
+ `); + + expect(text).not.toContain("

"); + expect(text).toContain('