doccupine 0.0.79 → 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.
@@ -52,6 +52,7 @@ import { StyledComponentsRegistry } from "cherry-styled-components";
52
52
  import { theme, themeDark } from "@/app/theme";
53
53
  import { CherryThemeProvider } from "@/components/layout/CherryThemeProvider";
54
54
  import { ChtProvider } from "@/components/Chat";
55
+ import { SearchProvider } from "@/components/SearchDocs";
55
56
  ${hasSections
56
57
  ? ""
57
58
  : `import { Footer } from "@/components/layout/Footer";
@@ -133,19 +134,21 @@ ${hasSections
133
134
  <StyledComponentsRegistry>
134
135
  ${analyticsEnabled ? " <PostHogProvider>\n" : ""}${a} <CherryThemeProvider theme={theme} themeDark={themeDark}>
135
136
  ${a} ${chtOpen}
136
- ${a} <Header>
137
- ${a} <SectionBar sections={doccupineSections} />
138
- ${a} </Header>
139
- ${a} {process.env.LLM_PROVIDER && <Chat />}
140
- ${a} <DocsWrapper>
141
- ${a} <SectionNavProvider
142
- ${a} sections={doccupineSections}
143
- ${a} allPages={pages}
144
- ${a} hideBranding={hideBranding}
145
- ${a} >
146
- ${a} {children}
147
- ${a} </SectionNavProvider>
148
- ${a} </DocsWrapper>
137
+ ${a} <SearchProvider pages={pages} sections={doccupineSections}>
138
+ ${a} <Header>
139
+ ${a} <SectionBar sections={doccupineSections} />
140
+ ${a} </Header>
141
+ ${a} {process.env.LLM_PROVIDER && <Chat />}
142
+ ${a} <DocsWrapper>
143
+ ${a} <SectionNavProvider
144
+ ${a} sections={doccupineSections}
145
+ ${a} allPages={pages}
146
+ ${a} hideBranding={hideBranding}
147
+ ${a} >
148
+ ${a} {children}
149
+ ${a} </SectionNavProvider>
150
+ ${a} </DocsWrapper>
151
+ ${a} </SearchProvider>
149
152
  ${a} </ChtProvider>
150
153
  ${a} </CherryThemeProvider>
151
154
  ${analyticsEnabled ? " </PostHogProvider>\n" : ""} </StyledComponentsRegistry>
@@ -192,19 +195,21 @@ ${analyticsEnabled ? " </PostHogProvider>\n" : ""} </StyledCompo
192
195
  <StyledComponentsRegistry>
193
196
  ${analyticsEnabled ? " <PostHogProvider>\n" : ""}${a} <CherryThemeProvider theme={theme} themeDark={themeDark}>
194
197
  ${a} ${chtOpen}
195
- ${a} <Header />
196
- ${a} {process.env.LLM_PROVIDER && <Chat />}
197
- ${a} <SectionBarProvider hasSectionBar={false}>
198
- ${a} <DocsWrapper>
199
- ${a} <SideBar result={result.length ? result : defaultResults} />
200
- ${a} {children}
201
- ${a} <DocsNavigation
202
- ${a} result={result.length ? result : defaultResults}
203
- ${a} />
204
- ${a} <StaticLinks />
205
- ${a} <Footer hideBranding={hideBranding} />
206
- ${a} </DocsWrapper>
207
- ${a} </SectionBarProvider>
198
+ ${a} <SearchProvider pages={pages}>
199
+ ${a} <Header />
200
+ ${a} {process.env.LLM_PROVIDER && <Chat />}
201
+ ${a} <SectionBarProvider hasSectionBar={false}>
202
+ ${a} <DocsWrapper>
203
+ ${a} <SideBar result={result.length ? result : defaultResults} />
204
+ ${a} {children}
205
+ ${a} <DocsNavigation
206
+ ${a} result={result.length ? result : defaultResults}
207
+ ${a} />
208
+ ${a} <StaticLinks />
209
+ ${a} <Footer hideBranding={hideBranding} />
210
+ ${a} </DocsWrapper>
211
+ ${a} </SectionBarProvider>
212
+ ${a} </SearchProvider>
208
213
  ${a} </ChtProvider>
209
214
  ${a} </CherryThemeProvider>
210
215
  ${analyticsEnabled ? " </PostHogProvider>\n" : ""} </StyledComponentsRegistry>
@@ -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";
@@ -18,7 +19,9 @@ import { docsSideBarTemplate } from "../templates/components/DocsSideBar.js";
18
19
  import { mdxComponentsTemplate } from "../templates/components/MDXComponents.js";
19
20
  import { sectionNavProviderTemplate } from "../templates/components/SectionNavProvider.js";
20
21
  import { postHogProviderTemplate } from "../templates/components/PostHogProvider.js";
22
+ import { searchDocsTemplate } from "../templates/components/SearchDocs.js";
21
23
  import { sideBarTemplate } from "../templates/components/SideBar.js";
24
+ import { spinnerTemplate } from "../templates/components/Spinner.js";
22
25
  import { sectionBarTemplate } from "../templates/components/layout/SectionBar.js";
23
26
  import { accordionTemplate } from "../templates/components/layout/Accordion.js";
24
27
  import { actionBarTemplate } from "../templates/components/layout/ActionBar.js";
@@ -46,6 +49,7 @@ import { tabsTemplate } from "../templates/components/layout/Tabs.js";
46
49
  import { themeToggleTemplate } from "../templates/components/layout/ThemeToggle.js";
47
50
  import { typographyTemplate } from "../templates/components/layout/Typography.js";
48
51
  import { updateTemplate } from "../templates/components/layout/Update.js";
52
+ import { searchServiceTemplate } from "../templates/services/search.js";
49
53
  import { mcpIndexTemplate } from "../templates/services/mcp/index.js";
50
54
  import { mcpServerTemplate } from "../templates/services/mcp/server.js";
51
55
  import { mcpToolsTemplate } from "../templates/services/mcp/tools.js";
@@ -117,7 +121,9 @@ export const appStructure = {
117
121
  "app/theme.ts": themeTemplate,
118
122
  "app/api/mcp/route.ts": mcpRoutesTemplate,
119
123
  "app/api/rag/route.ts": ragRoutesTemplate,
124
+ "app/api/search/route.ts": searchRoutesTemplate,
120
125
  "app/api/theme/route.ts": routesTemplate,
126
+ "services/search.ts": searchServiceTemplate,
121
127
  "services/mcp/index.ts": mcpIndexTemplate,
122
128
  "services/mcp/server.ts": mcpServerTemplate,
123
129
  "services/mcp/tools.ts": mcpToolsTemplate,
@@ -140,7 +146,9 @@ export const appStructure = {
140
146
  "components/MDXComponents.tsx": mdxComponentsTemplate,
141
147
  "components/SectionNavProvider.tsx": sectionNavProviderTemplate,
142
148
  "components/PostHogProvider.tsx": postHogProviderTemplate,
149
+ "components/SearchDocs.tsx": searchDocsTemplate,
143
150
  "components/SideBar.tsx": sideBarTemplate,
151
+ "components/Spinner.tsx": spinnerTemplate,
144
152
  "components/layout/Accordion.tsx": accordionTemplate,
145
153
  "components/layout/ActionBar.tsx": actionBarTemplate,
146
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
+ `;
@@ -0,0 +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\";\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";
@@ -0,0 +1,526 @@
1
+ export const searchDocsTemplate = `"use client";
2
+ import React, {
3
+ createContext,
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import { useRouter } from "next/navigation";
11
+ import styled, { css, keyframes } from "styled-components";
12
+ import { rgba } from "polished";
13
+ import { Search } from "lucide-react";
14
+ import { mq, Theme } from "@/app/theme";
15
+ import { interactiveStyles } from "@/components/layout/SharedStyled";
16
+ import { Spinner } from "@/components/Spinner";
17
+
18
+ interface PageItem {
19
+ slug: string;
20
+ title: string;
21
+ description?: string;
22
+ category: string;
23
+ section?: string;
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
+
41
+ interface SearchContextValue {
42
+ openSearch: () => void;
43
+ }
44
+
45
+ const SearchContext = createContext<SearchContextValue>({
46
+ openSearch: () => {},
47
+ });
48
+
49
+ const ANIMATION_MS = 150;
50
+
51
+ const backdropIn = keyframes\`
52
+ from { opacity: 0; }
53
+ to { opacity: 1; }
54
+ \`;
55
+
56
+ const backdropOut = keyframes\`
57
+ from { opacity: 1; }
58
+ to { opacity: 0; }
59
+ \`;
60
+
61
+ const modalIn = keyframes\`
62
+ from { opacity: 0; transform: scale(0.96) translateY(-8px); }
63
+ to { opacity: 1; transform: scale(1) translateY(0); }
64
+ \`;
65
+
66
+ const modalOut = keyframes\`
67
+ from { opacity: 1; transform: scale(1) translateY(0); }
68
+ to { opacity: 0; transform: scale(0.96) translateY(-8px); }
69
+ \`;
70
+
71
+ const StyledBackdrop = styled.div<{ theme: Theme; $isClosing: boolean }>\`
72
+ position: fixed;
73
+ inset: 0;
74
+ z-index: 9999;
75
+ background: \${({ theme }) =>
76
+ rgba(theme.isDark ? theme.colors.light : theme.colors.dark, 0.5)};
77
+ backdrop-filter: blur(4px);
78
+ -webkit-backdrop-filter: blur(4px);
79
+ display: flex;
80
+ align-items: flex-start;
81
+ justify-content: center;
82
+ padding: 20px;
83
+ animation: \${({ $isClosing }) => ($isClosing ? backdropOut : backdropIn)}
84
+ \${ANIMATION_MS}ms ease forwards;
85
+
86
+ \${mq("lg")} {
87
+ padding: 120px 20px 20px 20px;
88
+ }
89
+ \`;
90
+
91
+ const StyledModal = styled.div<{ theme: Theme; $isClosing: boolean }>\`
92
+ background: \${({ theme }) => theme.colors.light};
93
+ border-radius: \${({ theme }) => theme.spacing.radius.lg};
94
+ box-shadow: \${({ theme }) => theme.shadows.xs};
95
+ width: 100%;
96
+ max-width: 560px;
97
+ max-height: calc(100dvh - 40px);
98
+ display: flex;
99
+ flex-direction: column;
100
+ border: solid 1px \${({ theme }) => theme.colors.grayLight};
101
+ padding-bottom: 8px;
102
+ animation: \${({ $isClosing }) => ($isClosing ? modalOut : modalIn)}
103
+ \${ANIMATION_MS}ms ease forwards;
104
+
105
+ \${mq("lg")} {
106
+ max-height: calc(100dvh - 240px);
107
+ }
108
+ \`;
109
+
110
+ const StyledInputWrapper = styled.div<{ theme: Theme }>\`
111
+ display: flex;
112
+ align-items: center;
113
+ gap: 12px;
114
+ padding: 16px;
115
+ flex-shrink: 0;
116
+ border-bottom: solid 1px \${({ theme }) => theme.colors.grayLight};
117
+
118
+ & svg.lucide {
119
+ color: \${({ theme }) => theme.colors.gray};
120
+ flex-shrink: 0;
121
+ }
122
+ \`;
123
+
124
+ const StyledInput = styled.input<{ theme: Theme }>\`
125
+ flex: 1;
126
+ border: none;
127
+ outline: none;
128
+ background: transparent;
129
+ font-size: \${({ theme }) => theme.fontSizes.text.lg};
130
+ line-height: \${({ theme }) => theme.lineHeights.text.lg};
131
+ color: \${({ theme }) => theme.colors.dark};
132
+ font-family: inherit;
133
+
134
+ &::placeholder {
135
+ color: \${({ theme }) => theme.colors.gray};
136
+ }
137
+ \`;
138
+
139
+ const StyledResults = styled.ul<{ theme: Theme }>\`
140
+ list-style: none;
141
+ margin: 8px 0 0 0;
142
+ padding: 0 8px;
143
+ overflow-y: auto;
144
+ flex: 1;
145
+ min-height: 0;
146
+ -webkit-overflow-scrolling: touch;
147
+
148
+ &::-webkit-scrollbar {
149
+ display: none;
150
+ }
151
+ \`;
152
+
153
+ const StyledResultItem = styled.li<{ theme: Theme; $isActive: boolean }>\`
154
+ padding: 10px 12px;
155
+ border-radius: \${({ theme }) => theme.spacing.radius.xs};
156
+ cursor: pointer;
157
+ transition: background 0.15s ease;
158
+
159
+ \${({ $isActive, theme }) =>
160
+ $isActive &&
161
+ css\`
162
+ background: \${rgba(theme.colors.primaryLight, 0.2)};
163
+ \`}
164
+
165
+ &:hover {
166
+ background: \${({ theme }) => rgba(theme.colors.primaryLight, 0.15)};
167
+ }
168
+ \`;
169
+
170
+ const StyledResultTitle = styled.span<{ theme: Theme }>\`
171
+ font-size: \${({ theme }) => theme.fontSizes.text.lg};
172
+ font-weight: 500;
173
+ color: \${({ theme }) => theme.colors.dark};
174
+ display: block;
175
+ \`;
176
+
177
+ const StyledResultMeta = styled.span<{ theme: Theme }>\`
178
+ font-size: \${({ theme }) => theme.fontSizes.small.lg};
179
+ color: \${({ theme }) => theme.colors.gray};
180
+ display: block;
181
+ margin-top: 2px;
182
+ \`;
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
+
202
+ const StyledEmpty = styled.div<{ theme: Theme }>\`
203
+ padding: 20px 20px 12px;
204
+ min-height: 40px;
205
+ display: flex;
206
+ align-items: center;
207
+ justify-content: center;
208
+ text-align: center;
209
+ font-size: \${({ theme }) => theme.fontSizes.small.lg};
210
+ color: \${({ theme }) => theme.colors.gray};
211
+ \`;
212
+
213
+ const StyledKbd = styled.kbd<{ theme: Theme }>\`
214
+ font-size: 11px;
215
+ font-family: inherit;
216
+ background: \${({ theme }) => theme.colors.grayLight};
217
+ color: \${({ theme }) => theme.colors.grayDark};
218
+ padding: 2px 6px;
219
+ border-radius: 4px;
220
+ margin-left: auto;
221
+ font-weight: 600;
222
+ display: none;
223
+
224
+ \${mq("lg")} {
225
+ display: initial;
226
+ }
227
+ \`;
228
+
229
+ const StyledSearchButton = styled.button<{ theme: Theme }>\`
230
+ \${interactiveStyles};
231
+ border: solid 1px \${({ theme }) => theme.colors.grayLight};
232
+ display: flex;
233
+ align-items: center;
234
+ gap: 6px;
235
+ background: \${({ theme }) => theme.colors.light};
236
+ color: \${({ theme }) => theme.colors.primary};
237
+ border-radius: \${({ theme }) => theme.spacing.radius.xs};
238
+ padding: 7px 8px;
239
+ font-family: inherit;
240
+ cursor: pointer;
241
+
242
+ \${mq("lg")} {
243
+ padding: 5px 8px;
244
+ }
245
+
246
+ & svg.lucide {
247
+ color: inherit;
248
+ }
249
+ \`;
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
+
270
+ function SearchProvider({
271
+ pages,
272
+ sections,
273
+ children,
274
+ }: {
275
+ pages: PageItem[];
276
+ sections?: SectionItem[];
277
+ children: React.ReactNode;
278
+ }) {
279
+ const [isVisible, setIsVisible] = useState(false);
280
+ const [isClosing, setIsClosing] = useState(false);
281
+ const [query, setQuery] = useState("");
282
+ const [activeIndex, setActiveIndex] = useState(0);
283
+ const [contentResults, setContentResults] = useState<ContentHit[]>([]);
284
+ const [isSearching, setIsSearching] = useState(false);
285
+ const inputRef = useRef<HTMLInputElement>(null);
286
+ const resultsRef = useRef<HTMLUListElement>(null);
287
+ const closingTimer = useRef<ReturnType<typeof setTimeout>>(null);
288
+ const abortRef = useRef<AbortController | null>(null);
289
+ const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
290
+ const router = useRouter();
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
+
300
+ const openSearch = useCallback(() => {
301
+ if (closingTimer.current) clearTimeout(closingTimer.current);
302
+ setIsClosing(false);
303
+ setIsVisible(true);
304
+ }, []);
305
+
306
+ const closeSearch = useCallback(() => {
307
+ setIsClosing(true);
308
+ if (abortRef.current) abortRef.current.abort();
309
+ if (debounceRef.current) clearTimeout(debounceRef.current);
310
+ closingTimer.current = setTimeout(() => {
311
+ setIsVisible(false);
312
+ setIsClosing(false);
313
+ setQuery("");
314
+ setActiveIndex(0);
315
+ setContentResults([]);
316
+ setIsSearching(false);
317
+ }, ANIMATION_MS);
318
+ }, []);
319
+
320
+ // Instant title/description filtering
321
+ const titleFiltered = useMemo(() => {
322
+ if (!query.trim()) return pages;
323
+ const q = query.toLowerCase();
324
+ return pages.filter(
325
+ (p) =>
326
+ p.title.toLowerCase().includes(q) ||
327
+ p.description?.toLowerCase().includes(q),
328
+ );
329
+ }, [pages, query]);
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
+
396
+ const navigate = useCallback(
397
+ (slug: string) => {
398
+ closeSearch();
399
+ router.push(\`/\${slug}\`);
400
+ },
401
+ [closeSearch, router],
402
+ );
403
+
404
+ // Global Cmd+K / Ctrl+K listener
405
+ const isVisibleRef = useRef(false);
406
+
407
+ useEffect(() => {
408
+ isVisibleRef.current = isVisible;
409
+ }, [isVisible]);
410
+
411
+ useEffect(() => {
412
+ function handleKeyDown(e: KeyboardEvent) {
413
+ if ((e.metaKey || e.ctrlKey) && e.key === "k") {
414
+ e.preventDefault();
415
+ if (isVisibleRef.current) {
416
+ closeSearch();
417
+ } else {
418
+ openSearch();
419
+ }
420
+ }
421
+ }
422
+ document.addEventListener("keydown", handleKeyDown);
423
+ return () => document.removeEventListener("keydown", handleKeyDown);
424
+ }, [closeSearch, openSearch]);
425
+
426
+ // Focus input on open
427
+ useEffect(() => {
428
+ if (isVisible && !isClosing) {
429
+ setTimeout(() => inputRef.current?.focus(), 10);
430
+ }
431
+ }, [isVisible, isClosing]);
432
+
433
+ // Scroll active item into view
434
+ useEffect(() => {
435
+ if (!resultsRef.current) return;
436
+ const active = resultsRef.current.children[activeIndex] as HTMLElement;
437
+ active?.scrollIntoView({ block: "nearest" });
438
+ }, [activeIndex]);
439
+
440
+ function handleKeyDown(e: React.KeyboardEvent) {
441
+ if (e.key === "ArrowDown") {
442
+ e.preventDefault();
443
+ setActiveIndex((i) => (i < merged.length - 1 ? i + 1 : 0));
444
+ } else if (e.key === "ArrowUp") {
445
+ e.preventDefault();
446
+ setActiveIndex((i) => (i > 0 ? i - 1 : merged.length - 1));
447
+ } else if (e.key === "Enter") {
448
+ e.preventDefault();
449
+ if (merged[activeIndex]) {
450
+ navigate(merged[activeIndex].page.slug);
451
+ }
452
+ } else if (e.key === "Escape") {
453
+ closeSearch();
454
+ }
455
+ }
456
+
457
+ return (
458
+ <SearchContext.Provider value={{ openSearch }}>
459
+ {children}
460
+ {isVisible && (
461
+ <StyledBackdrop $isClosing={isClosing} onClick={closeSearch}>
462
+ <StyledModal
463
+ $isClosing={isClosing}
464
+ onClick={(e) => e.stopPropagation()}
465
+ >
466
+ <StyledInputWrapper>
467
+ <Search size={18} />
468
+ <StyledInput
469
+ ref={inputRef}
470
+ value={query}
471
+ onChange={(e) => {
472
+ setQuery(e.target.value);
473
+ setActiveIndex(0);
474
+ }}
475
+ onKeyDown={handleKeyDown}
476
+ placeholder="Search docs..."
477
+ autoComplete="off"
478
+ spellCheck={false}
479
+ />
480
+ <StyledKbd>Esc</StyledKbd>
481
+ </StyledInputWrapper>
482
+ {merged.length > 0 ? (
483
+ <StyledResults ref={resultsRef}>
484
+ {merged.map((result, index) => (
485
+ <StyledResultItem
486
+ key={result.page.slug + result.page.section}
487
+ $isActive={index === activeIndex}
488
+ onClick={() => navigate(result.page.slug)}
489
+ onMouseEnter={() => setActiveIndex(index)}
490
+ >
491
+ <StyledResultTitle>{result.page.title}</StyledResultTitle>
492
+ <StyledResultMeta>
493
+ {result.page.section
494
+ ? \`\${sectionLabels[result.page.section] || result.page.section} / \`
495
+ : ""}
496
+ {result.page.category}
497
+ </StyledResultMeta>
498
+ {result.snippet && (
499
+ <StyledSnippet
500
+ dangerouslySetInnerHTML={{
501
+ __html: highlightMatch(result.snippet, query),
502
+ }}
503
+ />
504
+ )}
505
+ </StyledResultItem>
506
+ ))}
507
+ </StyledResults>
508
+ ) : (
509
+ <StyledEmpty>
510
+ {isSearching ? <Spinner size={18} /> : "No results found"}
511
+ </StyledEmpty>
512
+ )}
513
+ </StyledModal>
514
+ </StyledBackdrop>
515
+ )}
516
+ </SearchContext.Provider>
517
+ );
518
+ }
519
+
520
+ export {
521
+ SearchProvider,
522
+ SearchContext,
523
+ StyledKbd as SearchKbd,
524
+ StyledSearchButton,
525
+ };
526
+ `;
@@ -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
+ `;
@@ -1 +1 @@
1
- export declare const calloutTemplate = "\"use client\";\nimport { Theme } from \"@/app/theme\";\nimport { styledSmall } from \"cherry-styled-components\";\nimport styled, { css } from \"styled-components\";\nimport { Icon, IconProps } from \"@/components/layout/Icon\";\n\ntype CalloutType = \"note\" | \"info\" | \"warning\" | \"danger\" | \"success\";\n\nconst StyledCallout = styled.div<{ theme: Theme; $type?: CalloutType }>`\n background: ${({ theme }) => theme.colors.light};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n padding: 20px;\n margin: 0;\n ${({ theme }) => styledSmall(theme)}\n color: ${({ theme }) => theme.colors.grayDark};\n display: flex;\n\n & svg {\n vertical-align: middle;\n min-width: min-content;\n margin: 3px 10px 0 0;\n }\n\n ${({ theme, $type }) =>\n $type === \"note\" &&\n css`\n border-color: ${theme.isDark ? \"#0ea5e94d\" : \"#0ea5e933\"};\n background: ${theme.isDark ? \"#0ea5e91a\" : \"#f0f9ff80\"};\n\n & svg.lucide,\n & p {\n color: ${theme.isDark ? \"#bae6fd\" : \"#0c4a6e\"};\n }\n `}\n\n ${({ theme, $type }) =>\n $type === \"info\" &&\n css`\n border-color: ${theme.isDark ? \"#71717a4d\" : \"#71717a33\"};\n background: ${theme.isDark ? \"#71717a1a\" : \"#fafafa80\"};\n\n & svg.lucide,\n & .lucide,\n & p {\n color: ${theme.isDark ? \"#e4e4e7\" : \"#18181b\"};\n }\n `}\n\n ${({ theme, $type }) =>\n $type === \"warning\" &&\n css`\n border-color: ${theme.isDark ? \"#f59e0b4d\" : \"#f59e0b33\"};\n background: ${theme.isDark ? \"#f59e0b1a\" : \"#fffbeb80\"};\n\n & svg.lucide,\n & p {\n color: ${theme.isDark ? \"#fde68a\" : \"#78350f\"};\n }\n `}\n\n ${({ theme, $type }) =>\n $type === \"danger\" &&\n css`\n border-color: ${theme.isDark ? \"#ef44444d\" : \"#ef444433\"};\n background: ${theme.isDark ? \"#ef44441a\" : \"#fef2f280\"};\n\n & svg.lucide,\n & p {\n color: ${theme.isDark ? \"#fecaca\" : \"#7f1d1d\"};\n }\n `}\n\n ${({ theme, $type }) =>\n $type === \"success\" &&\n css`\n border-color: ${theme.isDark ? \"#10b9814d\" : \"#10b98133\"};\n background: ${theme.isDark ? \"#10b9811a\" : \"#ecfdf580\"};\n\n & svg.lucide,\n & p {\n color: ${theme.isDark ? \"#a7f3d0\" : \"#064e3b\"};\n }\n `}\n`;\n\ninterface CalloutProps extends React.HTMLAttributes<HTMLDivElement> {\n children: React.ReactNode;\n icon?: IconProps;\n type?: CalloutType;\n}\n\nfunction Callout({ children, type, icon }: CalloutProps) {\n const iconType =\n type === \"note\"\n ? \"CircleAlert\"\n : type === \"info\"\n ? \"Info\"\n : type === \"warning\"\n ? \"TriangleAlert\"\n : type === \"danger\"\n ? \"OctagonAlert\"\n : type === \"success\"\n ? \"Check\"\n : (icon as IconProps);\n return (\n <StyledCallout $type={type}>\n <Icon name={iconType} size={16} />\n {children}\n </StyledCallout>\n );\n}\n\nexport { Callout };\n";
1
+ export declare const calloutTemplate = "\"use client\";\nimport { Theme } from \"@/app/theme\";\nimport { styledSmall } from \"cherry-styled-components\";\nimport styled, { css } from \"styled-components\";\nimport { Icon, IconProps } from \"@/components/layout/Icon\";\n\ntype CalloutType = \"note\" | \"info\" | \"warning\" | \"danger\" | \"success\";\n\nconst StyledCallout = styled.div<{ theme: Theme; $type?: CalloutType }>`\n background: ${({ theme }) => theme.colors.light};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n padding: 20px;\n margin: 0;\n ${({ theme }) => styledSmall(theme)}\n color: ${({ theme }) => theme.colors.grayDark};\n display: flex;\n\n & svg {\n vertical-align: middle;\n min-width: min-content;\n margin: 3px 10px 0 0;\n }\n\n ${({ theme, $type }) =>\n $type === \"note\" &&\n css`\n border-color: ${theme.isDark ? \"#0ea5e94d\" : \"#0ea5e933\"};\n background: ${theme.isDark ? \"#0ea5e91a\" : \"#f0f9ff80\"};\n\n & svg.lucide,\n & p {\n color: ${theme.isDark ? \"#bae6fd\" : \"#0c4a6e\"};\n }\n `}\n\n ${({ theme, $type }) =>\n $type === \"info\" &&\n css`\n border-color: ${theme.isDark ? \"#71717a4d\" : \"#71717a33\"};\n background: ${theme.isDark ? \"#71717a1a\" : \"#fafafa80\"};\n\n & svg.lucide,\n & .lucide,\n & p {\n color: ${theme.isDark ? \"#e4e4e7\" : \"#18181b\"};\n }\n `}\n\n ${({ theme, $type }) =>\n $type === \"warning\" &&\n css`\n border-color: ${theme.isDark ? \"#f59e0b4d\" : \"#f59e0b33\"};\n background: ${theme.isDark ? \"#f59e0b1a\" : \"#fffbeb80\"};\n\n & svg.lucide,\n & p {\n color: ${theme.isDark ? \"#fde68a\" : \"#78350f\"};\n }\n `}\n\n ${({ theme, $type }) =>\n $type === \"danger\" &&\n css`\n border-color: ${theme.isDark ? \"#ef44444d\" : \"#ef444433\"};\n background: ${theme.isDark ? \"#ef44441a\" : \"#fef2f280\"};\n\n & svg.lucide,\n & p {\n color: ${theme.isDark ? \"#fecaca\" : \"#7f1d1d\"};\n }\n `}\n\n ${({ theme, $type }) =>\n $type === \"success\" &&\n css`\n border-color: ${theme.isDark ? \"#10b9814d\" : \"#10b98133\"};\n background: ${theme.isDark ? \"#10b9811a\" : \"#ecfdf580\"};\n\n & svg.lucide,\n & p {\n color: ${theme.isDark ? \"#a7f3d0\" : \"#064e3b\"};\n }\n `}\n`;\n\nconst StyledChildren = styled.span`\n display: flex;\n flex-direction: column;\n gap: 10px;\n`;\n\ninterface CalloutProps extends React.HTMLAttributes<HTMLDivElement> {\n children: React.ReactNode;\n icon?: IconProps;\n type?: CalloutType;\n}\n\nfunction Callout({ children, type, icon }: CalloutProps) {\n const iconType =\n type === \"note\"\n ? \"CircleAlert\"\n : type === \"info\"\n ? \"Info\"\n : type === \"warning\"\n ? \"TriangleAlert\"\n : type === \"danger\"\n ? \"OctagonAlert\"\n : type === \"success\"\n ? \"Check\"\n : (icon as IconProps);\n return (\n <StyledCallout $type={type}>\n <Icon name={iconType} size={16} />\n <StyledChildren>{children}</StyledChildren>\n </StyledCallout>\n );\n}\n\nexport { Callout };\n";
@@ -84,6 +84,12 @@ const StyledCallout = styled.div<{ theme: Theme; $type?: CalloutType }>\`
84
84
  \`}
85
85
  \`;
86
86
 
87
+ const StyledChildren = styled.span\`
88
+ display: flex;
89
+ flex-direction: column;
90
+ gap: 10px;
91
+ \`;
92
+
87
93
  interface CalloutProps extends React.HTMLAttributes<HTMLDivElement> {
88
94
  children: React.ReactNode;
89
95
  icon?: IconProps;
@@ -106,7 +112,7 @@ function Callout({ children, type, icon }: CalloutProps) {
106
112
  return (
107
113
  <StyledCallout $type={type}>
108
114
  <Icon name={iconType} size={16} />
109
- {children}
115
+ <StyledChildren>{children}</StyledChildren>
110
116
  </StyledCallout>
111
117
  );
112
118
  }
@@ -1 +1 @@
1
- export declare const headerTemplate = "\"use client\";\nimport React from \"react\";\nimport { useCallback, useContext, useRef, useState } from \"react\";\nimport styled, { css, useTheme } from \"styled-components\";\nimport Link from \"next/link\";\nimport { rgba } from \"polished\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { useOnClickOutside } from \"@/components/ClickOutside\";\nimport { Logo } from \"@/components/layout/Pictograms\";\nimport { ChatContext, ChatButtonCTA } from \"@/components/Chat\";\nimport themeJson from \"@/theme.json\";\n\nconst customThemeJson = themeJson as typeof themeJson & {\n logo?: { dark: string; light: string };\n};\n\nconst StyledHeader = styled.header<{ theme: Theme; $hasChildren: boolean }>`\n position: sticky;\n top: 0;\n margin: 0;\n z-index: 1000;\n width: 100%;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n ${({ $hasChildren }) =>\n !$hasChildren &&\n css`\n ${mq(\"lg\")} {\n padding-bottom: 16px;\n padding-top: 16px;\n }\n `}\n\n &::before,\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n pointer-events: none;\n background: ${({ theme }) => theme.colors.light};\n z-index: -2;\n }\n\n &::after {\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.05)};\n z-index: -1;\n }\n\n & .logo {\n display: flex;\n\n & svg,\n & img {\n margin: auto;\n height: auto;\n width: fit-content;\n min-width: fit-content;\n max-width: 182px;\n max-height: 30px;\n\n & path[fill] {\n fill: ${({ theme }) => theme.colors.primary};\n }\n }\n }\n`;\n\nconst StyledHeaderInner = styled.div<{ $hasChildren: boolean }>`\n display: flex;\n align-items: center;\n justify-content: space-between;\n flex-wrap: wrap;\n padding: 16px 0 0 20px;\n\n ${({ $hasChildren }) =>\n !$hasChildren &&\n css`\n padding-bottom: 16px;\n `}\n\n ${mq(\"lg\")} {\n flex-wrap: nowrap;\n padding: 0 20px;\n }\n`;\n\nconst StyledLeftWrapper = styled.div`\n display: flex;\n align-items: center;\n gap: 8px;\n min-width: fit-content;\n padding-right: 20px;\n\n ${mq(\"lg\")} {\n padding-right: 0;\n }\n`;\n\ninterface HeaderProps {\n children?: React.ReactNode;\n}\n\nfunction Header({ children }: HeaderProps) {\n const [isOptionActive, setIsOptionActive] = useState(false);\n const [isLangActive, setIsLangActive] = useState(false);\n\n const wrapperRef = useRef<HTMLSpanElement>(null);\n const elmRef = useRef<HTMLDivElement>(null);\n const langRef = useRef<HTMLSpanElement>(null);\n const closeMenu = useCallback(() => {\n setIsOptionActive(false);\n setIsLangActive(false);\n }, []);\n\n useOnClickOutside(\n [elmRef, wrapperRef],\n isOptionActive ? closeMenu : () => {},\n );\n useOnClickOutside([langRef, wrapperRef], isLangActive ? closeMenu : () => {});\n const theme = useTheme() as Theme;\n const { isChatActive } = useContext(ChatContext);\n\n return (\n <StyledHeader $hasChildren={children ? true : false} id=\"header\">\n <StyledHeaderInner $hasChildren={children ? true : false}>\n <Link href=\"/\" className=\"logo\" aria-label=\"Logo\">\n {customThemeJson.logo ? (\n theme.isDark ? (\n // eslint-disable-next-line @next/next/no-img-element\n <img\n src={customThemeJson.logo.dark}\n alt=\"Logo\"\n width=\"100\"\n height=\"100\"\n />\n ) : (\n // eslint-disable-next-line @next/next/no-img-element\n <img\n src={customThemeJson.logo.light}\n alt=\"Logo\"\n width=\"100\"\n height=\"100\"\n />\n )\n ) : (\n <Logo />\n )}\n </Link>\n {children}\n <StyledLeftWrapper>\n {isChatActive && <ChatButtonCTA />}\n </StyledLeftWrapper>\n </StyledHeaderInner>\n </StyledHeader>\n );\n}\n\nexport { Header };\n";
1
+ export declare const headerTemplate = "\"use client\";\nimport React from \"react\";\nimport { useCallback, useContext, useRef, useState } from \"react\";\nimport styled, { css, useTheme } from \"styled-components\";\nimport Link from \"next/link\";\nimport { rgba } from \"polished\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { useOnClickOutside } from \"@/components/ClickOutside\";\nimport { Search } from \"lucide-react\";\nimport { Logo } from \"@/components/layout/Pictograms\";\nimport { ChatContext, ChatButtonCTA } from \"@/components/Chat\";\nimport {\n SearchContext,\n SearchKbd,\n StyledSearchButton,\n} from \"@/components/SearchDocs\";\nimport themeJson from \"@/theme.json\";\n\nconst customThemeJson = themeJson as typeof themeJson & {\n logo?: { dark: string; light: string };\n};\n\nconst StyledHeader = styled.header<{ theme: Theme; $hasChildren: boolean }>`\n position: sticky;\n top: 0;\n margin: 0;\n z-index: 1000;\n width: 100%;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n ${({ $hasChildren }) =>\n !$hasChildren &&\n css`\n ${mq(\"lg\")} {\n padding-bottom: 16px;\n padding-top: 16px;\n }\n `}\n\n &::before,\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n pointer-events: none;\n background: ${({ theme }) => theme.colors.light};\n z-index: -2;\n }\n\n &::after {\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.05)};\n z-index: -1;\n }\n\n & .logo {\n display: flex;\n\n & svg,\n & img {\n margin: auto;\n height: auto;\n width: fit-content;\n min-width: fit-content;\n max-width: 182px;\n max-height: 30px;\n\n & path[fill] {\n fill: ${({ theme }) => theme.colors.primary};\n }\n }\n }\n`;\n\nconst StyledHeaderInner = styled.div<{ $hasChildren: boolean }>`\n display: flex;\n align-items: center;\n justify-content: space-between;\n flex-wrap: wrap;\n padding: 16px 0 0 20px;\n\n ${({ $hasChildren }) =>\n !$hasChildren &&\n css`\n padding-bottom: 16px;\n `}\n\n ${mq(\"lg\")} {\n flex-wrap: nowrap;\n padding: 0 20px;\n }\n`;\n\nconst StyledLeftWrapper = styled.div`\n display: flex;\n align-items: center;\n gap: 10px;\n min-width: fit-content;\n padding-right: 20px;\n\n ${mq(\"lg\")} {\n padding-right: 0;\n }\n`;\n\ninterface HeaderProps {\n children?: React.ReactNode;\n}\n\nfunction Header({ children }: HeaderProps) {\n const [isOptionActive, setIsOptionActive] = useState(false);\n const [isLangActive, setIsLangActive] = useState(false);\n\n const wrapperRef = useRef<HTMLSpanElement>(null);\n const elmRef = useRef<HTMLDivElement>(null);\n const langRef = useRef<HTMLSpanElement>(null);\n const closeMenu = useCallback(() => {\n setIsOptionActive(false);\n setIsLangActive(false);\n }, []);\n\n useOnClickOutside(\n [elmRef, wrapperRef],\n isOptionActive ? closeMenu : () => {},\n );\n useOnClickOutside([langRef, wrapperRef], isLangActive ? closeMenu : () => {});\n const theme = useTheme() as Theme;\n const { isChatActive } = useContext(ChatContext);\n const { openSearch } = useContext(SearchContext);\n\n return (\n <StyledHeader $hasChildren={children ? true : false} id=\"header\">\n <StyledHeaderInner $hasChildren={children ? true : false}>\n <Link href=\"/\" className=\"logo\" aria-label=\"Logo\">\n {customThemeJson.logo ? (\n theme.isDark ? (\n // eslint-disable-next-line @next/next/no-img-element\n <img\n src={customThemeJson.logo.dark}\n alt=\"Logo\"\n width=\"100\"\n height=\"100\"\n />\n ) : (\n // eslint-disable-next-line @next/next/no-img-element\n <img\n src={customThemeJson.logo.light}\n alt=\"Logo\"\n width=\"100\"\n height=\"100\"\n />\n )\n ) : (\n <Logo />\n )}\n </Link>\n {children}\n <StyledLeftWrapper>\n <StyledSearchButton onClick={openSearch} aria-label=\"Search docs\">\n <Search size={14} />\n <SearchKbd>&#8984;K</SearchKbd>\n </StyledSearchButton>\n {isChatActive && <ChatButtonCTA />}\n </StyledLeftWrapper>\n </StyledHeaderInner>\n </StyledHeader>\n );\n}\n\nexport { Header };\n";
@@ -6,8 +6,14 @@ import Link from "next/link";
6
6
  import { rgba } from "polished";
7
7
  import { mq, Theme } from "@/app/theme";
8
8
  import { useOnClickOutside } from "@/components/ClickOutside";
9
+ import { Search } from "lucide-react";
9
10
  import { Logo } from "@/components/layout/Pictograms";
10
11
  import { ChatContext, ChatButtonCTA } from "@/components/Chat";
12
+ import {
13
+ SearchContext,
14
+ SearchKbd,
15
+ StyledSearchButton,
16
+ } from "@/components/SearchDocs";
11
17
  import themeJson from "@/theme.json";
12
18
 
13
19
  const customThemeJson = themeJson as typeof themeJson & {
@@ -91,7 +97,7 @@ const StyledHeaderInner = styled.div<{ $hasChildren: boolean }>\`
91
97
  const StyledLeftWrapper = styled.div\`
92
98
  display: flex;
93
99
  align-items: center;
94
- gap: 8px;
100
+ gap: 10px;
95
101
  min-width: fit-content;
96
102
  padding-right: 20px;
97
103
 
@@ -123,6 +129,7 @@ function Header({ children }: HeaderProps) {
123
129
  useOnClickOutside([langRef, wrapperRef], isLangActive ? closeMenu : () => {});
124
130
  const theme = useTheme() as Theme;
125
131
  const { isChatActive } = useContext(ChatContext);
132
+ const { openSearch } = useContext(SearchContext);
126
133
 
127
134
  return (
128
135
  <StyledHeader $hasChildren={children ? true : false} id="header">
@@ -152,6 +159,10 @@ function Header({ children }: HeaderProps) {
152
159
  </Link>
153
160
  {children}
154
161
  <StyledLeftWrapper>
162
+ <StyledSearchButton onClick={openSearch} aria-label="Search docs">
163
+ <Search size={14} />
164
+ <SearchKbd>&#8984;K</SearchKbd>
165
+ </StyledSearchButton>
155
166
  {isChatActive && <ChatButtonCTA />}
156
167
  </StyledLeftWrapper>
157
168
  </StyledHeaderInner>
@@ -1 +1 @@
1
- export declare const sharedStyledTemplate = "\"use client\";\nimport { mq, styledSmall, styledText, Theme } from \"cherry-styled-components\";\nimport styled, { css } from \"styled-components\";\n\nexport const interactiveStyles = css<{ theme: Theme }>`\n transition: all 0.3s ease;\n border: solid 1px transparent;\n box-shadow: 0 0 0 0px ${({ theme }) => theme.colors.primary};\n\n &:hover {\n border-color: ${({ theme }) => theme.colors.primary};\n }\n\n &:focus {\n border-color: ${({ theme }) => theme.colors.primary};\n box-shadow: 0 0 0 4px ${({ theme }) => theme.colors.primaryLight};\n }\n\n &:active {\n box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n`;\n\nexport const styledAnchor = css<{ theme: Theme }>`\n & a:not([class]):not(:has(img)) {\n color: inherit;\n transition: all 0.3s ease;\n text-decoration: none;\n box-shadow: 0 2px 0 0 ${({ theme }) => theme.colors.primary};\n\n &:hover {\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n box-shadow: 0 1px 0 0 ${({ theme }) => theme.colors.primary};\n }\n }\n`;\n\nexport const stylesLists = css<{ theme: Theme }>`\n & ul,\n & ol {\n & li {\n & > .code-wrapper {\n margin: 10px 0;\n }\n }\n }\n\n & ul {\n list-style: none;\n padding: 0;\n margin: 0;\n\n & li {\n text-indent: 0;\n display: block;\n position: relative;\n padding: 0 0 0 15px;\n margin: 0;\n ${({ theme }) => styledText(theme)};\n min-height: 23px;\n\n ${mq(\"lg\")} {\n min-height: 27px;\n }\n\n &::before {\n content: \"\";\n display: block;\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: ${({ theme }) => theme.colors.primary};\n position: absolute;\n top: 8px;\n left: 2px;\n\n ${mq(\"lg\")} {\n top: 10px;\n }\n }\n }\n }\n\n & ol {\n padding: 0;\n margin: 0;\n\n & ul {\n padding-left: 15px;\n }\n\n & > li {\n position: relative;\n padding: 0;\n counter-increment: item;\n margin: 0;\n ${({ theme }) => styledText(theme)};\n\n &::before {\n content: counter(item) \".\";\n display: inline-block;\n margin: 0 4px 0 0;\n font-weight: 700;\n color: ${({ theme }) => theme.colors.primary};\n min-width: max-content;\n }\n }\n }\n`;\n\nexport const styledTable = css<{ theme: Theme }>`\n & .table-wrapper {\n overflow-x: auto;\n width: 100%;\n }\n\n & table {\n margin: 0;\n padding: 0;\n border-collapse: collapse;\n width: 100%;\n text-align: left;\n\n & tr {\n margin: 0;\n padding: 0;\n }\n\n & th {\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding: 10px 10px 10px 0;\n ${({ theme }) => styledSmall(theme)};\n font-weight: 600;\n color: ${({ theme }) => theme.colors.dark};\n }\n\n & td {\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding: 10px 10px 10px 0;\n color: ${({ theme }) => theme.colors.grayDark};\n ${({ theme }) => styledSmall(theme)};\n }\n }\n`;\n\nexport const StyledSmallButton = styled.button<{ theme: Theme }>`\n ${interactiveStyles};\n background: transparent;\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n color: ${({ theme }) => theme.colors.primary};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n padding: 6px 8px;\n font-size: 12px;\n font-family: inherit;\n font-weight: 600;\n cursor: pointer;\n transition: all 0.2s ease;\n display: flex;\n align-items: center;\n gap: 6px;\n margin-right: -6px;\n\n & svg.lucide {\n color: inherit;\n }\n`;\n";
1
+ export declare const sharedStyledTemplate = "\"use client\";\nimport { mq, styledSmall, styledText, Theme } from \"cherry-styled-components\";\nimport styled, { css } from \"styled-components\";\n\nexport const interactiveStyles = css<{ theme: Theme }>`\n transition: all 0.3s ease;\n border: solid 1px transparent;\n box-shadow: 0 0 0 0px ${({ theme }) => theme.colors.primary};\n\n &:hover {\n border-color: ${({ theme }) => theme.colors.primary};\n }\n\n &:focus {\n border-color: ${({ theme }) => theme.colors.primary};\n box-shadow: 0 0 0 4px ${({ theme }) => theme.colors.primaryLight};\n }\n\n &:active {\n box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n`;\n\nexport const styledAnchor = css<{ theme: Theme }>`\n & a:not([class]):not(:has(img)) {\n color: inherit;\n transition: all 0.3s ease;\n text-decoration: none;\n box-shadow: 0 2px 0 0 ${({ theme }) => theme.colors.primary};\n\n &:hover {\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n box-shadow: 0 1px 0 0 ${({ theme }) => theme.colors.primary};\n }\n }\n`;\n\nexport const stylesLists = css<{ theme: Theme }>`\n & ul,\n & ol {\n & li {\n & > .code-wrapper {\n margin: 10px 0;\n }\n }\n }\n\n & ul {\n list-style: none;\n padding: 0;\n margin: 0;\n\n & li {\n text-indent: 0;\n display: block;\n position: relative;\n padding: 0 0 0 15px;\n margin: 0;\n ${({ theme }) => styledText(theme)};\n min-height: 23px;\n\n ${mq(\"lg\")} {\n min-height: 27px;\n }\n\n &::before {\n content: \"\";\n display: block;\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: ${({ theme }) => theme.colors.primary};\n position: absolute;\n top: 8px;\n left: 2px;\n\n ${mq(\"lg\")} {\n top: 10px;\n }\n }\n }\n }\n\n & ol {\n padding: 0;\n margin: 0;\n\n & ul {\n padding-left: 15px;\n }\n\n & > li {\n position: relative;\n padding: 0;\n counter-increment: item;\n margin: 0;\n ${({ theme }) => styledText(theme)};\n\n &::before {\n content: counter(item) \".\";\n display: inline-block;\n margin: 0 4px 0 0;\n font-weight: 700;\n color: ${({ theme }) => theme.colors.primary};\n min-width: max-content;\n }\n }\n }\n`;\n\nexport const styledTable = css<{ theme: Theme }>`\n & .table-wrapper {\n overflow-x: auto;\n width: 100%;\n }\n\n & table {\n margin: 0;\n padding: 0;\n border-collapse: collapse;\n width: 100%;\n text-align: left;\n\n & tr {\n margin: 0;\n padding: 0;\n }\n\n & th {\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding: 10px 10px 10px 0;\n ${({ theme }) => styledSmall(theme)};\n font-weight: 600;\n color: ${({ theme }) => theme.colors.dark};\n }\n\n & td {\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding: 10px 10px 10px 0;\n color: ${({ theme }) => theme.colors.grayDark};\n ${({ theme }) => styledSmall(theme)};\n }\n }\n`;\n\nexport const StyledSmallButton = styled.button<{ theme: Theme }>`\n ${interactiveStyles};\n background: ${({ theme }) => theme.colors.light};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n color: ${({ theme }) => theme.colors.primary};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n padding: 6px 8px;\n font-size: 12px;\n font-family: inherit;\n font-weight: 600;\n cursor: pointer;\n transition: all 0.2s ease;\n display: flex;\n align-items: center;\n gap: 6px;\n margin-right: -6px;\n\n & svg.lucide {\n color: inherit;\n }\n`;\n";
@@ -146,7 +146,7 @@ export const styledTable = css<{ theme: Theme }>\`
146
146
 
147
147
  export const StyledSmallButton = styled.button<{ theme: Theme }>\`
148
148
  \${interactiveStyles};
149
- background: transparent;
149
+ background: \${({ theme }) => theme.colors.light};
150
150
  border: solid 1px \${({ theme }) => theme.colors.grayLight};
151
151
  color: \${({ theme }) => theme.colors.primary};
152
152
  border-radius: \${({ theme }) => theme.spacing.radius.xs};
@@ -1 +1 @@
1
- export declare const themeToggleTemplate = "\"use client\";\nimport { Theme, resetButton } from \"cherry-styled-components\";\nimport styled, { css, useTheme } from \"styled-components\";\nimport { rgba } from \"polished\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { theme as themeLight, themeDark } from \"@/app/theme\";\nimport { useThemeOverride } from \"@/components/layout/ClientThemeProvider\";\n\nconst StyledThemeToggle = styled.button<{ theme: Theme; $hidden?: boolean }>`\n ${resetButton}\n width: 56px;\n height: 30px;\n border-radius: 30px;\n display: flex;\n position: relative;\n margin: auto 0;\n transform: scale(1);\n background: ${({ theme }) => theme.colors.light};\n\n &::after {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};\n transition: all 0.3s ease;\n z-index: 1;\n ${({ theme }) =>\n theme.isDark &&\n css`\n transform: translateX(27px);\n `}\n }\n\n ${({ $hidden }) =>\n $hidden &&\n css`\n display: none;\n `}\n\n & svg {\n width: 16px;\n height: 16px;\n object-fit: contain;\n margin: auto;\n transition: all 0.3s ease;\n position: relative;\n z-index: 2;\n }\n\n & .lucide-sun {\n transform: translateX(1px);\n }\n\n & svg[stroke] {\n stroke: ${({ theme }) => theme.colors.primary};\n }\n\n &:hover {\n transform: scale(1.05);\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n\n & svg[stroke] {\n stroke: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n }\n }\n\n &:active {\n transform: scale(0.97);\n }\n`;\n\nfunction ToggleTheme({ $hidden }: { $hidden?: boolean }) {\n const { setTheme } = useThemeOverride();\n const theme = useTheme() as Theme;\n\n return (\n <StyledThemeToggle\n onClick={async () => {\n const nextTheme = theme.isDark ? \"light\" : \"dark\";\n setTheme(nextTheme === \"light\" ? themeLight : themeDark);\n await fetch(\"/api/theme\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ theme: nextTheme }),\n }).catch((err) => console.error(\"Failed to persist theme:\", err));\n }}\n $hidden={$hidden}\n aria-label=\"Toggle Theme\"\n >\n <Icon name=\"Sun\" className=\"light\" />\n <Icon name=\"MoonStar\" className=\"dark\" />\n </StyledThemeToggle>\n );\n}\n\nfunction ToggleThemeLoading() {\n return (\n <StyledThemeToggle $hidden aria-label=\"Toggle Theme\">\n <Icon name=\"MoonStar\" className=\"dark\" />\n <Icon name=\"Sun\" className=\"light\" />\n </StyledThemeToggle>\n );\n}\n\nexport { ToggleTheme, ToggleThemeLoading };\n";
1
+ export declare const themeToggleTemplate = "\"use client\";\nimport { Theme, resetButton } from \"cherry-styled-components\";\nimport styled, { css, useTheme } from \"styled-components\";\nimport { rgba } from \"polished\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { theme as themeLight, themeDark } from \"@/app/theme\";\nimport { useThemeOverride } from \"@/components/layout/ClientThemeProvider\";\n\nconst StyledThemeToggle = styled.button<{ theme: Theme; $hidden?: boolean }>`\n ${resetButton}\n width: 59px;\n height: 32px;\n border-radius: 30px;\n display: flex;\n position: relative;\n margin: auto 0;\n transform: scale(1);\n background: ${({ theme }) => theme.colors.light};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n &::after {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};\n transition: all 0.3s ease;\n z-index: 1;\n ${({ theme }) =>\n theme.isDark &&\n css`\n transform: translateX(27px);\n `}\n }\n\n ${({ $hidden }) =>\n $hidden &&\n css`\n display: none;\n `}\n\n & svg {\n width: 16px;\n height: 16px;\n object-fit: contain;\n margin: auto;\n transition: all 0.3s ease;\n position: relative;\n z-index: 2;\n }\n\n & .lucide-sun {\n transform: translateX(1px);\n }\n\n & .lucide-moon-star {\n transform: translateX(-1px);\n }\n\n & svg[stroke] {\n stroke: ${({ theme }) => theme.colors.primary};\n }\n\n &:hover {\n transform: scale(1.05);\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n\n & svg[stroke] {\n stroke: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n }\n }\n\n &:active {\n transform: scale(0.97);\n }\n`;\n\nfunction ToggleTheme({ $hidden }: { $hidden?: boolean }) {\n const { setTheme } = useThemeOverride();\n const theme = useTheme() as Theme;\n\n return (\n <StyledThemeToggle\n onClick={async () => {\n const nextTheme = theme.isDark ? \"light\" : \"dark\";\n setTheme(nextTheme === \"light\" ? themeLight : themeDark);\n await fetch(\"/api/theme\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ theme: nextTheme }),\n }).catch((err) => console.error(\"Failed to persist theme:\", err));\n }}\n $hidden={$hidden}\n aria-label=\"Toggle Theme\"\n >\n <Icon name=\"Sun\" className=\"light\" />\n <Icon name=\"MoonStar\" className=\"dark\" />\n </StyledThemeToggle>\n );\n}\n\nfunction ToggleThemeLoading() {\n return (\n <StyledThemeToggle $hidden aria-label=\"Toggle Theme\">\n <Icon name=\"MoonStar\" className=\"dark\" />\n <Icon name=\"Sun\" className=\"light\" />\n </StyledThemeToggle>\n );\n}\n\nexport { ToggleTheme, ToggleThemeLoading };\n";
@@ -8,14 +8,15 @@ import { useThemeOverride } from "@/components/layout/ClientThemeProvider";
8
8
 
9
9
  const StyledThemeToggle = styled.button<{ theme: Theme; $hidden?: boolean }>\`
10
10
  \${resetButton}
11
- width: 56px;
12
- height: 30px;
11
+ width: 59px;
12
+ height: 32px;
13
13
  border-radius: 30px;
14
14
  display: flex;
15
15
  position: relative;
16
16
  margin: auto 0;
17
17
  transform: scale(1);
18
18
  background: \${({ theme }) => theme.colors.light};
19
+ border: solid 1px \${({ theme }) => theme.colors.grayLight};
19
20
 
20
21
  &::after {
21
22
  content: "";
@@ -55,6 +56,10 @@ const StyledThemeToggle = styled.button<{ theme: Theme; $hidden?: boolean }>\`
55
56
  transform: translateX(1px);
56
57
  }
57
58
 
59
+ & .lucide-moon-star {
60
+ transform: translateX(-1px);
61
+ }
62
+
58
63
  & svg[stroke] {
59
64
  stroke: \${({ theme }) => theme.colors.primary};
60
65
  }
@@ -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.79",
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": {