diff --git a/package-lock.json b/package-lock.json index 88e93519da..76b6125223 100644 --- a/package-lock.json +++ b/package-lock.json @@ -598,14 +598,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", @@ -9639,6 +9631,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "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, @@ -15927,8 +15925,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": "*", @@ -15938,6 +15934,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 0a9f1a175f..689d95b853 100644 --- a/packages/stacks-docs/package.json +++ b/packages/stacks-docs/package.json @@ -23,8 +23,6 @@ "vite": "^7.3.3" }, "dependencies": { - "@docsearch/css": "^4.3.2", - "@docsearch/js": "^4.3.2", "@hbsnow/rehype-sectionize": "^1.0.7", "@stackoverflow/stacks": "*", "@stackoverflow/stacks-editor": "*", @@ -34,6 +32,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 7d3f7fc100..73276ca4e0 100644 --- a/packages/stacks-docs/src/app.css +++ b/packages/stacks-docs/src/app.css @@ -608,12 +608,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 8b9d7c7948..78df67cef0 100644 --- a/packages/stacks-docs/src/app.d.ts +++ b/packages/stacks-docs/src/app.d.ts @@ -32,6 +32,14 @@ declare global { [key: string]: unknown; }; + type DocsSearchDocument = { + id: string; + title: string; + description: string; + path: string; + text: string; + }; + namespace App { interface Locals { session?: unknown; @@ -47,6 +55,7 @@ declare global { markdown?: string | null; metadata?: DocsMetadata | null; needsAuth?: boolean; + searchDocuments?: DocsSearchDocument[]; source?: string | null; structure?: { navigation?: DocsNavItem[] }; [key: string]: unknown; diff --git a/packages/stacks-docs/src/components/Search.svelte b/packages/stacks-docs/src/components/Search.svelte index 66e2707fe3..9372df88b2 100644 --- a/packages/stacks-docs/src/components/Search.svelte +++ b/packages/stacks-docs/src/components/Search.svelte @@ -1,52 +1,174 @@ - -{#if searchEnabled} - -{/if} + + + + + + {#snippet header()} + Search documentation + {/snippet} + + {#snippet body()} +
+ +
+ +

{resultStatus}

+ +
+ {#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..8427b08ec0 --- /dev/null +++ b/packages/stacks-docs/src/lib/searchDocuments.ts @@ -0,0 +1,148 @@ +import type { Component } from "svelte"; +import { render } from "svelte/server"; +import TurndownService from "turndown"; + +// Only public docs are indexed because layout data is sent to every visitor. +const mdFiles = import.meta.glob("$docs/public/**/*.md"); + +const turndownService = new TurndownService({ + headingStyle: "atx", + codeBlockStyle: "fenced", +}); + +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 normalizeText(metadata.title); + + const trail = getNavTrail(structure, path.split("/").filter(Boolean)); + if (trail.length) { + return trail.map((item) => item.title ?? item.slug).join(" > "); + } + + return path; +} + +function getSearchDescription( + metadata: DocsMetadata, + structure: Structure, + path: string +): string { + if (metadata?.description) return normalizeText(metadata.description); + + const trail = getNavTrail(structure, path.split("/").filter(Boolean)); + return normalizeText(trail.at(-1)?.description ?? ""); +} + +function getPlainText(html: string): string { + return normalizeText(turndownService.turndown(html)); +} + +function normalizeText(text: string): string { + return text + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +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 = getPlainText(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/routes/+layout.server.ts b/packages/stacks-docs/src/routes/+layout.server.ts index df4ac8a57c..48019ae22f 100644 --- a/packages/stacks-docs/src/routes/+layout.server.ts +++ b/packages/stacks-docs/src/routes/+layout.server.ts @@ -1,10 +1,13 @@ import type { LayoutServerLoad } from "./$types"; import YAML from "yaml"; import structureRaw from "$src/structure.yaml?raw"; +import { getSearchDocumentsPromise } from "$lib/searchDocuments"; type NavItem = { slug: string; title?: string; + description?: string; + externalUrl?: string; private?: boolean; items?: NavItem[]; [key: string]: unknown; @@ -14,6 +17,18 @@ type Structure = { navigation?: NavItem[]; }; +function parseStructure(): Structure { + try { + return YAML.parse(structureRaw); + } catch (err) { + console.error("Failed to parse structure.yaml:", err); + return {}; + } +} + +const structure = parseStructure(); +const searchDocumentsPromise = getSearchDocumentsPromise(structure); + function findByPath( { navigation = [] }: Structure, path: string[] @@ -30,15 +45,6 @@ function findByPath( } export const load: LayoutServerLoad = async (event) => { - // Load the navigation structure from the structure.yaml - let structure: Structure = {}; - - try { - structure = YAML.parse(structureRaw); - } catch (err) { - console.error("Failed to parse structure.yaml:", err); - } - // Grab the current section from the structure const path = [ event.params.category, @@ -96,5 +102,6 @@ export const load: LayoutServerLoad = async (event) => { active, breadcrumb, needsAuth, + searchDocuments: await searchDocumentsPromise, }; }; diff --git a/packages/stacks-docs/src/routes/+layout.svelte b/packages/stacks-docs/src/routes/+layout.svelte index a1a40c2c8a..63461ede48 100644 --- a/packages/stacks-docs/src/routes/+layout.svelte +++ b/packages/stacks-docs/src/routes/+layout.svelte @@ -39,7 +39,7 @@ - +