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.
- package/dist/lib/layout.js +1 -1
- package/dist/lib/structures.js +6 -0
- package/dist/templates/app/api/search/route.d.ts +1 -0
- package/dist/templates/app/api/search/route.js +28 -0
- package/dist/templates/components/SearchDocs.d.ts +1 -1
- package/dist/templates/components/SearchDocs.js +171 -18
- package/dist/templates/components/Spinner.d.ts +1 -0
- package/dist/templates/components/Spinner.js +29 -0
- package/dist/templates/package.js +6 -5
- package/dist/templates/services/search.d.ts +1 -0
- package/dist/templates/services/search.js +116 -0
- package/package.json +1 -1
package/dist/lib/layout.js
CHANGED
|
@@ -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>
|
package/dist/lib/structures.js
CHANGED
|
@@ -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, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\");\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.
|
|
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, "&")
|
|
254
|
+
.replace(/</g, "<")
|
|
255
|
+
.replace(/>/g, ">")
|
|
256
|
+
.replace(/"/g, """);
|
|
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
|
-
|
|
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 <
|
|
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 :
|
|
446
|
+
setActiveIndex((i) => (i > 0 ? i - 1 : merged.length - 1));
|
|
305
447
|
} else if (e.key === "Enter") {
|
|
306
448
|
e.preventDefault();
|
|
307
|
-
if (
|
|
308
|
-
navigate(
|
|
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>
|
|
480
|
+
<StyledKbd>Esc</StyledKbd>
|
|
339
481
|
</StyledInputWrapper>
|
|
340
|
-
{
|
|
482
|
+
{merged.length > 0 ? (
|
|
341
483
|
<StyledResults ref={resultsRef}>
|
|
342
|
-
{
|
|
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
|
-
{
|
|
352
|
-
|
|
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>
|
|
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.
|
|
14
|
-
"@langchain/core": "^1.1.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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": {
|