From 9d3ae5302a248f01885d14f4fec3e0906b1092c8 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Sat, 6 Jun 2026 22:14:36 +0200 Subject: [PATCH] chore: improve cmdk (#2240) * chore: improve cmdk * fix: review * fix: review --- apps/web/src/app/api/search/route.ts | 224 +---------------- apps/web/src/content/cmdk.tsx | 270 +++++++++++++-------- apps/web/src/content/search-meta.ts | 19 ++ apps/web/src/content/utils/search-index.ts | 250 +++++++++++++++++++ 4 files changed, 444 insertions(+), 319 deletions(-) create mode 100644 apps/web/src/content/search-meta.ts create mode 100644 apps/web/src/content/utils/search-index.ts diff --git a/apps/web/src/app/api/search/route.ts b/apps/web/src/app/api/search/route.ts index 3806a40f..651923cb 100644 --- a/apps/web/src/app/api/search/route.ts +++ b/apps/web/src/app/api/search/route.ts @@ -1,11 +1,5 @@ -import { slugify } from "@/content/mdx"; -import { - type MDXData, - PAGE_TYPES, - getHomePage, - getPages, -} from "@/content/utils"; -import sanitizeHtml from "sanitize-html"; +import { PAGE_TYPES } from "@/content/utils"; +import { searchCorpus } from "@/content/utils/search-index"; import { z } from "zod"; const SearchSchema = z.object({ @@ -17,12 +11,10 @@ export type SearchParams = z.infer; export async function GET(request: Request) { const { searchParams } = new URL(request.url); - const query = searchParams.get("q"); - const page = searchParams.get("p"); const params = SearchSchema.safeParse({ - p: page, - q: query, + p: searchParams.get("p"), + q: searchParams.get("q"), }); if (!params.success) { @@ -33,212 +25,10 @@ export async function GET(request: Request) { } if (!params.data.p) { - return new Response(JSON.stringify([]), { - status: 200, - }); - } - - const results = search(params.data).sort((a, b) => { - return b.metadata.publishedAt.getTime() - a.metadata.publishedAt.getTime(); - }); - - return new Response(JSON.stringify(results), { - status: 200, - }); -} - -function search(params: SearchParams) { - const { p, q } = params; - let results: MDXData[] = []; - - if (p === "tools") { - results = getPages("tools"); - } else if (p === "product") { - const home = getHomePage(); - // NOTE: we override /home with / for the home.mdx file - home.href = "/"; - home.metadata.title = "Homepage"; - results = [home, ...getPages("product")]; - } else if (p === "all") { - const home = getHomePage(); - // NOTE: we override /home with / for the home.mdx file - home.href = "/"; - home.metadata.title = "Homepage"; - results = [ - ...getPages("blog"), - ...getPages("changelog"), - ...getPages("tools"), - ...getPages("tooling"), - ...getPages("compare"), - ...getPages("product"), - ...getPages("guides"), - ...getPages("use-case"), - ...getPages("unrelated"), - ...getPages("docs"), - home, - ]; - } else { - if (p) results = getPages(p); + return new Response(JSON.stringify([]), { status: 200 }); } - const searchMap = new Map< - string, - { - title: boolean; - content: boolean; - } - >(); - - results = results - .filter((result) => { - if (!q) return true; - - const hasSearchTitle = result.metadata.title - .toLowerCase() - .includes(q.toLowerCase()); - const hasSearchContent = result.content - .toLowerCase() - .includes(q.toLowerCase()); - - searchMap.set(result.slug, { - title: hasSearchTitle, - content: hasSearchContent, - }); - - return hasSearchTitle || hasSearchContent; - }) - .map((result) => { - const search = searchMap.get(result.slug); - - // Find the closest heading to the search match and add it as an anchor - let href = result.href; - - // Add query parameter for highlighting - if (q) { - href = `${href}?q=${encodeURIComponent(q)}`; - } - - if (q && search?.content) { - const headingSlug = findClosestHeading(result.content, q); - if (headingSlug) { - href = `${href}#${headingSlug}`; - } - } - - const content = - search?.content || !search?.title - ? getContentSnippet(result.content, q) - : ""; - - return { - ...result, - content, - href, - }; - }); - - return results; -} - -const WORKDS_BEFORE = 2; -const WORKDS_AFTER = 20; - -function getContentSnippet( - mdxContent: string, - searchQuery: string | null | undefined, -): string { - if (!searchQuery) { - return `${mdxContent.slice(0, 100)}...`; - } - - const content = sanitizeContent(mdxContent.toLowerCase()); - const searchLower = searchQuery.toLowerCase(); - const matchIndex = content.indexOf(searchLower); - - if (matchIndex === -1) { - // No match found, return first 100 chars - return `${content.slice(0, 100)}...`; - } - - // Find start of snippet (go back N words) - let start = matchIndex; - for (let i = 0; i < WORKDS_BEFORE && start > 0; i++) { - const prevSpace = content.lastIndexOf(" ", start - 2); - if (prevSpace === -1) break; - start = prevSpace + 1; - } - - // Find end of snippet (go forward N words) - let end = matchIndex + searchQuery.length; - for (let i = 0; i < WORKDS_AFTER && end < content.length; i++) { - const nextSpace = content.indexOf(" ", end + 1); - if (nextSpace === -1) { - end = content.length; - break; - } - end = nextSpace; - } - - // Extract snippet - let snippet = content.slice(start, end).trim(); - - if (!snippet) return snippet; - - if (start > 0) snippet = `...${snippet}`; - if (end < content.length) snippet = `${snippet}...`; - - return snippet; -} - -export function sanitizeContent(input: string) { - return sanitizeHtml(input) - .replace(/<[^>]+>/g, "") // strip JSX tags - .replace(/^#{1,6}\s+/gm, "") // strip markdown heading symbols, keep text - .replace(/!\[.*?\]\(.*?\)/g, "") // strip images - .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // keep link text - .replace(/\*\*(.*?)\*\*/g, "$1") // strip bold - .replace(/__(.*?)__/g, "$1") // strip italic - .replace(/_(.*?)_/g, "$1") // strip underline - .replace(/[`*>~]/g, "") // strip most formatting - .replace(/\s+/g, " ") // collapse whitespace - .replace(/[<>]/g, (c) => (c === "<" ? "<" : ">")) // escape any remaining angle brackets - .trim(); -} - -/** - * Find the closest heading before the search match and return its slug - */ -function findClosestHeading( - mdxContent: string, - searchQuery: string | null | undefined, -): string | null { - if (!searchQuery) return null; - - const searchLower = searchQuery.toLowerCase(); - const contentLower = mdxContent.toLowerCase(); - const matchIndex = contentLower.indexOf(searchLower); - - if (matchIndex === -1) return null; - - // Look for headings before the match (## Heading, ### Heading, etc.) - const contentBeforeMatch = mdxContent.slice(0, matchIndex); - const headingRegex = /^#{1,6}\s+(.+)$/gm; - const headings: { text: string; index: number }[] = []; - - let match = headingRegex.exec(contentBeforeMatch); - while (match !== null) { - headings.push({ - text: match[1].trim(), - index: match.index, - }); - match = headingRegex.exec(contentBeforeMatch); - } - - // Return the closest heading (last one before the match) - if (headings.length > 0) { - const closestHeading = headings[headings.length - 1]; - return slugify(closestHeading.text); - } + const results = searchCorpus({ p: params.data.p, q: params.data.q }); - return null; + return new Response(JSON.stringify(results), { status: 200 }); } diff --git a/apps/web/src/content/cmdk.tsx b/apps/web/src/content/cmdk.tsx index 5c980879..899552e1 100644 --- a/apps/web/src/content/cmdk.tsx +++ b/apps/web/src/content/cmdk.tsx @@ -1,6 +1,10 @@ "use client"; -import type { MDXData } from "@/content/utils"; +import { + CORPUS_LABELS, + type Corpus, + type SearchResult, +} from "@/content/search-meta"; import { Command, CommandEmpty, @@ -20,7 +24,7 @@ import { useDebounce } from "@openstatus/ui/hooks/use-debounce"; import { cn } from "@openstatus/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { Command as CommandPrimitive } from "cmdk"; -import { Loader2, Search } from "lucide-react"; +import { Loader2, Search, X } from "lucide-react"; import { useTheme } from "next-themes"; import { useRouter } from "next/navigation"; import * as React from "react"; @@ -201,28 +205,35 @@ export function CmdK({ const [pages, setPages] = React.useState( defaultPage ? [defaultPage] : [], ); - const debouncedSearch = useDebounce(search, 300); + const debouncedSearch = useDebounce(search, 150); + const query = debouncedSearch.trim(); const router = useRouter(); + // Explicitly pinned corpus (e.g. "Search in Docs…" or the docs-page default). const page = pages.length > 0 ? pages[pages.length - 1] : null; + // Typing with nothing pinned searches everything — no need to pick a corpus first. + const scope: Corpus | "all" | null = + (page as Corpus | null) ?? (search.trim() ? "all" : null); + // Commands (Go to…, links, theme) stay matchable while typing, unless a corpus + // is pinned — then the palette is focused on searching within that corpus. + const showCommands = !page; const { data: items = [], isLoading: loading, isFetching: fetching, - } = useQuery({ - queryKey: ["search", page, debouncedSearch], + } = useQuery({ + queryKey: ["search", scope, query], queryFn: async () => { - if (!page) return []; + if (!scope) return []; const searchParams = new URLSearchParams(); - searchParams.set("p", page); - if (debouncedSearch) searchParams.set("q", debouncedSearch); - const promise = fetch(`/api/search?${searchParams.toString()}`); - // NOTE: artificial delay to avoid flickering - const delay = new Promise((r) => setTimeout(r, 300)); - const [res, _] = await Promise.all([promise, delay]); + searchParams.set("p", scope); + if (query) searchParams.set("q", query); + const res = await fetch(`/api/search?${searchParams.toString()}`); return res.json(); }, + // A pinned corpus can browse with an empty query; unpinned "all" waits for input. + enabled: !!page || !!query, placeholderData: (previousData) => previousData, }); @@ -310,7 +321,7 @@ export function CmdK({ - + Search { @@ -320,45 +331,69 @@ export function CmdK({ setPages((pages) => pages.slice(0, -1)); } }} - shouldFilter={!page} + shouldFilter={false} className="rounded-none" >
{loading || fetching ? ( - + ) : ( - + )} + {page ? ( + + {CORPUS_LABELS[page as Corpus] ?? page} + + + ) : scope === "all" ? ( + + all + + ) : null}
- - {(loading || fetching) && page && !items.length ? ( + + {(loading || fetching) && scope && !items.length ? ( Searching... ) : null} {!(loading || fetching) ? ( No results found. ) : null} - {!page ? ( + {showCommands ? ( ) : null} - {items.length > 0 ? ( + {scope && items.length > 0 ? ( ) : null} @@ -370,10 +405,12 @@ export function CmdK({ } function Home({ + search, setPages, resetSearch, setOpen, }: { + search: string; setPages: React.Dispatch>; resetSearch: () => void; setOpen: React.Dispatch>; @@ -381,33 +418,59 @@ function Home({ const router = useRouter(); const { resolvedTheme, setTheme } = useTheme(); + const matches = (label: string) => + !search || label.toLowerCase().includes(search.toLowerCase()); + + const themeLabel = `Switch to ${resolvedTheme === "dark" ? "light" : "dark"} theme`; + + // Build the visible sections up front so separators only sit between + // sections that actually render (no leading/trailing/doubled dividers). + const sections = CONFIG.map((group) => ({ + heading: group.heading, + items: group.items.filter((item) => matches(item.label)), + })).filter((section) => section.items.length > 0); + + if (matches(themeLabel)) { + sections.push({ heading: "Settings", items: [] }); + } + return ( <> - {CONFIG.map((group, groupIndex) => ( - - {groupIndex > 0 && } - - {group.items.map((item) => { - if (item.type === "item") { - return ( - { - router.push(item.href); - setOpen(false); - }} - > - {item.label} - {item.shortcut && ( - {item.shortcut} - )} - - ); - } - if (item.type === "group") { + {sections.map((section, index) => ( + + {index > 0 && } + + {section.heading === "Settings" ? ( + + setTheme(resolvedTheme === "dark" ? "light" : "dark") + } + > + {themeLabel} + + ) : ( + section.items.map((item) => { + if (item.type === "item") { + return ( + { + router.push(item.href); + setOpen(false); + }} + > + {item.label} + {item.shortcut && ( + {item.shortcut} + )} + + ); + } return ( { setPages((pages) => [...pages, item.page]); resetSearch(); @@ -416,22 +479,11 @@ function Home({ {item.label} ); - } - return null; - })} + }) + )} ))} - - - setTheme(resolvedTheme === "dark" ? "light" : "dark")} - > - - Switch to {resolvedTheme === "dark" ? "light" : "dark"} theme - - - ); } @@ -440,59 +492,73 @@ function SearchResults({ items, search, setOpen, - page, }: { - items: MDXData[]; + items: SearchResult[]; search: string; setOpen: React.Dispatch>; - page: string | null; }) { const router = useRouter(); - const _page = CONFIG[0].items.find( - (item) => item.type === "group" && item.page === page, - ) as ConfigGroup | undefined; + // Bucket by corpus, preserving the server's rank order for both groups and rows. + const groups: { type: Corpus; items: SearchResult[] }[] = []; + const byType = new Map(); + for (const item of items) { + let bucket = byType.get(item.type); + if (!bucket) { + bucket = []; + byType.set(item.type, bucket); + groups.push({ type: item.type, items: bucket }); + } + bucket.push(item); + } - return ( - - {items.map((item) => { - // Highlight search term match in the title, case-insensitive - const title = item.metadata.title.replace( - new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"), - (match) => `${match}`, - ); - const html = item.content.replace( + const highlight = (text: string) => + search + ? text.replace( new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"), (match) => `${match}`, - ); + ) + : text; - return ( - { - router.push(item.href); - setOpen(false); - }} - > -
- - dangerouslySetInnerHTML={{ __html: title }} - /> - {item.content && search ? ( - - dangerouslySetInnerHTML={{ __html: html }} - /> - ) : null} -
-
- ); - })} -
+ return ( + <> + {groups.map((group, index) => ( + + {index > 0 && } + + {group.items.map((item) => ( + { + router.push(item.href); + setOpen(false); + }} + > +
+ + {item.content && search ? ( + + ) : null} +
+
+ ))} +
+
+ ))} + ); } diff --git a/apps/web/src/content/search-meta.ts b/apps/web/src/content/search-meta.ts new file mode 100644 index 00000000..acb0dd00 --- /dev/null +++ b/apps/web/src/content/search-meta.ts @@ -0,0 +1,19 @@ +import type { MDXData, PageType } from "./utils"; + +export type Corpus = Exclude; + +export const CORPUS_LABELS: Record = { + blog: "blog", + changelog: "changelog", + product: "product", + unrelated: "pages", + compare: "compare", + tools: "tools", + tooling: "tooling", + customers: "customers", + guides: "guides", + "use-case": "use-case", + docs: "docs", +}; + +export type SearchResult = MDXData & { type: Corpus }; diff --git a/apps/web/src/content/utils/search-index.ts b/apps/web/src/content/utils/search-index.ts new file mode 100644 index 00000000..b0fe488f --- /dev/null +++ b/apps/web/src/content/utils/search-index.ts @@ -0,0 +1,250 @@ +import "server-only"; +import { slugify } from "@/content/mdx"; +import { type MDXData, PAGE_TYPES, getHomePage, getPages } from "."; +import type { Corpus, SearchResult } from "../search-meta"; + +type Heading = { slug: string; index: number }; + +type IndexedDoc = { + doc: MDXData; + type: Corpus; + titleLower: string; + rawLower: string; + sanitized: string; + sanitizedLower: string; + headings: Heading[]; + headingsLower: string; +}; + +const CORPORA: Corpus[] = PAGE_TYPES.filter((t): t is Corpus => t !== "all"); + +// Content is static after build; parse + index each corpus once. Skip the cache +// in dev so editing an .mdx file is reflected without a restart. +const shouldCache = process.env.NODE_ENV === "production"; +const cache = new Map(); + +function homeDoc(): MDXData { + const home = getHomePage(); + home.href = "/"; + home.metadata.title = "Homepage"; + return home; +} + +function indexDoc(doc: MDXData, type: Corpus): IndexedDoc { + const raw = doc.content; + const sanitized = sanitizeContent(raw); + const headings: Heading[] = []; + const headingTexts: string[] = []; + const headingRegex = /^#{1,6}\s+(.+)$/gm; + let match = headingRegex.exec(raw); + while (match !== null) { + const text = match[1].trim(); + headings.push({ slug: slugify(text), index: match.index }); + headingTexts.push(text); + match = headingRegex.exec(raw); + } + return { + doc, + type, + titleLower: doc.metadata.title.toLowerCase(), + rawLower: raw.toLowerCase(), + sanitized, + sanitizedLower: sanitized.toLowerCase(), + headings, + headingsLower: headingTexts.join(" \n ").toLowerCase(), + }; +} + +function buildCorpus(type: Corpus): IndexedDoc[] { + const pages = + type === "product" ? [homeDoc(), ...getPages("product")] : getPages(type); + return pages.map((doc) => indexDoc(doc, type)); +} + +function getCorpus(type: Corpus): IndexedDoc[] { + if (shouldCache && cache.has(type)) { + return cache.get(type) as IndexedDoc[]; + } + const indexed = buildCorpus(type); + if (shouldCache) cache.set(type, indexed); + return indexed; +} + +function getAll(): IndexedDoc[] { + const home = indexDoc(homeDoc(), "product"); + const corpora = CORPORA.filter((t) => t !== "product").flatMap(getCorpus); + return [ + home, + ...getCorpus("product").filter((d) => d.doc.href !== "/"), + ...corpora, + ]; +} + +const WEIGHTS = { + phraseTitle: 100, + phraseHeading: 60, + phraseContent: 30, + termTitle: 10, + termHeading: 6, + termContent: 2, + allTermsBonus: 20, +} as const; + +function scoreDoc( + entry: IndexedDoc, + phrase: string, + terms: string[], +): { score: number; needle: string | null } { + let score = 0; + let needle: string | null = null; + + if (entry.titleLower.includes(phrase)) score += WEIGHTS.phraseTitle; + if (entry.headingsLower.includes(phrase)) score += WEIGHTS.phraseHeading; + if (entry.sanitizedLower.includes(phrase)) { + score += WEIGHTS.phraseContent; + needle = phrase; + } + + let matched = 0; + for (const term of terms) { + const inTitle = entry.titleLower.includes(term); + const inHeading = entry.headingsLower.includes(term); + const inContent = entry.sanitizedLower.includes(term); + if (inTitle) score += WEIGHTS.termTitle; + if (inHeading) score += WEIGHTS.termHeading; + if (inContent) { + score += WEIGHTS.termContent; + if (!needle) needle = term; + } + if (inTitle || inHeading || inContent) matched++; + } + + if (terms.length > 1 && matched === terms.length) + score += WEIGHTS.allTermsBonus; + + return { score, needle }; +} + +function closestHeadingSlug(entry: IndexedDoc, needle: string): string | null { + const matchIndex = entry.rawLower.indexOf(needle); + if (matchIndex === -1 || entry.headings.length === 0) return null; + let best: string | null = null; + for (const h of entry.headings) { + if (h.index <= matchIndex) best = h.slug; + else break; + } + return best; +} + +export function searchCorpus(params: { + p: Corpus | "all"; + q: string | null | undefined; +}): SearchResult[] { + const { p, q } = params; + const entries = p === "all" ? getAll() : getCorpus(p); + + const query = q?.trim() ?? ""; + const phrase = query.toLowerCase(); + const terms = phrase.split(/\s+/).filter(Boolean); + + if (!query) { + return entries + .map((e) => toResult(e, getContentSnippet(e.sanitized, null), e.doc.href)) + .sort(byRecency); + } + + const scored: { entry: IndexedDoc; score: number; needle: string | null }[] = + []; + for (const entry of entries) { + const { score, needle } = scoreDoc(entry, phrase, terms); + if (score > 0) scored.push({ entry, score, needle }); + } + + scored.sort( + (a, b) => b.score - a.score || recency(b.entry) - recency(a.entry), + ); + + return scored.map(({ entry, needle }) => { + let href = `${entry.doc.href}?q=${encodeURIComponent(query)}`; + if (needle) { + const slug = closestHeadingSlug(entry, needle); + if (slug) href = `${href}#${slug}`; + } + const snippet = getContentSnippet(entry.sanitized, needle); + return toResult(entry, snippet, href); + }); +} + +function toResult( + entry: IndexedDoc, + content: string, + href: string, +): SearchResult { + return { ...entry.doc, type: entry.type, content, href }; +} + +function recency(entry: IndexedDoc) { + return entry.doc.metadata.publishedAt.getTime(); +} + +function byRecency(a: SearchResult, b: SearchResult) { + return b.metadata.publishedAt.getTime() - a.metadata.publishedAt.getTime(); +} + +const WORDS_BEFORE = 2; +const WORDS_AFTER = 20; + +function getContentSnippet(sanitized: string, needle: string | null): string { + if (!needle) return `${sanitized.slice(0, 100)}...`; + + const matchIndex = sanitized.toLowerCase().indexOf(needle.toLowerCase()); + if (matchIndex === -1) return `${sanitized.slice(0, 100)}...`; + + let start = matchIndex; + for (let i = 0; i < WORDS_BEFORE && start > 0; i++) { + const prevSpace = sanitized.lastIndexOf(" ", start - 2); + if (prevSpace === -1) break; + start = prevSpace + 1; + } + + let end = matchIndex + needle.length; + for (let i = 0; i < WORDS_AFTER && end < sanitized.length; i++) { + const nextSpace = sanitized.indexOf(" ", end + 1); + if (nextSpace === -1) { + end = sanitized.length; + break; + } + end = nextSpace; + } + + let snippet = sanitized.slice(start, end).trim(); + if (!snippet) return snippet; + if (start > 0) snippet = `...${snippet}`; + if (end < sanitized.length) snippet = `${snippet}...`; + return snippet; +} + +export function sanitizeContent(input: string) { + return stripTags(input) + .replace(/^#{1,6}\s+/gm, "") // strip markdown heading symbols, keep text + .replace(/!\[.*?\]\(.*?\)/g, "") // strip images + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // keep link text + .replace(/\*\*(.*?)\*\*/g, "$1") // strip bold + .replace(/__(.*?)__/g, "$1") // strip italic + .replace(/_(.*?)_/g, "$1") // strip underline + .replace(/[`*>~]/g, "") // strip most formatting + .replace(/\s+/g, " ") // collapse whitespace + .replace(/[<>]/g, (c) => (c === "<" ? "<" : ">")) // escape stray brackets for safe innerHTML + .trim(); +} + +// Loop until stable: stripping a tag can splice fragments into a new tag (`ipt>` → `