doccupine 0.0.84 → 0.0.86
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/templates/components/SearchDocs.d.ts +1 -1
- package/dist/templates/components/SearchDocs.js +0 -2
- package/dist/templates/components/SearchModalContent.d.ts +1 -1
- package/dist/templates/components/SearchModalContent.js +2 -18
- package/dist/templates/components/layout/Field.d.ts +1 -1
- package/dist/templates/components/layout/Field.js +1 -0
- package/dist/templates/mdx/footer-links.mdx.d.ts +1 -1
- package/dist/templates/mdx/footer-links.mdx.js +1 -1
- package/dist/templates/mdx/platform/external-links.mdx.d.ts +1 -1
- package/dist/templates/mdx/platform/external-links.mdx.js +6 -21
- package/dist/templates/package.js +9 -9
- package/dist/templates/services/llm/config.d.ts +1 -1
- package/dist/templates/services/llm/config.js +12 -0
- package/dist/templates/services/mcp/server.d.ts +1 -1
- package/dist/templates/services/mcp/server.js +5 -3
- package/package.json +2 -2
|
@@ -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 dynamic from \"next/dynamic\";\nimport styled from \"styled-components\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { interactiveStyles } from \"@/components/layout/SharedStyled\";\nimport type { PageItem, MergedResult } from \"@/components/SearchModalContent\";\n\nconst SearchModalContent = dynamic(\n () =>\n import(\"@/components/SearchModalContent\").then(\n (mod) => mod.SearchModalContent,\n ),\n { ssr: false },\n);\n\ninterface SectionItem {\n label: string;\n slug: string;\n}\n\ninterface ContentHit {\n slug: string;\n snippet: string;\n}\n\ninterface SearchContextValue {\n openSearch: () => void;\n}\n\nconst SearchContext = createContext<SearchContextValue>({\n openSearch: () => {},\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 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
|
|
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 dynamic from \"next/dynamic\";\nimport styled from \"styled-components\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { interactiveStyles } from \"@/components/layout/SharedStyled\";\nimport type { PageItem, MergedResult } from \"@/components/SearchModalContent\";\n\nconst SearchModalContent = dynamic(\n () =>\n import(\"@/components/SearchModalContent\").then(\n (mod) => mod.SearchModalContent,\n ),\n { ssr: false },\n);\n\ninterface SectionItem {\n label: string;\n slug: string;\n}\n\ninterface ContentHit {\n slug: string;\n snippet: string;\n}\n\ninterface SearchContextValue {\n openSearch: () => void;\n}\n\nconst SearchContext = createContext<SearchContextValue>({\n openSearch: () => {},\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 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 resultsRef = useRef<HTMLUListElement>(null);\n const closingRef = useRef(false);\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 closingRef.current = false;\n setIsClosing(false);\n setIsVisible(true);\n }, []);\n\n const closeSearch = useCallback(() => {\n closingRef.current = true;\n setIsClosing(true);\n if (abortRef.current) abortRef.current.abort();\n if (debounceRef.current) clearTimeout(debounceRef.current);\n }, []);\n\n const handleCloseAnimationEnd = useCallback(() => {\n if (!closingRef.current) return;\n closingRef.current = false;\n setIsVisible(false);\n setIsClosing(false);\n setQuery(\"\");\n setActiveIndex(0);\n setContentResults([]);\n setIsSearching(false);\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 // 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 <SearchModalContent\n isClosing={isClosing}\n closeSearch={closeSearch}\n onCloseAnimationEnd={handleCloseAnimationEnd}\n query={query}\n setQuery={setQuery}\n activeIndex={activeIndex}\n setActiveIndex={setActiveIndex}\n resultsRef={resultsRef}\n onKeyDown={handleKeyDown}\n merged={merged}\n sectionLabels={sectionLabels}\n isSearching={isSearching}\n navigate={navigate}\n />\n )}\n </SearchContext.Provider>\n );\n}\n\nexport {\n SearchProvider,\n SearchContext,\n StyledKbd as SearchKbd,\n StyledSearchButton,\n};\n";
|
|
@@ -93,7 +93,6 @@ function SearchProvider({
|
|
|
93
93
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
94
94
|
const [contentResults, setContentResults] = useState<ContentHit[]>([]);
|
|
95
95
|
const [isSearching, setIsSearching] = useState(false);
|
|
96
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
97
96
|
const resultsRef = useRef<HTMLUListElement>(null);
|
|
98
97
|
const closingRef = useRef(false);
|
|
99
98
|
const abortRef = useRef<AbortController | null>(null);
|
|
@@ -274,7 +273,6 @@ function SearchProvider({
|
|
|
274
273
|
setQuery={setQuery}
|
|
275
274
|
activeIndex={activeIndex}
|
|
276
275
|
setActiveIndex={setActiveIndex}
|
|
277
|
-
inputRef={inputRef}
|
|
278
276
|
resultsRef={resultsRef}
|
|
279
277
|
onKeyDown={handleKeyDown}
|
|
280
278
|
merged={merged}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const searchModalContentTemplate = "\"use client\";\nimport React
|
|
1
|
+
export declare const searchModalContentTemplate = "\"use client\";\nimport React from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { rgba } from \"polished\";\nimport { Search } from \"lucide-react\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { Spinner } from \"@/components/Spinner\";\n\nexport interface PageItem {\n slug: string;\n title: string;\n description?: string;\n category: string;\n section?: string;\n}\n\nexport interface MergedResult {\n page: PageItem;\n snippet?: string;\n}\n\nexport interface SearchModalContentProps {\n isClosing: boolean;\n closeSearch: () => void;\n onCloseAnimationEnd: () => void;\n query: string;\n setQuery: (q: string) => void;\n activeIndex: number;\n setActiveIndex: (i: number | ((prev: number) => number)) => void;\n resultsRef: React.RefObject<HTMLUListElement | null>;\n onKeyDown: (e: React.KeyboardEvent) => void;\n merged: MergedResult[];\n sectionLabels: Record<string, string>;\n isSearching: boolean;\n navigate: (slug: string) => void;\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\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\nexport function SearchModalContent({\n isClosing,\n closeSearch,\n onCloseAnimationEnd,\n query,\n setQuery,\n activeIndex,\n setActiveIndex,\n resultsRef,\n onKeyDown,\n merged,\n sectionLabels,\n isSearching,\n navigate,\n}: SearchModalContentProps) {\n return (\n <StyledBackdrop\n $isClosing={isClosing}\n onClick={closeSearch}\n onAnimationEnd={onCloseAnimationEnd}\n >\n <StyledModal $isClosing={isClosing} onClick={(e) => e.stopPropagation()}>\n <StyledInputWrapper>\n <Search size={18} />\n <StyledInput\n autoFocus\n value={query}\n onChange={(e) => {\n setQuery(e.target.value);\n setActiveIndex(0);\n }}\n onKeyDown={onKeyDown}\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}\n";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const searchModalContentTemplate = `"use client";
|
|
2
|
-
import React
|
|
2
|
+
import React from "react";
|
|
3
3
|
import styled, { css, keyframes } from "styled-components";
|
|
4
4
|
import { rgba } from "polished";
|
|
5
5
|
import { Search } from "lucide-react";
|
|
@@ -27,7 +27,6 @@ export interface SearchModalContentProps {
|
|
|
27
27
|
setQuery: (q: string) => void;
|
|
28
28
|
activeIndex: number;
|
|
29
29
|
setActiveIndex: (i: number | ((prev: number) => number)) => void;
|
|
30
|
-
inputRef: React.RefObject<HTMLInputElement | null>;
|
|
31
30
|
resultsRef: React.RefObject<HTMLUListElement | null>;
|
|
32
31
|
onKeyDown: (e: React.KeyboardEvent) => void;
|
|
33
32
|
merged: MergedResult[];
|
|
@@ -243,7 +242,6 @@ export function SearchModalContent({
|
|
|
243
242
|
setQuery,
|
|
244
243
|
activeIndex,
|
|
245
244
|
setActiveIndex,
|
|
246
|
-
inputRef,
|
|
247
245
|
resultsRef,
|
|
248
246
|
onKeyDown,
|
|
249
247
|
merged,
|
|
@@ -251,20 +249,6 @@ export function SearchModalContent({
|
|
|
251
249
|
isSearching,
|
|
252
250
|
navigate,
|
|
253
251
|
}: SearchModalContentProps) {
|
|
254
|
-
const setInputRef = useCallback(
|
|
255
|
-
(node: HTMLInputElement | null) => {
|
|
256
|
-
// Sync with parent ref
|
|
257
|
-
if (inputRef) {
|
|
258
|
-
(inputRef as React.RefObject<HTMLInputElement | null>).current = node;
|
|
259
|
-
}
|
|
260
|
-
// Auto-focus on mount
|
|
261
|
-
if (node && !isClosing) {
|
|
262
|
-
node.focus();
|
|
263
|
-
}
|
|
264
|
-
},
|
|
265
|
-
[inputRef, isClosing],
|
|
266
|
-
);
|
|
267
|
-
|
|
268
252
|
return (
|
|
269
253
|
<StyledBackdrop
|
|
270
254
|
$isClosing={isClosing}
|
|
@@ -275,7 +259,7 @@ export function SearchModalContent({
|
|
|
275
259
|
<StyledInputWrapper>
|
|
276
260
|
<Search size={18} />
|
|
277
261
|
<StyledInput
|
|
278
|
-
|
|
262
|
+
autoFocus
|
|
279
263
|
value={query}
|
|
280
264
|
onChange={(e) => {
|
|
281
265
|
setQuery(e.target.value);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const fieldTemplate = "\"use client\";\nimport styled from \"styled-components\";\nimport { styledSmall, Theme } from \"cherry-styled-components\";\nimport { rgba } from \"polished\";\n\nconst StyledField = styled.div<{ theme: Theme; $columns?: number }>`\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding: 0 0 20px 0;\n`;\n\nconst StyledFieldFlex = styled.div<{ theme: Theme }>`\n display: flex;\n flex-wrap: wrap;\n gap: 10px;\n ${({ theme }) => styledSmall(theme)};\n padding: 0 0 15px 0;\n`;\n\nconst StyledFieldValue = styled.span<{ theme: Theme }>`\n color: ${({ theme }) => theme.colors.primary};\n font-family: ${({ theme }) => theme.fonts.mono};\n font-weight: 600;\n`;\n\nconst StyledFieldType = styled.span<{ theme: Theme }>`\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};\n color: ${({ theme }) => theme.colors.dark};\n padding: 0 4px;\n font-family: ${({ theme }) => theme.fonts.mono};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n`;\n\nconst StyledFieldRequired = styled.span<{ theme: Theme }>`\n background: ${({ theme }) => rgba(theme.colors.error, 0.2)};\n color: ${({ theme }) => theme.colors.error};\n padding: 0 4px;\n font-family: ${({ theme }) => theme.fonts.mono};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n`;\n\ninterface FieldProps extends React.HTMLAttributes<HTMLDivElement> {\n children: React.ReactNode;\n value: string;\n type: string;\n required?: boolean;\n}\n\nfunction Field({ children, value, type, required }: FieldProps) {\n return (\n <StyledField>\n <StyledFieldFlex>\n <StyledFieldValue>{value}</StyledFieldValue>\n <StyledFieldType>{type}</StyledFieldType>\n {required && <StyledFieldRequired>required</StyledFieldRequired>}\n </StyledFieldFlex>\n {children}\n </StyledField>\n );\n}\n\nexport { Field };\n";
|
|
1
|
+
export declare const fieldTemplate = "\"use client\";\nimport styled from \"styled-components\";\nimport { styledSmall, Theme } from \"cherry-styled-components\";\nimport { rgba } from \"polished\";\n\nconst StyledField = styled.div<{ theme: Theme; $columns?: number }>`\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding: 0 0 20px 0;\n color: ${({ theme }) => theme.colors.grayDark};\n`;\n\nconst StyledFieldFlex = styled.div<{ theme: Theme }>`\n display: flex;\n flex-wrap: wrap;\n gap: 10px;\n ${({ theme }) => styledSmall(theme)};\n padding: 0 0 15px 0;\n`;\n\nconst StyledFieldValue = styled.span<{ theme: Theme }>`\n color: ${({ theme }) => theme.colors.primary};\n font-family: ${({ theme }) => theme.fonts.mono};\n font-weight: 600;\n`;\n\nconst StyledFieldType = styled.span<{ theme: Theme }>`\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};\n color: ${({ theme }) => theme.colors.dark};\n padding: 0 4px;\n font-family: ${({ theme }) => theme.fonts.mono};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n`;\n\nconst StyledFieldRequired = styled.span<{ theme: Theme }>`\n background: ${({ theme }) => rgba(theme.colors.error, 0.2)};\n color: ${({ theme }) => theme.colors.error};\n padding: 0 4px;\n font-family: ${({ theme }) => theme.fonts.mono};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n`;\n\ninterface FieldProps extends React.HTMLAttributes<HTMLDivElement> {\n children: React.ReactNode;\n value: string;\n type: string;\n required?: boolean;\n}\n\nfunction Field({ children, value, type, required }: FieldProps) {\n return (\n <StyledField>\n <StyledFieldFlex>\n <StyledFieldValue>{value}</StyledFieldValue>\n <StyledFieldType>{type}</StyledFieldType>\n {required && <StyledFieldRequired>required</StyledFieldRequired>}\n </StyledFieldFlex>\n {children}\n </StyledField>\n );\n}\n\nexport { Field };\n";
|
|
@@ -6,6 +6,7 @@ import { rgba } from "polished";
|
|
|
6
6
|
const StyledField = styled.div<{ theme: Theme; $columns?: number }>\`
|
|
7
7
|
border-bottom: solid 1px \${({ theme }) => theme.colors.grayLight};
|
|
8
8
|
padding: 0 0 20px 0;
|
|
9
|
+
color: \${({ theme }) => theme.colors.grayDark};
|
|
9
10
|
\`;
|
|
10
11
|
|
|
11
12
|
const StyledFieldFlex = styled.div<{ theme: Theme }>\`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const footerLinksMdxTemplate = "---\ntitle: \"Footer Links\"\ndescription: \"Add static links at the bottom of the documentation pages.\"\ndate: \"2026-02-19\"\ncategory: \"Configuration\"\ncategoryOrder: 3\norder: 4\n---\n# Footer Links\nAdd a row of static links at the bottom of your documentation pages, just above the footer. Links open in a new tab and are useful for pointing users to related resources, repositories, or external docs.\n\n## links.json\nPlace a `links.json` at your project root (the same folder where you execute `npx doccupine`). When present, Doccupine displays the links at the bottom of each page. You can add as many links as you need.\n\n### Example links.json\n\n```json\n[\n {\n \"title\": \"Back to Home\",\n \"url\": \"https://doccupine.com\",\n \"icon\": \"arrow-left\"\n },\n {\n \"title\": \"GitHub\",\n \"url\": \"https://github.com/doccupine\",\n \"icon\": \"
|
|
1
|
+
export declare const footerLinksMdxTemplate = "---\ntitle: \"Footer Links\"\ndescription: \"Add static links at the bottom of the documentation pages.\"\ndate: \"2026-02-19\"\ncategory: \"Configuration\"\ncategoryOrder: 3\norder: 4\n---\n# Footer Links\nAdd a row of static links at the bottom of your documentation pages, just above the footer. Links open in a new tab and are useful for pointing users to related resources, repositories, or external docs.\n\n## links.json\nPlace a `links.json` at your project root (the same folder where you execute `npx doccupine`). When present, Doccupine displays the links at the bottom of each page. You can add as many links as you need.\n\n### Example links.json\n\n```json\n[\n {\n \"title\": \"Back to Home\",\n \"url\": \"https://doccupine.com\",\n \"icon\": \"arrow-left\"\n },\n {\n \"title\": \"GitHub\",\n \"url\": \"https://github.com/doccupine\",\n \"icon\": \"git-branch\"\n },\n {\n \"title\": \"Discord\",\n \"url\": \"https://discord.gg/E9BufYGPhG\",\n \"icon\": \"message-circle\"\n }\n]\n```\n\n### Fields\n- **title**: The label shown for the link.\n- **url**: The destination URL. Links open in a new tab with `target=\"_blank\"` and `rel=\"noopener noreferrer\"`.\n- **icon**: The icon to display next to the label. Icons are from [Lucide](https://lucide.dev/).\n\n## Behavior\n- **Empty or missing file**: If `links.json` is empty or not present, the footer links are hidden.\n- **Order**: Links appear in the same order as in the array.\n- **No limit**: Add as many links as you want; they wrap automatically on smaller screens.";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const platformExternalLinksMdxTemplate = "---\ntitle: \"External Links\"\ndescription: \"Add quick-access link buttons to your site's footer for GitHub, Discord, and other external resources.\"\ndate: \"2026-02-19\"\ncategory: \"Configuration\"\ncategoryOrder: 2\norder: 5\nsection: \"Platform\"\n---\n# External Links\nThe
|
|
1
|
+
export declare const platformExternalLinksMdxTemplate = "---\ntitle: \"External Links\"\ndescription: \"Add quick-access link buttons to your site's footer for GitHub, Discord, and other external resources.\"\ndate: \"2026-02-19\"\ncategory: \"Configuration\"\ncategoryOrder: 2\norder: 5\nsection: \"Platform\"\n---\n# External Links\nThe External Links settings page lets you add external link buttons to your documentation site's footer. These provide quick access to your project's GitHub repository, Discord server, social profiles, and other resources.\n\n## Adding a link\nClick **Add Link** and configure:\n\n- **Icon** (optional) - search and pick from any [Lucide](https://lucide.dev/) icon\n- **Title** - the display text for the link\n- **URL** - the target URL\n\n## Choosing an icon\nThe icon picker lets you search through the full [Lucide](https://lucide.dev/) icon set. Type a keyword to filter (e.g. \"git-branch\", \"mail\", \"globe\") and click to select.\n\nLeave the icon unset for a text-only link.\n\nIf you edit `links.json` directly, use Lucide icon names in kebab-case (e.g. `git-branch`, `message-circle`, `heart`).\n\n## How it works\nLink settings are stored in `links.json` at the root of your repository:\n\n```json\n[\n {\n \"title\": \"GitHub\",\n \"url\": \"https://github.com/your-org/your-repo\",\n \"icon\": \"git-branch\"\n },\n {\n \"title\": \"Discord\",\n \"url\": \"https://discord.gg/your-invite\",\n \"icon\": \"discord\"\n }\n]\n```";
|
|
@@ -8,36 +8,21 @@ order: 5
|
|
|
8
8
|
section: "Platform"
|
|
9
9
|
---
|
|
10
10
|
# External Links
|
|
11
|
-
The
|
|
11
|
+
The External Links settings page lets you add external link buttons to your documentation site's footer. These provide quick access to your project's GitHub repository, Discord server, social profiles, and other resources.
|
|
12
12
|
|
|
13
13
|
## Adding a link
|
|
14
14
|
Click **Add Link** and configure:
|
|
15
15
|
|
|
16
|
+
- **Icon** (optional) - search and pick from any [Lucide](https://lucide.dev/) icon
|
|
16
17
|
- **Title** - the display text for the link
|
|
17
18
|
- **URL** - the target URL
|
|
18
|
-
- **Icon** (optional) - choose from one of the preset icons
|
|
19
19
|
|
|
20
|
-
##
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
- GitHub
|
|
24
|
-
- Twitter / X
|
|
25
|
-
- Discord
|
|
26
|
-
- Slack
|
|
27
|
-
- LinkedIn
|
|
28
|
-
- YouTube
|
|
29
|
-
- Website (globe)
|
|
30
|
-
- Email
|
|
31
|
-
- Docs (book)
|
|
32
|
-
- Code
|
|
33
|
-
- Package
|
|
34
|
-
- RSS
|
|
35
|
-
- Chat
|
|
36
|
-
- Sponsor (heart)
|
|
20
|
+
## Choosing an icon
|
|
21
|
+
The icon picker lets you search through the full [Lucide](https://lucide.dev/) icon set. Type a keyword to filter (e.g. "git-branch", "mail", "globe") and click to select.
|
|
37
22
|
|
|
38
23
|
Leave the icon unset for a text-only link.
|
|
39
24
|
|
|
40
|
-
If you edit \`links.json\` directly, use
|
|
25
|
+
If you edit \`links.json\` directly, use Lucide icon names in kebab-case (e.g. \`git-branch\`, \`message-circle\`, \`heart\`).
|
|
41
26
|
|
|
42
27
|
## How it works
|
|
43
28
|
Link settings are stored in \`links.json\` at the root of your repository:
|
|
@@ -47,7 +32,7 @@ Link settings are stored in \`links.json\` at the root of your repository:
|
|
|
47
32
|
{
|
|
48
33
|
"title": "GitHub",
|
|
49
34
|
"url": "https://github.com/your-org/your-repo",
|
|
50
|
-
"icon": "
|
|
35
|
+
"icon": "git-branch"
|
|
51
36
|
},
|
|
52
37
|
{
|
|
53
38
|
"title": "Discord",
|
|
@@ -10,21 +10,21 @@ 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.26",
|
|
14
|
+
"@langchain/core": "^1.1.38",
|
|
15
15
|
"@langchain/google-genai": "^2.1.26",
|
|
16
|
-
"@langchain/openai": "^1.
|
|
16
|
+
"@langchain/openai": "^1.4.1",
|
|
17
17
|
"@mdx-js/react": "^3.1.1",
|
|
18
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
18
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
19
19
|
"@posthog/react": "^1.8.2",
|
|
20
20
|
"cherry-styled-components": "^0.1.16",
|
|
21
|
-
langchain: "^1.2.
|
|
21
|
+
langchain: "^1.2.39",
|
|
22
22
|
"lucide-react": "^1.7.0",
|
|
23
23
|
minisearch: "^7.2.0",
|
|
24
|
-
next: "16.2.
|
|
24
|
+
next: "16.2.2",
|
|
25
25
|
"next-mdx-remote": "^6.0.0",
|
|
26
26
|
polished: "^4.3.1",
|
|
27
|
-
"posthog-js": "^1.364.
|
|
27
|
+
"posthog-js": "^1.364.4",
|
|
28
28
|
"posthog-node": "^5.28.9",
|
|
29
29
|
react: "19.2.4",
|
|
30
30
|
"react-dom": "19.2.4",
|
|
@@ -40,9 +40,9 @@ export const packageJsonTemplate = JSON.stringify({
|
|
|
40
40
|
"@types/node": "^25",
|
|
41
41
|
"@types/react": "^19",
|
|
42
42
|
"@types/react-dom": "^19",
|
|
43
|
-
"baseline-browser-mapping": "^2.10.
|
|
43
|
+
"baseline-browser-mapping": "^2.10.13",
|
|
44
44
|
eslint: "^9",
|
|
45
|
-
"eslint-config-next": "16.2.
|
|
45
|
+
"eslint-config-next": "16.2.2",
|
|
46
46
|
prettier: "^3.8.1",
|
|
47
47
|
typescript: "^6",
|
|
48
48
|
},
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const llmConfigTemplate = "import type {\n LLMConfig,\n LLMProvider,\n ProviderDefaults,\n} from \"@/services/llm/types\";\nconst PROVIDER_DEFAULTS: ProviderDefaults = {\n openai: {\n chat: \"gpt-4.1-nano\",\n embedding: \"text-embedding-3-small\",\n },\n anthropic: {\n chat: \"claude-sonnet-4-5-20250929\",\n embedding: \"text-embedding-3-small\", // Fallback to OpenAI\n },\n google: {\n chat: \"gemini-2.5-flash-lite\",\n embedding: \"gemini-embedding-001\",\n },\n};\nfunction validateAPIKeys(provider: LLMProvider): void {\n const requiredKeys: Record<LLMProvider, string> = {\n openai: \"OPENAI_API_KEY\",\n anthropic: \"ANTHROPIC_API_KEY\",\n google: \"GOOGLE_API_KEY\",\n };\n const keyName = requiredKeys[provider];\n const keyValue = process.env[keyName];\n if (!keyValue) {\n throw new Error(\n `Missing API key for ${provider}. Please set ${keyName} in your environment variables.`,\n );\n }\n if (provider === \"anthropic\" && !process.env.OPENAI_API_KEY) {\n throw new Error(\n \"Anthropic provider requires OPENAI_API_KEY for embeddings. Please set OPENAI_API_KEY in your environment variables.\",\n );\n }\n}\nexport function getLLMConfig(): LLMConfig {\n const provider = (process.env.LLM_PROVIDER || \"openai\") as LLMProvider;\n if (![\"openai\", \"anthropic\", \"google\"].includes(provider)) {\n throw new Error(\n `Invalid LLM_PROVIDER: ${provider}. Must be one of: openai, anthropic, google`,\n );\n }\n validateAPIKeys(provider);\n const defaults = PROVIDER_DEFAULTS[provider];\n return {\n provider,\n chatModel: process.env.LLM_CHAT_MODEL || defaults.chat,\n embeddingModel: process.env.LLM_EMBEDDING_MODEL || defaults.embedding,\n temperature: parseFloat(process.env.LLM_TEMPERATURE || \"0\"),\n };\n}\n";
|
|
1
|
+
export declare const llmConfigTemplate = "import type {\n LLMConfig,\n LLMProvider,\n ProviderDefaults,\n} from \"@/services/llm/types\";\nconst PROVIDER_DEFAULTS: ProviderDefaults = {\n openai: {\n chat: \"gpt-4.1-nano\",\n embedding: \"text-embedding-3-small\",\n },\n anthropic: {\n chat: \"claude-sonnet-4-5-20250929\",\n embedding: \"text-embedding-3-small\", // Fallback to OpenAI\n },\n google: {\n chat: \"gemini-2.5-flash-lite\",\n embedding: \"gemini-embedding-001\",\n },\n};\nfunction validateAPIKeys(provider: LLMProvider): void {\n const requiredKeys: Record<LLMProvider, string> = {\n openai: \"OPENAI_API_KEY\",\n anthropic: \"ANTHROPIC_API_KEY\",\n google: \"GOOGLE_API_KEY\",\n };\n const keyName = requiredKeys[provider];\n const keyValue = process.env[keyName];\n if (!keyValue) {\n throw new Error(\n `Missing API key for ${provider}. Please set ${keyName} in your environment variables.`,\n );\n }\n if (provider === \"anthropic\" && !process.env.OPENAI_API_KEY) {\n throw new Error(\n \"Anthropic provider requires OPENAI_API_KEY for embeddings. Please set OPENAI_API_KEY in your environment variables.\",\n );\n }\n}\nexport function isLLMAvailable(): boolean {\n const provider = (process.env.LLM_PROVIDER || \"openai\") as LLMProvider;\n const requiredKeys: Record<LLMProvider, string> = {\n openai: \"OPENAI_API_KEY\",\n anthropic: \"ANTHROPIC_API_KEY\",\n google: \"GOOGLE_API_KEY\",\n };\n const keyName = requiredKeys[provider];\n if (!keyName || !process.env[keyName]) return false;\n if (provider === \"anthropic\" && !process.env.OPENAI_API_KEY) return false;\n return true;\n}\nexport function getLLMConfig(): LLMConfig {\n const provider = (process.env.LLM_PROVIDER || \"openai\") as LLMProvider;\n if (![\"openai\", \"anthropic\", \"google\"].includes(provider)) {\n throw new Error(\n `Invalid LLM_PROVIDER: ${provider}. Must be one of: openai, anthropic, google`,\n );\n }\n validateAPIKeys(provider);\n const defaults = PROVIDER_DEFAULTS[provider];\n return {\n provider,\n chatModel: process.env.LLM_CHAT_MODEL || defaults.chat,\n embeddingModel: process.env.LLM_EMBEDDING_MODEL || defaults.embedding,\n temperature: parseFloat(process.env.LLM_TEMPERATURE || \"0\"),\n };\n}\n";
|
|
@@ -36,6 +36,18 @@ function validateAPIKeys(provider: LLMProvider): void {
|
|
|
36
36
|
);
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
export function isLLMAvailable(): boolean {
|
|
40
|
+
const provider = (process.env.LLM_PROVIDER || "openai") as LLMProvider;
|
|
41
|
+
const requiredKeys: Record<LLMProvider, string> = {
|
|
42
|
+
openai: "OPENAI_API_KEY",
|
|
43
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
44
|
+
google: "GOOGLE_API_KEY",
|
|
45
|
+
};
|
|
46
|
+
const keyName = requiredKeys[provider];
|
|
47
|
+
if (!keyName || !process.env[keyName]) return false;
|
|
48
|
+
if (provider === "anthropic" && !process.env.OPENAI_API_KEY) return false;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
39
51
|
export function getLLMConfig(): LLMConfig {
|
|
40
52
|
const provider = (process.env.LLM_PROVIDER || "openai") as LLMProvider;
|
|
41
53
|
if (!["openai", "anthropic", "google"].includes(provider)) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const mcpServerTemplate = "import { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\nimport {\n listDocs,\n getDoc,\n getAllDocsChunks,\n DOCS_TOOLS,\n} from \"@/services/mcp/tools\";\nimport { getLLMConfig, createEmbeddings } from \"@/services/llm\";\nimport type { DocsChunk } from \"@/services/mcp/types\";\n\n/**\n * In-memory cache for document embeddings.\n * Built once at server startup since docs are static.\n */\nlet docsIndex: {\n ready: boolean;\n building: boolean;\n chunks: (DocsChunk & { embedding: number[] })[];\n} = {\n ready: false,\n building: false,\n chunks: [],\n};\n\n/** Resolves when the initial index build completes */\nlet indexReady: Promise<void> | null = null;\n\n/**\n * Cosine similarity between two vectors\n */\nfunction cosineSim(a: number[], b: number[]): number {\n let dot = 0,\n na = 0,\n nb = 0;\n for (let i = 0; i < a.length; i++) {\n const x = a[i];\n const y = b[i];\n dot += x * y;\n na += x * x;\n nb += y * y;\n }\n if (na === 0 || nb === 0) return 0;\n return dot / (Math.sqrt(na) * Math.sqrt(nb));\n}\n\n/**\n * Build or rebuild the documentation index\n */\nasync function buildDocsIndex(force = false): Promise<void> {\n if (docsIndex.building) return;\n if (docsIndex.ready && !force) return;\n\n docsIndex.building = true;\n try {\n const chunks = await getAllDocsChunks();\n\n if (chunks.length === 0) {\n docsIndex.chunks = [];\n docsIndex.ready = true;\n return;\n }\n\n const config = getLLMConfig();\n const embeddings = createEmbeddings(config);\n\n // Process embeddings in small batches to avoid exceeding token limits\n const BATCH_SIZE = 10;\n const texts = chunks.map((c) => c.text);\n const vectors: number[][] = [];\n\n for (let i = 0; i < texts.length; i += BATCH_SIZE) {\n const batch = texts.slice(i, i + BATCH_SIZE);\n const batchVectors = await embeddings.embedDocuments(batch);\n vectors.push(...batchVectors);\n }\n\n docsIndex.chunks = chunks.map((c, i) => ({\n ...c,\n embedding: vectors[i],\n }));\n docsIndex.ready = true;\n } catch (error) {\n // Reset so the next call to ensureDocsIndex retries\n indexReady = null;\n throw error;\n } finally {\n docsIndex.building = false;\n }\n}\n\n/**\n * Ensure the docs index is ready.\n * On first call, triggers the build; subsequent calls wait for the same promise.\n */\nexport async function ensureDocsIndex(force = false): Promise<void> {\n if (force) {\n // Wait for any in-flight build before starting a forced rebuild\n if (docsIndex.building && indexReady) {\n await indexReady.catch(() => {});\n }\n docsIndex.ready = false;\n docsIndex.chunks = [];\n indexReady = buildDocsIndex(true);\n return indexReady;\n }\n if (!indexReady) {\n indexReady = buildDocsIndex();\n }\n return indexReady;\n}\n\n// Eagerly start building the index on server startup
|
|
1
|
+
export declare const mcpServerTemplate = "import { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\nimport {\n listDocs,\n getDoc,\n getAllDocsChunks,\n DOCS_TOOLS,\n} from \"@/services/mcp/tools\";\nimport { getLLMConfig, isLLMAvailable, createEmbeddings } from \"@/services/llm\";\nimport type { DocsChunk } from \"@/services/mcp/types\";\n\n/**\n * In-memory cache for document embeddings.\n * Built once at server startup since docs are static.\n */\nlet docsIndex: {\n ready: boolean;\n building: boolean;\n chunks: (DocsChunk & { embedding: number[] })[];\n} = {\n ready: false,\n building: false,\n chunks: [],\n};\n\n/** Resolves when the initial index build completes */\nlet indexReady: Promise<void> | null = null;\n\n/**\n * Cosine similarity between two vectors\n */\nfunction cosineSim(a: number[], b: number[]): number {\n let dot = 0,\n na = 0,\n nb = 0;\n for (let i = 0; i < a.length; i++) {\n const x = a[i];\n const y = b[i];\n dot += x * y;\n na += x * x;\n nb += y * y;\n }\n if (na === 0 || nb === 0) return 0;\n return dot / (Math.sqrt(na) * Math.sqrt(nb));\n}\n\n/**\n * Build or rebuild the documentation index\n */\nasync function buildDocsIndex(force = false): Promise<void> {\n if (docsIndex.building) return;\n if (docsIndex.ready && !force) return;\n\n docsIndex.building = true;\n try {\n const chunks = await getAllDocsChunks();\n\n if (chunks.length === 0) {\n docsIndex.chunks = [];\n docsIndex.ready = true;\n return;\n }\n\n const config = getLLMConfig();\n const embeddings = createEmbeddings(config);\n\n // Process embeddings in small batches to avoid exceeding token limits\n const BATCH_SIZE = 10;\n const texts = chunks.map((c) => c.text);\n const vectors: number[][] = [];\n\n for (let i = 0; i < texts.length; i += BATCH_SIZE) {\n const batch = texts.slice(i, i + BATCH_SIZE);\n const batchVectors = await embeddings.embedDocuments(batch);\n vectors.push(...batchVectors);\n }\n\n docsIndex.chunks = chunks.map((c, i) => ({\n ...c,\n embedding: vectors[i],\n }));\n docsIndex.ready = true;\n } catch (error) {\n // Reset so the next call to ensureDocsIndex retries\n indexReady = null;\n throw error;\n } finally {\n docsIndex.building = false;\n }\n}\n\n/**\n * Ensure the docs index is ready.\n * On first call, triggers the build; subsequent calls wait for the same promise.\n */\nexport async function ensureDocsIndex(force = false): Promise<void> {\n if (force) {\n // Wait for any in-flight build before starting a forced rebuild\n if (docsIndex.building && indexReady) {\n await indexReady.catch(() => {});\n }\n docsIndex.ready = false;\n docsIndex.chunks = [];\n indexReady = buildDocsIndex(true);\n return indexReady;\n }\n if (!indexReady) {\n indexReady = buildDocsIndex();\n }\n return indexReady;\n}\n\n// Eagerly start building the index on server startup if LLM is configured\nif (isLLMAvailable()) {\n indexReady = buildDocsIndex();\n}\n\n/** Cached embeddings instance for search queries */\nlet cachedEmbeddings: ReturnType<typeof createEmbeddings> | null = null;\n\nfunction getEmbeddings() {\n if (!cachedEmbeddings) {\n cachedEmbeddings = createEmbeddings(getLLMConfig());\n }\n return cachedEmbeddings;\n}\n\n/**\n * Search documents using semantic similarity\n */\nexport async function searchDocs(\n query: string,\n limit = 6,\n): Promise<{ chunk: DocsChunk; score: number }[]> {\n await ensureDocsIndex();\n\n const queryVector = await getEmbeddings().embedQuery(query);\n\n const scored = docsIndex.chunks\n .map((c) => ({\n chunk: { id: c.id, text: c.text, path: c.path, uri: c.uri },\n score: cosineSim(queryVector, c.embedding),\n }))\n .sort((a, b) => b.score - a.score)\n .slice(0, limit);\n\n return scored;\n}\n\n/**\n * Get the current index status\n */\nexport function getIndexStatus(): { ready: boolean; chunkCount: number } {\n return {\n ready: docsIndex.ready,\n chunkCount: docsIndex.chunks.length,\n };\n}\n\n/**\n * Create and configure the MCP server with documentation tools\n */\nexport function createMCPServer(): McpServer {\n const server = new McpServer({\n name: \"docs-server\",\n version: \"1.0.0\",\n });\n\n // Register the search_docs tool\n server.tool(\n \"search_docs\",\n DOCS_TOOLS[0].description,\n {\n query: z\n .string()\n .describe(\"The search query to find relevant documentation\"),\n limit: z\n .number()\n .optional()\n .describe(\"Maximum number of results to return (default: 6)\"),\n },\n async ({ query, limit }) => {\n const results = await searchDocs(query, limit ?? 6);\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(\n results.map(({ chunk, score }) => ({\n path: chunk.path,\n uri: chunk.uri,\n score: score.toFixed(3),\n text: chunk.text,\n })),\n null,\n 2,\n ),\n },\n ],\n };\n },\n );\n\n // Register the get_doc tool\n server.tool(\n \"get_doc\",\n DOCS_TOOLS[1].description,\n {\n path: z.string().describe(\"The file path to the documentation page\"),\n },\n async ({ path }) => {\n const doc = await getDoc({ path });\n if (!doc) {\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify({ error: \"Document not found\" }),\n },\n ],\n isError: true,\n };\n }\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(doc, null, 2),\n },\n ],\n };\n },\n );\n\n // Register the list_docs tool\n server.tool(\n \"list_docs\",\n DOCS_TOOLS[2].description,\n {\n directory: z\n .string()\n .optional()\n .describe(\"Optional directory to filter results\"),\n },\n async ({ directory }) => {\n const docs = await listDocs({ directory });\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(\n docs.map((d) => ({\n name: d.name,\n path: d.path,\n uri: d.uri,\n })),\n null,\n 2,\n ),\n },\n ],\n };\n },\n );\n\n // Register documentation as resources\n server.resource(\"docs://list\", \"docs://list\", async () => {\n const docs = await listDocs();\n return {\n contents: [\n {\n uri: \"docs://list\",\n text: JSON.stringify(\n docs.map((d) => ({ name: d.name, path: d.path, uri: d.uri })),\n null,\n 2,\n ),\n },\n ],\n };\n });\n\n return server;\n}\n";
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
getAllDocsChunks,
|
|
7
7
|
DOCS_TOOLS,
|
|
8
8
|
} from "@/services/mcp/tools";
|
|
9
|
-
import { getLLMConfig, createEmbeddings } from "@/services/llm";
|
|
9
|
+
import { getLLMConfig, isLLMAvailable, createEmbeddings } from "@/services/llm";
|
|
10
10
|
import type { DocsChunk } from "@/services/mcp/types";
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -110,8 +110,10 @@ export async function ensureDocsIndex(force = false): Promise<void> {
|
|
|
110
110
|
return indexReady;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
// Eagerly start building the index on server startup
|
|
114
|
-
|
|
113
|
+
// Eagerly start building the index on server startup if LLM is configured
|
|
114
|
+
if (isLLMAvailable()) {
|
|
115
|
+
indexReady = buildDocsIndex();
|
|
116
|
+
}
|
|
115
117
|
|
|
116
118
|
/** Cached embeddings instance for search queries */
|
|
117
119
|
let cachedEmbeddings: ReturnType<typeof createEmbeddings> | null = null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "doccupine",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.86",
|
|
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": {
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"commander": "^14.0.3",
|
|
40
40
|
"fs-extra": "^11.3.4",
|
|
41
41
|
"gray-matter": "^4.0.3",
|
|
42
|
-
"next": "^16.2.
|
|
42
|
+
"next": "^16.2.2",
|
|
43
43
|
"prompts": "^2.4.2",
|
|
44
44
|
"react": "^19.2.4",
|
|
45
45
|
"react-dom": "^19.2.4"
|