doccupine 0.0.80 → 0.0.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -134,7 +134,7 @@ ${hasSections
134
134
  <StyledComponentsRegistry>
135
135
  ${analyticsEnabled ? " <PostHogProvider>\n" : ""}${a} <CherryThemeProvider theme={theme} themeDark={themeDark}>
136
136
  ${a} ${chtOpen}
137
- ${a} <SearchProvider pages={pages}>
137
+ ${a} <SearchProvider pages={pages} sections={doccupineSections}>
138
138
  ${a} <Header>
139
139
  ${a} <SectionBar sections={doccupineSections} />
140
140
  ${a} </Header>
@@ -7,6 +7,7 @@ import { prettierignoreTemplate } from "../templates/prettierignore.js";
7
7
  import { tsconfigTemplate } from "../templates/tsconfig.js";
8
8
  import { mcpRoutesTemplate } from "../templates/app/api/mcp/route.js";
9
9
  import { ragRoutesTemplate } from "../templates/app/api/rag/route.js";
10
+ import { searchRoutesTemplate } from "../templates/app/api/search/route.js";
10
11
  import { routesTemplate } from "../templates/app/api/theme/routes.js";
11
12
  import { notFoundTemplate } from "../templates/app/not-found.js";
12
13
  import { themeTemplate } from "../templates/app/theme.js";
@@ -20,6 +21,7 @@ import { sectionNavProviderTemplate } from "../templates/components/SectionNavPr
20
21
  import { postHogProviderTemplate } from "../templates/components/PostHogProvider.js";
21
22
  import { searchDocsTemplate } from "../templates/components/SearchDocs.js";
22
23
  import { sideBarTemplate } from "../templates/components/SideBar.js";
24
+ import { spinnerTemplate } from "../templates/components/Spinner.js";
23
25
  import { sectionBarTemplate } from "../templates/components/layout/SectionBar.js";
24
26
  import { accordionTemplate } from "../templates/components/layout/Accordion.js";
25
27
  import { actionBarTemplate } from "../templates/components/layout/ActionBar.js";
@@ -47,6 +49,7 @@ import { tabsTemplate } from "../templates/components/layout/Tabs.js";
47
49
  import { themeToggleTemplate } from "../templates/components/layout/ThemeToggle.js";
48
50
  import { typographyTemplate } from "../templates/components/layout/Typography.js";
49
51
  import { updateTemplate } from "../templates/components/layout/Update.js";
52
+ import { searchServiceTemplate } from "../templates/services/search.js";
50
53
  import { mcpIndexTemplate } from "../templates/services/mcp/index.js";
51
54
  import { mcpServerTemplate } from "../templates/services/mcp/server.js";
52
55
  import { mcpToolsTemplate } from "../templates/services/mcp/tools.js";
@@ -118,7 +121,9 @@ export const appStructure = {
118
121
  "app/theme.ts": themeTemplate,
119
122
  "app/api/mcp/route.ts": mcpRoutesTemplate,
120
123
  "app/api/rag/route.ts": ragRoutesTemplate,
124
+ "app/api/search/route.ts": searchRoutesTemplate,
121
125
  "app/api/theme/route.ts": routesTemplate,
126
+ "services/search.ts": searchServiceTemplate,
122
127
  "services/mcp/index.ts": mcpIndexTemplate,
123
128
  "services/mcp/server.ts": mcpServerTemplate,
124
129
  "services/mcp/tools.ts": mcpToolsTemplate,
@@ -143,6 +148,7 @@ export const appStructure = {
143
148
  "components/PostHogProvider.tsx": postHogProviderTemplate,
144
149
  "components/SearchDocs.tsx": searchDocsTemplate,
145
150
  "components/SideBar.tsx": sideBarTemplate,
151
+ "components/Spinner.tsx": spinnerTemplate,
146
152
  "components/layout/Accordion.tsx": accordionTemplate,
147
153
  "components/layout/ActionBar.tsx": actionBarTemplate,
148
154
  "components/layout/Button.tsx": buttonTemplate,
@@ -0,0 +1 @@
1
+ export declare const searchRoutesTemplate = "import { NextResponse } from \"next/server\";\nimport { z } from \"zod\";\nimport { searchContent } from \"@/services/search\";\n\nconst searchSchema = z.object({\n q: z.string().min(1).max(200),\n limit: z.coerce.number().int().min(1).max(30).optional(),\n});\n\nexport async function GET(req: Request) {\n const url = new URL(req.url);\n const limitParam = url.searchParams.get(\"limit\");\n const parsed = searchSchema.safeParse({\n q: url.searchParams.get(\"q\"),\n ...(limitParam != null && { limit: limitParam }),\n });\n\n if (!parsed.success) {\n return NextResponse.json(\n { error: \"Invalid query\", details: parsed.error.issues },\n { status: 400 },\n );\n }\n\n const results = await searchContent(parsed.data.q, parsed.data.limit ?? 10);\n return NextResponse.json({ results });\n}\n";
@@ -0,0 +1,28 @@
1
+ export const searchRoutesTemplate = `import { NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import { searchContent } from "@/services/search";
4
+
5
+ const searchSchema = z.object({
6
+ q: z.string().min(1).max(200),
7
+ limit: z.coerce.number().int().min(1).max(30).optional(),
8
+ });
9
+
10
+ export async function GET(req: Request) {
11
+ const url = new URL(req.url);
12
+ const limitParam = url.searchParams.get("limit");
13
+ const parsed = searchSchema.safeParse({
14
+ q: url.searchParams.get("q"),
15
+ ...(limitParam != null && { limit: limitParam }),
16
+ });
17
+
18
+ if (!parsed.success) {
19
+ return NextResponse.json(
20
+ { error: "Invalid query", details: parsed.error.issues },
21
+ { status: 400 },
22
+ );
23
+ }
24
+
25
+ const results = await searchContent(parsed.data.q, parsed.data.limit ?? 10);
26
+ return NextResponse.json({ results });
27
+ }
28
+ `;
@@ -1 +1 @@
1
- export declare const searchDocsTemplate = "\"use client\";\nimport React, {\n createContext,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { useRouter } from \"next/navigation\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { rgba } from \"polished\";\nimport { Search } from \"lucide-react\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { interactiveStyles } from \"@/components/layout/SharedStyled\";\n\ninterface PageItem {\n slug: string;\n title: string;\n description?: string;\n category: string;\n section?: string;\n}\n\ninterface SearchContextValue {\n openSearch: () => void;\n}\n\nconst SearchContext = createContext<SearchContextValue>({\n openSearch: () => {},\n});\n\nconst ANIMATION_MS = 150;\n\nconst backdropIn = keyframes`\n from { opacity: 0; }\n to { opacity: 1; }\n`;\n\nconst backdropOut = keyframes`\n from { opacity: 1; }\n to { opacity: 0; }\n`;\n\nconst modalIn = keyframes`\n from { opacity: 0; transform: scale(0.96) translateY(-8px); }\n to { opacity: 1; transform: scale(1) translateY(0); }\n`;\n\nconst modalOut = keyframes`\n from { opacity: 1; transform: scale(1) translateY(0); }\n to { opacity: 0; transform: scale(0.96) translateY(-8px); }\n`;\n\nconst StyledBackdrop = styled.div<{ theme: Theme; $isClosing: boolean }>`\n position: fixed;\n inset: 0;\n z-index: 9999;\n background: ${({ theme }) =>\n rgba(theme.isDark ? theme.colors.light : theme.colors.dark, 0.5)};\n backdrop-filter: blur(4px);\n -webkit-backdrop-filter: blur(4px);\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding: 20px;\n animation: ${({ $isClosing }) => ($isClosing ? backdropOut : backdropIn)}\n ${ANIMATION_MS}ms ease forwards;\n\n ${mq(\"lg\")} {\n padding: 120px 20px 20px 20px;\n }\n`;\n\nconst StyledModal = styled.div<{ theme: Theme; $isClosing: boolean }>`\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n box-shadow: ${({ theme }) => theme.shadows.xl};\n width: 100%;\n max-width: 560px;\n max-height: calc(100dvh - 40px);\n display: flex;\n flex-direction: column;\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n animation: ${({ $isClosing }) => ($isClosing ? modalOut : modalIn)}\n ${ANIMATION_MS}ms ease forwards;\n\n ${mq(\"lg\")} {\n max-height: calc(100dvh - 240px);\n }\n`;\n\nconst StyledInputWrapper = styled.div<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 16px;\n flex-shrink: 0;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n & svg.lucide {\n color: ${({ theme }) => theme.colors.gray};\n flex-shrink: 0;\n }\n`;\n\nconst StyledInput = styled.input<{ theme: Theme }>`\n flex: 1;\n border: none;\n outline: none;\n background: transparent;\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n line-height: ${({ theme }) => theme.lineHeights.text.lg};\n color: ${({ theme }) => theme.colors.dark};\n font-family: inherit;\n\n &::placeholder {\n color: ${({ theme }) => theme.colors.gray};\n }\n`;\n\nconst StyledResults = styled.ul<{ theme: Theme }>`\n list-style: none;\n margin: 0;\n padding: 8px;\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n -webkit-overflow-scrolling: touch;\n\n &::-webkit-scrollbar {\n display: none;\n }\n`;\n\nconst StyledResultItem = styled.li<{ theme: Theme; $isActive: boolean }>`\n padding: 10px 12px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n cursor: pointer;\n transition: background 0.15s ease;\n\n ${({ $isActive, theme }) =>\n $isActive &&\n css`\n background: ${rgba(theme.colors.primaryLight, 0.2)};\n `}\n\n &:hover {\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.15)};\n }\n`;\n\nconst StyledResultTitle = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n font-weight: 500;\n color: ${({ theme }) => theme.colors.dark};\n display: block;\n`;\n\nconst StyledResultMeta = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.gray};\n display: block;\n margin-top: 2px;\n`;\n\nconst StyledEmpty = styled.div<{ theme: Theme }>`\n padding: 20px;\n text-align: center;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.gray};\n`;\n\nconst StyledKbd = styled.kbd<{ theme: Theme }>`\n font-size: 11px;\n font-family: inherit;\n background: ${({ theme }) => theme.colors.grayLight};\n color: ${({ theme }) => theme.colors.grayDark};\n padding: 2px 6px;\n border-radius: 4px;\n margin-left: auto;\n font-weight: 600;\n display: none;\n\n ${mq(\"lg\")} {\n display: initial;\n }\n`;\n\nconst StyledSearchButton = styled.button<{ theme: Theme }>`\n ${interactiveStyles};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n display: flex;\n align-items: center;\n gap: 6px;\n background: ${({ theme }) => theme.colors.light};\n color: ${({ theme }) => theme.colors.primary};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n padding: 7px 8px;\n font-family: inherit;\n cursor: pointer;\n\n ${mq(\"lg\")} {\n padding: 5px 8px;\n }\n\n & svg.lucide {\n color: inherit;\n }\n`;\n\nfunction SearchProvider({\n pages,\n children,\n}: {\n pages: PageItem[];\n children: React.ReactNode;\n}) {\n const [isVisible, setIsVisible] = useState(false);\n const [isClosing, setIsClosing] = useState(false);\n const [query, setQuery] = useState(\"\");\n const [activeIndex, setActiveIndex] = useState(0);\n const inputRef = useRef<HTMLInputElement>(null);\n const resultsRef = useRef<HTMLUListElement>(null);\n const closingTimer = useRef<ReturnType<typeof setTimeout>>(null);\n const router = useRouter();\n\n const openSearch = useCallback(() => {\n if (closingTimer.current) clearTimeout(closingTimer.current);\n setIsClosing(false);\n setIsVisible(true);\n }, []);\n\n const closeSearch = useCallback(() => {\n setIsClosing(true);\n closingTimer.current = setTimeout(() => {\n setIsVisible(false);\n setIsClosing(false);\n setQuery(\"\");\n setActiveIndex(0);\n }, ANIMATION_MS);\n }, []);\n\n const filtered = useMemo(() => {\n if (!query.trim()) return pages;\n const q = query.toLowerCase();\n return pages.filter(\n (p) =>\n p.title.toLowerCase().includes(q) ||\n p.description?.toLowerCase().includes(q),\n );\n }, [pages, query]);\n\n const navigate = useCallback(\n (slug: string) => {\n closeSearch();\n router.push(`/${slug}`);\n },\n [closeSearch, router],\n );\n\n // Global Cmd+K / Ctrl+K listener\n const isVisibleRef = useRef(false);\n\n useEffect(() => {\n isVisibleRef.current = isVisible;\n }, [isVisible]);\n\n useEffect(() => {\n function handleKeyDown(e: KeyboardEvent) {\n if ((e.metaKey || e.ctrlKey) && e.key === \"k\") {\n e.preventDefault();\n if (isVisibleRef.current) {\n closeSearch();\n } else {\n openSearch();\n }\n }\n }\n document.addEventListener(\"keydown\", handleKeyDown);\n return () => document.removeEventListener(\"keydown\", handleKeyDown);\n }, [closeSearch, openSearch]);\n\n // Focus input on open\n useEffect(() => {\n if (isVisible && !isClosing) {\n setTimeout(() => inputRef.current?.focus(), 10);\n }\n }, [isVisible, isClosing]);\n\n // Scroll active item into view\n useEffect(() => {\n if (!resultsRef.current) return;\n const active = resultsRef.current.children[activeIndex] as HTMLElement;\n active?.scrollIntoView({ block: \"nearest\" });\n }, [activeIndex]);\n\n function handleKeyDown(e: React.KeyboardEvent) {\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n setActiveIndex((i) => (i < filtered.length - 1 ? i + 1 : 0));\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n setActiveIndex((i) => (i > 0 ? i - 1 : filtered.length - 1));\n } else if (e.key === \"Enter\") {\n e.preventDefault();\n if (filtered[activeIndex]) {\n navigate(filtered[activeIndex].slug);\n }\n } else if (e.key === \"Escape\") {\n closeSearch();\n }\n }\n\n return (\n <SearchContext.Provider value={{ openSearch }}>\n {children}\n {isVisible && (\n <StyledBackdrop $isClosing={isClosing} onClick={closeSearch}>\n <StyledModal\n $isClosing={isClosing}\n onClick={(e) => e.stopPropagation()}\n >\n <StyledInputWrapper>\n <Search size={18} />\n <StyledInput\n ref={inputRef}\n value={query}\n onChange={(e) => {\n setQuery(e.target.value);\n setActiveIndex(0);\n }}\n onKeyDown={handleKeyDown}\n placeholder=\"Search docs...\"\n autoComplete=\"off\"\n spellCheck={false}\n />\n <StyledKbd>esc</StyledKbd>\n </StyledInputWrapper>\n {filtered.length > 0 ? (\n <StyledResults ref={resultsRef}>\n {filtered.map((page, index) => (\n <StyledResultItem\n key={page.slug + page.section}\n $isActive={index === activeIndex}\n onClick={() => navigate(page.slug)}\n onMouseEnter={() => setActiveIndex(index)}\n >\n <StyledResultTitle>{page.title}</StyledResultTitle>\n <StyledResultMeta>\n {page.section ? `${page.section} / ` : \"\"}\n {page.category}\n </StyledResultMeta>\n </StyledResultItem>\n ))}\n </StyledResults>\n ) : (\n <StyledEmpty>No results found</StyledEmpty>\n )}\n </StyledModal>\n </StyledBackdrop>\n )}\n </SearchContext.Provider>\n );\n}\n\nexport {\n SearchProvider,\n SearchContext,\n StyledKbd as SearchKbd,\n StyledSearchButton,\n};\n";
1
+ export declare const searchDocsTemplate = "\"use client\";\nimport React, {\n createContext,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { useRouter } from \"next/navigation\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { rgba } from \"polished\";\nimport { Search } from \"lucide-react\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { interactiveStyles } from \"@/components/layout/SharedStyled\";\nimport { Spinner } from \"@/components/Spinner\";\n\ninterface PageItem {\n slug: string;\n title: string;\n description?: string;\n category: string;\n section?: string;\n}\n\ninterface SectionItem {\n label: string;\n slug: string;\n}\n\ninterface ContentHit {\n slug: string;\n snippet: string;\n}\n\ninterface MergedResult {\n page: PageItem;\n snippet?: string;\n}\n\ninterface SearchContextValue {\n openSearch: () => void;\n}\n\nconst SearchContext = createContext<SearchContextValue>({\n openSearch: () => {},\n});\n\nconst ANIMATION_MS = 150;\n\nconst backdropIn = keyframes`\n from { opacity: 0; }\n to { opacity: 1; }\n`;\n\nconst backdropOut = keyframes`\n from { opacity: 1; }\n to { opacity: 0; }\n`;\n\nconst modalIn = keyframes`\n from { opacity: 0; transform: scale(0.96) translateY(-8px); }\n to { opacity: 1; transform: scale(1) translateY(0); }\n`;\n\nconst modalOut = keyframes`\n from { opacity: 1; transform: scale(1) translateY(0); }\n to { opacity: 0; transform: scale(0.96) translateY(-8px); }\n`;\n\nconst StyledBackdrop = styled.div<{ theme: Theme; $isClosing: boolean }>`\n position: fixed;\n inset: 0;\n z-index: 9999;\n background: ${({ theme }) =>\n rgba(theme.isDark ? theme.colors.light : theme.colors.dark, 0.5)};\n backdrop-filter: blur(4px);\n -webkit-backdrop-filter: blur(4px);\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding: 20px;\n animation: ${({ $isClosing }) => ($isClosing ? backdropOut : backdropIn)}\n ${ANIMATION_MS}ms ease forwards;\n\n ${mq(\"lg\")} {\n padding: 120px 20px 20px 20px;\n }\n`;\n\nconst StyledModal = styled.div<{ theme: Theme; $isClosing: boolean }>`\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n box-shadow: ${({ theme }) => theme.shadows.xs};\n width: 100%;\n max-width: 560px;\n max-height: calc(100dvh - 40px);\n display: flex;\n flex-direction: column;\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding-bottom: 8px;\n animation: ${({ $isClosing }) => ($isClosing ? modalOut : modalIn)}\n ${ANIMATION_MS}ms ease forwards;\n\n ${mq(\"lg\")} {\n max-height: calc(100dvh - 240px);\n }\n`;\n\nconst StyledInputWrapper = styled.div<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 16px;\n flex-shrink: 0;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n & svg.lucide {\n color: ${({ theme }) => theme.colors.gray};\n flex-shrink: 0;\n }\n`;\n\nconst StyledInput = styled.input<{ theme: Theme }>`\n flex: 1;\n border: none;\n outline: none;\n background: transparent;\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n line-height: ${({ theme }) => theme.lineHeights.text.lg};\n color: ${({ theme }) => theme.colors.dark};\n font-family: inherit;\n\n &::placeholder {\n color: ${({ theme }) => theme.colors.gray};\n }\n`;\n\nconst StyledResults = styled.ul<{ theme: Theme }>`\n list-style: none;\n margin: 8px 0 0 0;\n padding: 0 8px;\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n -webkit-overflow-scrolling: touch;\n\n &::-webkit-scrollbar {\n display: none;\n }\n`;\n\nconst StyledResultItem = styled.li<{ theme: Theme; $isActive: boolean }>`\n padding: 10px 12px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n cursor: pointer;\n transition: background 0.15s ease;\n\n ${({ $isActive, theme }) =>\n $isActive &&\n css`\n background: ${rgba(theme.colors.primaryLight, 0.2)};\n `}\n\n &:hover {\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.15)};\n }\n`;\n\nconst StyledResultTitle = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n font-weight: 500;\n color: ${({ theme }) => theme.colors.dark};\n display: block;\n`;\n\nconst StyledResultMeta = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.gray};\n display: block;\n margin-top: 2px;\n`;\n\nconst StyledSnippet = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.grayDark};\n display: block;\n margin-top: 4px;\n line-height: 1.4;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n & mark {\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.35)};\n color: inherit;\n border-radius: 4px;\n padding: 0 1px;\n }\n`;\n\nconst StyledEmpty = styled.div<{ theme: Theme }>`\n padding: 20px 20px 12px;\n min-height: 40px;\n display: flex;\n align-items: center;\n justify-content: center;\n text-align: center;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.gray};\n`;\n\nconst StyledKbd = styled.kbd<{ theme: Theme }>`\n font-size: 11px;\n font-family: inherit;\n background: ${({ theme }) => theme.colors.grayLight};\n color: ${({ theme }) => theme.colors.grayDark};\n padding: 2px 6px;\n border-radius: 4px;\n margin-left: auto;\n font-weight: 600;\n display: none;\n\n ${mq(\"lg\")} {\n display: initial;\n }\n`;\n\nconst StyledSearchButton = styled.button<{ theme: Theme }>`\n ${interactiveStyles};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n display: flex;\n align-items: center;\n gap: 6px;\n background: ${({ theme }) => theme.colors.light};\n color: ${({ theme }) => theme.colors.primary};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n padding: 7px 8px;\n font-family: inherit;\n cursor: pointer;\n\n ${mq(\"lg\")} {\n padding: 5px 8px;\n }\n\n & svg.lucide {\n color: inherit;\n }\n`;\n\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\");\n}\n\nfunction highlightMatch(snippet: string, query: string): string {\n const escaped = escapeHtml(snippet);\n if (!query.trim()) return escaped;\n const q = escapeHtml(query.trim());\n const regex = new RegExp(\n `(${q.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")})`,\n \"gi\",\n );\n return escaped.replace(regex, \"<mark>$1</mark>\");\n}\n\nfunction SearchProvider({\n pages,\n sections,\n children,\n}: {\n pages: PageItem[];\n sections?: SectionItem[];\n children: React.ReactNode;\n}) {\n const [isVisible, setIsVisible] = useState(false);\n const [isClosing, setIsClosing] = useState(false);\n const [query, setQuery] = useState(\"\");\n const [activeIndex, setActiveIndex] = useState(0);\n const [contentResults, setContentResults] = useState<ContentHit[]>([]);\n const [isSearching, setIsSearching] = useState(false);\n const inputRef = useRef<HTMLInputElement>(null);\n const resultsRef = useRef<HTMLUListElement>(null);\n const closingTimer = useRef<ReturnType<typeof setTimeout>>(null);\n const abortRef = useRef<AbortController | null>(null);\n const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);\n const router = useRouter();\n\n const sectionLabels = useMemo(() => {\n const map: Record<string, string> = {};\n sections?.forEach((s) => {\n map[s.slug] = s.label;\n });\n return map;\n }, [sections]);\n\n const openSearch = useCallback(() => {\n if (closingTimer.current) clearTimeout(closingTimer.current);\n setIsClosing(false);\n setIsVisible(true);\n }, []);\n\n const closeSearch = useCallback(() => {\n setIsClosing(true);\n if (abortRef.current) abortRef.current.abort();\n if (debounceRef.current) clearTimeout(debounceRef.current);\n closingTimer.current = setTimeout(() => {\n setIsVisible(false);\n setIsClosing(false);\n setQuery(\"\");\n setActiveIndex(0);\n setContentResults([]);\n setIsSearching(false);\n }, ANIMATION_MS);\n }, []);\n\n // Instant title/description filtering\n const titleFiltered = useMemo(() => {\n if (!query.trim()) return pages;\n const q = query.toLowerCase();\n return pages.filter(\n (p) =>\n p.title.toLowerCase().includes(q) ||\n p.description?.toLowerCase().includes(q),\n );\n }, [pages, query]);\n\n // Merge title matches with content matches\n const merged = useMemo<MergedResult[]>(() => {\n if (!query.trim()) {\n return pages.map((p) => ({ page: p }));\n }\n\n const titleMatchSlugs = new Set(titleFiltered.map((p) => p.slug));\n const titleMatches: MergedResult[] = titleFiltered.map((p) => {\n const hit = contentResults.find((cr) => cr.slug === p.slug);\n return { page: p, snippet: hit?.snippet };\n });\n\n const pageMap = new Map(pages.map((p) => [p.slug, p]));\n const contentOnly: MergedResult[] = [];\n for (const cr of contentResults) {\n if (!titleMatchSlugs.has(cr.slug)) {\n const page = pageMap.get(cr.slug);\n if (page) {\n contentOnly.push({ page, snippet: cr.snippet });\n }\n }\n }\n\n return [...titleMatches, ...contentOnly];\n }, [pages, query, titleFiltered, contentResults]);\n\n // Debounced content search\n useEffect(() => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (abortRef.current) abortRef.current.abort();\n\n const q = query.trim();\n if (q.length < 2) {\n setContentResults([]);\n setIsSearching(false);\n return;\n }\n\n setIsSearching(true);\n debounceRef.current = setTimeout(async () => {\n const controller = new AbortController();\n abortRef.current = controller;\n try {\n const res = await fetch(\n `/api/search?q=${encodeURIComponent(q)}&limit=15`,\n { signal: controller.signal },\n );\n if (!res.ok) throw new Error(\"Search failed\");\n const data = await res.json();\n setContentResults(data.results ?? []);\n } catch (err: unknown) {\n if (err instanceof DOMException && err.name === \"AbortError\") return;\n setContentResults([]);\n } finally {\n if (!controller.signal.aborted) {\n setIsSearching(false);\n }\n }\n }, 300);\n\n return () => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n };\n }, [query]);\n\n const navigate = useCallback(\n (slug: string) => {\n closeSearch();\n router.push(`/${slug}`);\n },\n [closeSearch, router],\n );\n\n // Global Cmd+K / Ctrl+K listener\n const isVisibleRef = useRef(false);\n\n useEffect(() => {\n isVisibleRef.current = isVisible;\n }, [isVisible]);\n\n useEffect(() => {\n function handleKeyDown(e: KeyboardEvent) {\n if ((e.metaKey || e.ctrlKey) && e.key === \"k\") {\n e.preventDefault();\n if (isVisibleRef.current) {\n closeSearch();\n } else {\n openSearch();\n }\n }\n }\n document.addEventListener(\"keydown\", handleKeyDown);\n return () => document.removeEventListener(\"keydown\", handleKeyDown);\n }, [closeSearch, openSearch]);\n\n // Focus input on open\n useEffect(() => {\n if (isVisible && !isClosing) {\n setTimeout(() => inputRef.current?.focus(), 10);\n }\n }, [isVisible, isClosing]);\n\n // Scroll active item into view\n useEffect(() => {\n if (!resultsRef.current) return;\n const active = resultsRef.current.children[activeIndex] as HTMLElement;\n active?.scrollIntoView({ block: \"nearest\" });\n }, [activeIndex]);\n\n function handleKeyDown(e: React.KeyboardEvent) {\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n setActiveIndex((i) => (i < merged.length - 1 ? i + 1 : 0));\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n setActiveIndex((i) => (i > 0 ? i - 1 : merged.length - 1));\n } else if (e.key === \"Enter\") {\n e.preventDefault();\n if (merged[activeIndex]) {\n navigate(merged[activeIndex].page.slug);\n }\n } else if (e.key === \"Escape\") {\n closeSearch();\n }\n }\n\n return (\n <SearchContext.Provider value={{ openSearch }}>\n {children}\n {isVisible && (\n <StyledBackdrop $isClosing={isClosing} onClick={closeSearch}>\n <StyledModal\n $isClosing={isClosing}\n onClick={(e) => e.stopPropagation()}\n >\n <StyledInputWrapper>\n <Search size={18} />\n <StyledInput\n ref={inputRef}\n value={query}\n onChange={(e) => {\n setQuery(e.target.value);\n setActiveIndex(0);\n }}\n onKeyDown={handleKeyDown}\n placeholder=\"Search docs...\"\n autoComplete=\"off\"\n spellCheck={false}\n />\n <StyledKbd>Esc</StyledKbd>\n </StyledInputWrapper>\n {merged.length > 0 ? (\n <StyledResults ref={resultsRef}>\n {merged.map((result, index) => (\n <StyledResultItem\n key={result.page.slug + result.page.section}\n $isActive={index === activeIndex}\n onClick={() => navigate(result.page.slug)}\n onMouseEnter={() => setActiveIndex(index)}\n >\n <StyledResultTitle>{result.page.title}</StyledResultTitle>\n <StyledResultMeta>\n {result.page.section\n ? `${sectionLabels[result.page.section] || result.page.section} / `\n : \"\"}\n {result.page.category}\n </StyledResultMeta>\n {result.snippet && (\n <StyledSnippet\n dangerouslySetInnerHTML={{\n __html: highlightMatch(result.snippet, query),\n }}\n />\n )}\n </StyledResultItem>\n ))}\n </StyledResults>\n ) : (\n <StyledEmpty>\n {isSearching ? <Spinner size={18} /> : \"No results found\"}\n </StyledEmpty>\n )}\n </StyledModal>\n </StyledBackdrop>\n )}\n </SearchContext.Provider>\n );\n}\n\nexport {\n SearchProvider,\n SearchContext,\n StyledKbd as SearchKbd,\n StyledSearchButton,\n};\n";
@@ -13,6 +13,7 @@ import { rgba } from "polished";
13
13
  import { Search } from "lucide-react";
14
14
  import { mq, Theme } from "@/app/theme";
15
15
  import { interactiveStyles } from "@/components/layout/SharedStyled";
16
+ import { Spinner } from "@/components/Spinner";
16
17
 
17
18
  interface PageItem {
18
19
  slug: string;
@@ -22,6 +23,21 @@ interface PageItem {
22
23
  section?: string;
23
24
  }
24
25
 
26
+ interface SectionItem {
27
+ label: string;
28
+ slug: string;
29
+ }
30
+
31
+ interface ContentHit {
32
+ slug: string;
33
+ snippet: string;
34
+ }
35
+
36
+ interface MergedResult {
37
+ page: PageItem;
38
+ snippet?: string;
39
+ }
40
+
25
41
  interface SearchContextValue {
26
42
  openSearch: () => void;
27
43
  }
@@ -75,13 +91,14 @@ const StyledBackdrop = styled.div<{ theme: Theme; $isClosing: boolean }>\`
75
91
  const StyledModal = styled.div<{ theme: Theme; $isClosing: boolean }>\`
76
92
  background: \${({ theme }) => theme.colors.light};
77
93
  border-radius: \${({ theme }) => theme.spacing.radius.lg};
78
- box-shadow: \${({ theme }) => theme.shadows.xl};
94
+ box-shadow: \${({ theme }) => theme.shadows.xs};
79
95
  width: 100%;
80
96
  max-width: 560px;
81
97
  max-height: calc(100dvh - 40px);
82
98
  display: flex;
83
99
  flex-direction: column;
84
100
  border: solid 1px \${({ theme }) => theme.colors.grayLight};
101
+ padding-bottom: 8px;
85
102
  animation: \${({ $isClosing }) => ($isClosing ? modalOut : modalIn)}
86
103
  \${ANIMATION_MS}ms ease forwards;
87
104
 
@@ -121,8 +138,8 @@ const StyledInput = styled.input<{ theme: Theme }>\`
121
138
 
122
139
  const StyledResults = styled.ul<{ theme: Theme }>\`
123
140
  list-style: none;
124
- margin: 0;
125
- padding: 8px;
141
+ margin: 8px 0 0 0;
142
+ padding: 0 8px;
126
143
  overflow-y: auto;
127
144
  flex: 1;
128
145
  min-height: 0;
@@ -164,8 +181,30 @@ const StyledResultMeta = styled.span<{ theme: Theme }>\`
164
181
  margin-top: 2px;
165
182
  \`;
166
183
 
184
+ const StyledSnippet = styled.span<{ theme: Theme }>\`
185
+ font-size: \${({ theme }) => theme.fontSizes.small.lg};
186
+ color: \${({ theme }) => theme.colors.grayDark};
187
+ display: block;
188
+ margin-top: 4px;
189
+ line-height: 1.4;
190
+ overflow: hidden;
191
+ text-overflow: ellipsis;
192
+ white-space: nowrap;
193
+
194
+ & mark {
195
+ background: \${({ theme }) => rgba(theme.colors.primaryLight, 0.35)};
196
+ color: inherit;
197
+ border-radius: 4px;
198
+ padding: 0 1px;
199
+ }
200
+ \`;
201
+
167
202
  const StyledEmpty = styled.div<{ theme: Theme }>\`
168
- padding: 20px;
203
+ padding: 20px 20px 12px;
204
+ min-height: 40px;
205
+ display: flex;
206
+ align-items: center;
207
+ justify-content: center;
169
208
  text-align: center;
170
209
  font-size: \${({ theme }) => theme.fontSizes.small.lg};
171
210
  color: \${({ theme }) => theme.colors.gray};
@@ -209,22 +248,55 @@ const StyledSearchButton = styled.button<{ theme: Theme }>\`
209
248
  }
210
249
  \`;
211
250
 
251
+ function escapeHtml(str: string): string {
252
+ return str
253
+ .replace(/&/g, "&amp;")
254
+ .replace(/</g, "&lt;")
255
+ .replace(/>/g, "&gt;")
256
+ .replace(/"/g, "&quot;");
257
+ }
258
+
259
+ function highlightMatch(snippet: string, query: string): string {
260
+ const escaped = escapeHtml(snippet);
261
+ if (!query.trim()) return escaped;
262
+ const q = escapeHtml(query.trim());
263
+ const regex = new RegExp(
264
+ \`(\${q.replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&")})\`,
265
+ "gi",
266
+ );
267
+ return escaped.replace(regex, "<mark>$1</mark>");
268
+ }
269
+
212
270
  function SearchProvider({
213
271
  pages,
272
+ sections,
214
273
  children,
215
274
  }: {
216
275
  pages: PageItem[];
276
+ sections?: SectionItem[];
217
277
  children: React.ReactNode;
218
278
  }) {
219
279
  const [isVisible, setIsVisible] = useState(false);
220
280
  const [isClosing, setIsClosing] = useState(false);
221
281
  const [query, setQuery] = useState("");
222
282
  const [activeIndex, setActiveIndex] = useState(0);
283
+ const [contentResults, setContentResults] = useState<ContentHit[]>([]);
284
+ const [isSearching, setIsSearching] = useState(false);
223
285
  const inputRef = useRef<HTMLInputElement>(null);
224
286
  const resultsRef = useRef<HTMLUListElement>(null);
225
287
  const closingTimer = useRef<ReturnType<typeof setTimeout>>(null);
288
+ const abortRef = useRef<AbortController | null>(null);
289
+ const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
226
290
  const router = useRouter();
227
291
 
292
+ const sectionLabels = useMemo(() => {
293
+ const map: Record<string, string> = {};
294
+ sections?.forEach((s) => {
295
+ map[s.slug] = s.label;
296
+ });
297
+ return map;
298
+ }, [sections]);
299
+
228
300
  const openSearch = useCallback(() => {
229
301
  if (closingTimer.current) clearTimeout(closingTimer.current);
230
302
  setIsClosing(false);
@@ -233,15 +305,20 @@ function SearchProvider({
233
305
 
234
306
  const closeSearch = useCallback(() => {
235
307
  setIsClosing(true);
308
+ if (abortRef.current) abortRef.current.abort();
309
+ if (debounceRef.current) clearTimeout(debounceRef.current);
236
310
  closingTimer.current = setTimeout(() => {
237
311
  setIsVisible(false);
238
312
  setIsClosing(false);
239
313
  setQuery("");
240
314
  setActiveIndex(0);
315
+ setContentResults([]);
316
+ setIsSearching(false);
241
317
  }, ANIMATION_MS);
242
318
  }, []);
243
319
 
244
- const filtered = useMemo(() => {
320
+ // Instant title/description filtering
321
+ const titleFiltered = useMemo(() => {
245
322
  if (!query.trim()) return pages;
246
323
  const q = query.toLowerCase();
247
324
  return pages.filter(
@@ -251,6 +328,71 @@ function SearchProvider({
251
328
  );
252
329
  }, [pages, query]);
253
330
 
331
+ // Merge title matches with content matches
332
+ const merged = useMemo<MergedResult[]>(() => {
333
+ if (!query.trim()) {
334
+ return pages.map((p) => ({ page: p }));
335
+ }
336
+
337
+ const titleMatchSlugs = new Set(titleFiltered.map((p) => p.slug));
338
+ const titleMatches: MergedResult[] = titleFiltered.map((p) => {
339
+ const hit = contentResults.find((cr) => cr.slug === p.slug);
340
+ return { page: p, snippet: hit?.snippet };
341
+ });
342
+
343
+ const pageMap = new Map(pages.map((p) => [p.slug, p]));
344
+ const contentOnly: MergedResult[] = [];
345
+ for (const cr of contentResults) {
346
+ if (!titleMatchSlugs.has(cr.slug)) {
347
+ const page = pageMap.get(cr.slug);
348
+ if (page) {
349
+ contentOnly.push({ page, snippet: cr.snippet });
350
+ }
351
+ }
352
+ }
353
+
354
+ return [...titleMatches, ...contentOnly];
355
+ }, [pages, query, titleFiltered, contentResults]);
356
+
357
+ // Debounced content search
358
+ useEffect(() => {
359
+ if (debounceRef.current) clearTimeout(debounceRef.current);
360
+ if (abortRef.current) abortRef.current.abort();
361
+
362
+ const q = query.trim();
363
+ if (q.length < 2) {
364
+ setContentResults([]);
365
+ setIsSearching(false);
366
+ return;
367
+ }
368
+
369
+ setIsSearching(true);
370
+ debounceRef.current = setTimeout(async () => {
371
+ const controller = new AbortController();
372
+ abortRef.current = controller;
373
+ try {
374
+ const res = await fetch(
375
+ \`/api/search?q=\${encodeURIComponent(q)}&limit=15\`,
376
+ { signal: controller.signal },
377
+ );
378
+ if (!res.ok) throw new Error("Search failed");
379
+ const data = await res.json();
380
+ setContentResults(data.results ?? []);
381
+ } catch (err: unknown) {
382
+ if (err instanceof DOMException && err.name === "AbortError") return;
383
+ setContentResults([]);
384
+ } finally {
385
+ if (!controller.signal.aborted) {
386
+ setIsSearching(false);
387
+ }
388
+ }
389
+ }, 300);
390
+
391
+ return () => {
392
+ if (debounceRef.current) clearTimeout(debounceRef.current);
393
+ };
394
+ }, [query]);
395
+
254
396
  const navigate = useCallback(
255
397
  (slug: string) => {
256
398
  closeSearch();
@@ -298,14 +440,14 @@ function SearchProvider({
298
440
  function handleKeyDown(e: React.KeyboardEvent) {
299
441
  if (e.key === "ArrowDown") {
300
442
  e.preventDefault();
301
- setActiveIndex((i) => (i < filtered.length - 1 ? i + 1 : 0));
443
+ setActiveIndex((i) => (i < merged.length - 1 ? i + 1 : 0));
302
444
  } else if (e.key === "ArrowUp") {
303
445
  e.preventDefault();
304
- setActiveIndex((i) => (i > 0 ? i - 1 : filtered.length - 1));
446
+ setActiveIndex((i) => (i > 0 ? i - 1 : merged.length - 1));
305
447
  } else if (e.key === "Enter") {
306
448
  e.preventDefault();
307
- if (filtered[activeIndex]) {
308
- navigate(filtered[activeIndex].slug);
449
+ if (merged[activeIndex]) {
450
+ navigate(merged[activeIndex].page.slug);
309
451
  }
310
452
  } else if (e.key === "Escape") {
311
453
  closeSearch();
@@ -335,27 +477,38 @@ function SearchProvider({
335
477
  autoComplete="off"
336
478
  spellCheck={false}
337
479
  />
338
- <StyledKbd>esc</StyledKbd>
480
+ <StyledKbd>Esc</StyledKbd>
339
481
  </StyledInputWrapper>
340
- {filtered.length > 0 ? (
482
+ {merged.length > 0 ? (
341
483
  <StyledResults ref={resultsRef}>
342
- {filtered.map((page, index) => (
484
+ {merged.map((result, index) => (
343
485
  <StyledResultItem
344
- key={page.slug + page.section}
486
+ key={result.page.slug + result.page.section}
345
487
  $isActive={index === activeIndex}
346
- onClick={() => navigate(page.slug)}
488
+ onClick={() => navigate(result.page.slug)}
347
489
  onMouseEnter={() => setActiveIndex(index)}
348
490
  >
349
- <StyledResultTitle>{page.title}</StyledResultTitle>
491
+ <StyledResultTitle>{result.page.title}</StyledResultTitle>
350
492
  <StyledResultMeta>
351
- {page.section ? \`\${page.section} / \` : ""}
352
- {page.category}
493
+ {result.page.section
494
+ ? \`\${sectionLabels[result.page.section] || result.page.section} / \`
495
+ : ""}
496
+ {result.page.category}
353
497
  </StyledResultMeta>
498
+ {result.snippet && (
499
+ <StyledSnippet
500
+ dangerouslySetInnerHTML={{
501
+ __html: highlightMatch(result.snippet, query),
502
+ }}
503
+ />
504
+ )}
354
505
  </StyledResultItem>
355
506
  ))}
356
507
  </StyledResults>
357
508
  ) : (
358
- <StyledEmpty>No results found</StyledEmpty>
509
+ <StyledEmpty>
510
+ {isSearching ? <Spinner size={18} /> : "No results found"}
511
+ </StyledEmpty>
359
512
  )}
360
513
  </StyledModal>
361
514
  </StyledBackdrop>
@@ -0,0 +1 @@
1
+ export declare const spinnerTemplate = "\"use client\";\n\nimport styled, { keyframes } from \"styled-components\";\nimport { LoaderCircle } from \"lucide-react\";\n\nconst spin = keyframes`\n from { transform: rotate(0deg); }\n to { transform: rotate(360deg); }\n`;\n\nconst SpinnerWrapper = styled.span<{ $size?: number }>`\n display: inline-flex;\n animation: ${spin} 1s linear infinite;\n color: inherit;\n`;\n\ninterface SpinnerProps {\n size?: number;\n className?: string;\n}\n\nexport function Spinner({ size = 20, className }: SpinnerProps) {\n return (\n <SpinnerWrapper $size={size} className={className}>\n <LoaderCircle size={size} />\n </SpinnerWrapper>\n );\n}\n";
@@ -0,0 +1,29 @@
1
+ export const spinnerTemplate = `"use client";
2
+
3
+ import styled, { keyframes } from "styled-components";
4
+ import { LoaderCircle } from "lucide-react";
5
+
6
+ const spin = keyframes\`
7
+ from { transform: rotate(0deg); }
8
+ to { transform: rotate(360deg); }
9
+ \`;
10
+
11
+ const SpinnerWrapper = styled.span<{ $size?: number }>\`
12
+ display: inline-flex;
13
+ animation: \${spin} 1s linear infinite;
14
+ color: inherit;
15
+ \`;
16
+
17
+ interface SpinnerProps {
18
+ size?: number;
19
+ className?: string;
20
+ }
21
+
22
+ export function Spinner({ size = 20, className }: SpinnerProps) {
23
+ return (
24
+ <SpinnerWrapper $size={size} className={className}>
25
+ <LoaderCircle size={size} />
26
+ </SpinnerWrapper>
27
+ );
28
+ }
29
+ `;
@@ -10,16 +10,17 @@ export const packageJsonTemplate = JSON.stringify({
10
10
  format: "prettier --write .",
11
11
  },
12
12
  dependencies: {
13
- "@langchain/anthropic": "^1.3.24",
14
- "@langchain/core": "^1.1.33",
13
+ "@langchain/anthropic": "^1.3.25",
14
+ "@langchain/core": "^1.1.34",
15
15
  "@langchain/google-genai": "^2.1.26",
16
16
  "@langchain/openai": "^1.3.0",
17
17
  "@mdx-js/react": "^3.1.1",
18
18
  "@modelcontextprotocol/sdk": "^1.27.1",
19
19
  "@posthog/react": "^1.8.2",
20
20
  "cherry-styled-components": "^0.1.13",
21
- langchain: "^1.2.34",
21
+ langchain: "^1.2.35",
22
22
  "lucide-react": "^0.577.0",
23
+ minisearch: "^7.2.0",
23
24
  next: "16.2.0",
24
25
  "next-mdx-remote": "^6.0.0",
25
26
  polished: "^4.3.1",
@@ -31,7 +32,7 @@ export const packageJsonTemplate = JSON.stringify({
31
32
  "rehype-parse": "^9.0.1",
32
33
  "rehype-stringify": "^10.0.1",
33
34
  "remark-gfm": "^4.0.1",
34
- "styled-components": "^6.3.11",
35
+ "styled-components": "^6.3.12",
35
36
  unified: "^11.0.5",
36
37
  zod: "^4.3.6",
37
38
  },
@@ -39,7 +40,7 @@ export const packageJsonTemplate = JSON.stringify({
39
40
  "@types/node": "^25",
40
41
  "@types/react": "^19",
41
42
  "@types/react-dom": "^19",
42
- "baseline-browser-mapping": "^2.10.8",
43
+ "baseline-browser-mapping": "^2.10.9",
43
44
  eslint: "^9",
44
45
  "eslint-config-next": "16.2.0",
45
46
  prettier: "^3.8.1",
@@ -0,0 +1 @@
1
+ export declare const searchServiceTemplate = "import MiniSearch from \"minisearch\";\nimport { listDocs } from \"@/services/mcp/tools\";\n\ninterface IndexedDoc {\n id: string;\n slug: string;\n title: string;\n content: string;\n}\n\nexport interface SearchHit {\n slug: string;\n snippet: string;\n}\n\nlet index: MiniSearch<IndexedDoc> | null = null;\nlet docs: IndexedDoc[] = [];\nlet buildPromise: Promise<void> | null = null;\n\nconst CONTEXT_BEFORE = 60;\nconst CONTEXT_AFTER = 90;\n\nasync function ensureIndex(): Promise<MiniSearch<IndexedDoc>> {\n if (index) return index;\n if (buildPromise) {\n await buildPromise;\n return index!;\n }\n\n buildPromise = (async () => {\n const resources = await listDocs();\n\n docs = resources.map((doc) => {\n const slug =\n doc.path.replace(/^app\\//, \"\").replace(/\\/page\\.\\w+$/, \"\") || \"\";\n const cleanContent = doc.content\n .replace(/\\r\\n/g, \"\\n\")\n .replace(/\\n{3,}/g, \"\\n\\n\")\n .slice(0, 200_000);\n return {\n id: slug || \"__index__\",\n slug,\n title: doc.name,\n content: cleanContent,\n };\n });\n\n index = new MiniSearch<IndexedDoc>({\n fields: [\"title\", \"content\"],\n storeFields: [\"slug\", \"title\", \"content\"],\n searchOptions: {\n boost: { title: 3 },\n fuzzy: 0.2,\n prefix: true,\n },\n });\n\n index.addAll(docs);\n })();\n\n await buildPromise;\n return index!;\n}\n\nfunction extractSnippet(content: string, query: string): string {\n const lower = content.toLowerCase();\n const qLower = query.toLowerCase();\n const matchIdx = lower.indexOf(qLower);\n\n if (matchIdx === -1) {\n // Fuzzy match - no exact substring. Return start of content.\n const end = Math.min(content.length, CONTEXT_BEFORE + CONTEXT_AFTER);\n const raw = content.slice(0, end).trim();\n return raw.length < content.length ? raw + \"...\" : raw;\n }\n\n let start = Math.max(0, matchIdx - CONTEXT_BEFORE);\n let end = Math.min(content.length, matchIdx + query.length + CONTEXT_AFTER);\n\n // Align to word boundaries\n if (start > 0) {\n const space = content.indexOf(\" \", start);\n if (space !== -1 && space < matchIdx) start = space + 1;\n }\n if (end < content.length) {\n const space = content.lastIndexOf(\" \", end);\n if (space > matchIdx + query.length) end = space;\n }\n\n const prefix = start > 0 ? \"...\" : \"\";\n const suffix = end < content.length ? \"...\" : \"\";\n const snippet = content.slice(start, end).replace(/\\n+/g, \" \").trim();\n\n return prefix + snippet + suffix;\n}\n\nexport async function searchContent(\n query: string,\n limit = 10,\n): Promise<SearchHit[]> {\n const q = query.trim();\n if (!q) return [];\n\n const idx = await ensureIndex();\n const results = idx.search(q);\n\n return results.slice(0, limit).map((result) => {\n const doc = docs.find((d) => d.id === result.id);\n const content = doc?.content || \"\";\n return {\n slug: doc?.slug || \"\",\n snippet: extractSnippet(content, q),\n };\n });\n}\n";
@@ -0,0 +1,116 @@
1
+ export const searchServiceTemplate = `import MiniSearch from "minisearch";
2
+ import { listDocs } from "@/services/mcp/tools";
3
+
4
+ interface IndexedDoc {
5
+ id: string;
6
+ slug: string;
7
+ title: string;
8
+ content: string;
9
+ }
10
+
11
+ export interface SearchHit {
12
+ slug: string;
13
+ snippet: string;
14
+ }
15
+
16
+ let index: MiniSearch<IndexedDoc> | null = null;
17
+ let docs: IndexedDoc[] = [];
18
+ let buildPromise: Promise<void> | null = null;
19
+
20
+ const CONTEXT_BEFORE = 60;
21
+ const CONTEXT_AFTER = 90;
22
+
23
+ async function ensureIndex(): Promise<MiniSearch<IndexedDoc>> {
24
+ if (index) return index;
25
+ if (buildPromise) {
26
+ await buildPromise;
27
+ return index!;
28
+ }
29
+
30
+ buildPromise = (async () => {
31
+ const resources = await listDocs();
32
+
33
+ docs = resources.map((doc) => {
34
+ const slug =
35
+ doc.path.replace(/^app\\//, "").replace(/\\/page\\.\\w+$/, "") || "";
36
+ const cleanContent = doc.content
37
+ .replace(/\\r\\n/g, "\\n")
38
+ .replace(/\\n{3,}/g, "\\n\\n")
39
+ .slice(0, 200_000);
40
+ return {
41
+ id: slug || "__index__",
42
+ slug,
43
+ title: doc.name,
44
+ content: cleanContent,
45
+ };
46
+ });
47
+
48
+ index = new MiniSearch<IndexedDoc>({
49
+ fields: ["title", "content"],
50
+ storeFields: ["slug", "title", "content"],
51
+ searchOptions: {
52
+ boost: { title: 3 },
53
+ fuzzy: 0.2,
54
+ prefix: true,
55
+ },
56
+ });
57
+
58
+ index.addAll(docs);
59
+ })();
60
+
61
+ await buildPromise;
62
+ return index!;
63
+ }
64
+
65
+ function extractSnippet(content: string, query: string): string {
66
+ const lower = content.toLowerCase();
67
+ const qLower = query.toLowerCase();
68
+ const matchIdx = lower.indexOf(qLower);
69
+
70
+ if (matchIdx === -1) {
71
+ // Fuzzy match - no exact substring. Return start of content.
72
+ const end = Math.min(content.length, CONTEXT_BEFORE + CONTEXT_AFTER);
73
+ const raw = content.slice(0, end).trim();
74
+ return raw.length < content.length ? raw + "..." : raw;
75
+ }
76
+
77
+ let start = Math.max(0, matchIdx - CONTEXT_BEFORE);
78
+ let end = Math.min(content.length, matchIdx + query.length + CONTEXT_AFTER);
79
+
80
+ // Align to word boundaries
81
+ if (start > 0) {
82
+ const space = content.indexOf(" ", start);
83
+ if (space !== -1 && space < matchIdx) start = space + 1;
84
+ }
85
+ if (end < content.length) {
86
+ const space = content.lastIndexOf(" ", end);
87
+ if (space > matchIdx + query.length) end = space;
88
+ }
89
+
90
+ const prefix = start > 0 ? "..." : "";
91
+ const suffix = end < content.length ? "..." : "";
92
+ const snippet = content.slice(start, end).replace(/\\n+/g, " ").trim();
93
+
94
+ return prefix + snippet + suffix;
95
+ }
96
+
97
+ export async function searchContent(
98
+ query: string,
99
+ limit = 10,
100
+ ): Promise<SearchHit[]> {
101
+ const q = query.trim();
102
+ if (!q) return [];
103
+
104
+ const idx = await ensureIndex();
105
+ const results = idx.search(q);
106
+
107
+ return results.slice(0, limit).map((result) => {
108
+ const doc = docs.find((d) => d.id === result.id);
109
+ const content = doc?.content || "";
110
+ return {
111
+ slug: doc?.slug || "",
112
+ snippet: extractSnippet(content, q),
113
+ };
114
+ });
115
+ }
116
+ `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doccupine",
3
- "version": "0.0.80",
3
+ "version": "0.0.81",
4
4
  "description": "Free and open-source documentation platform. Write MDX, get a production-ready site with AI chat, built-in components, and an MCP server - in one command.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {