doccupine 0.0.122 → 0.0.123
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/structures.js +2 -0
- package/dist/templates/components/Chat.d.ts +1 -1
- package/dist/templates/components/Chat.js +32 -12
- package/dist/templates/components/SearchDocs.d.ts +1 -1
- package/dist/templates/components/SearchDocs.js +56 -9
- package/dist/templates/components/SearchModalContent.d.ts +1 -1
- package/dist/templates/components/SearchModalContent.js +177 -14
- package/dist/templates/components/SideBar.d.ts +1 -1
- package/dist/templates/components/SideBar.js +1 -1
- package/dist/templates/components/layout/SiteGate.d.ts +1 -1
- package/dist/templates/components/layout/SiteGate.js +1 -1
- package/dist/templates/mdx/platform/ai-assistant.mdx.d.ts +1 -1
- package/dist/templates/mdx/platform/ai-assistant.mdx.js +1 -7
- package/dist/templates/mdx/platform/mcp.mdx.d.ts +1 -0
- package/dist/templates/mdx/platform/mcp.mdx.js +53 -0
- package/dist/templates/package.js +3 -3
- package/package.json +1 -1
package/dist/lib/structures.js
CHANGED
|
@@ -113,6 +113,7 @@ import { platformFontsSettingsMdxTemplate } from "../templates/mdx/platform/font
|
|
|
113
113
|
import { platformExternalLinksMdxTemplate } from "../templates/mdx/platform/external-links.mdx.js";
|
|
114
114
|
import { platformAnalyticsMdxTemplate } from "../templates/mdx/platform/analytics.mdx.js";
|
|
115
115
|
import { platformAiAssistantMdxTemplate } from "../templates/mdx/platform/ai-assistant.mdx.js";
|
|
116
|
+
import { platformMcpMdxTemplate } from "../templates/mdx/platform/mcp.mdx.js";
|
|
116
117
|
import { platformCustomDomainsMdxTemplate } from "../templates/mdx/platform/custom-domains.mdx.js";
|
|
117
118
|
import { platformBuildAndDeployMdxTemplate } from "../templates/mdx/platform/build-and-deploy.mdx.js";
|
|
118
119
|
import { platformTeamMembersMdxTemplate } from "../templates/mdx/platform/team-members.mdx.js";
|
|
@@ -245,6 +246,7 @@ export const startingDocsStructure = {
|
|
|
245
246
|
"platform/external-links.mdx": platformExternalLinksMdxTemplate,
|
|
246
247
|
"platform/analytics.mdx": platformAnalyticsMdxTemplate,
|
|
247
248
|
"platform/ai-assistant.mdx": platformAiAssistantMdxTemplate,
|
|
249
|
+
"platform/mcp.mdx": platformMcpMdxTemplate,
|
|
248
250
|
"platform/custom-domains.mdx": platformCustomDomainsMdxTemplate,
|
|
249
251
|
"platform/build-and-deploy.mdx": platformBuildAndDeployMdxTemplate,
|
|
250
252
|
"platform/team-members.mdx": platformTeamMembersMdxTemplate,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const chatTemplate = "\"use client\";\nimport React, {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useRef,\n useState,\n} from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { Button, IconButton } from \"cherry-styled-components\";\nimport { ArrowUp, LoaderPinwheel, RotateCcw, Sparkles, X } from \"lucide-react\";\nimport remarkGfm from \"remark-gfm\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport { MDXRemote, MDXRemoteSerializeResult } from \"next-mdx-remote\";\nimport { serialize } from \"next-mdx-remote/serialize\";\nimport Link from \"next/link\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { useLockBodyScroll } from \"@/components/LockBodyScroll\";\nimport { useMDXComponents as getMDXComponents } from \"@/components/MDXComponents\";\nimport {\n styledAnchor,\n styledTable,\n stylesLists,\n StyledSmallButton,\n interactiveStyles,\n thinScrollbar,\n} from \"@/components/layout/SharedStyled\";\n\nconst mdxComponents = getMDXComponents({});\n\nconst styledText = css<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.text.xs};\n line-height: ${({ theme }) => theme.lineHeights.text.xs};\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: ${({ theme }) => theme.lineHeights.small.lg};\n }\n`;\n\nconst StyledChat = styled.div<{ theme: Theme; $isVisible: boolean }>`\n margin: 0;\n position: fixed;\n top: 0;\n right: 0;\n width: 100%;\n height: calc(100dvh - 90px);\n overflow-y: scroll;\n overflow-x: hidden;\n z-index: 1000;\n padding: 0 20px;\n transition: all 0.3s ease;\n transform: translateX(0);\n background: ${({ theme }) => theme.colors.light};\n -webkit-overflow-scrolling: touch;\n opacity: 1;\n\n &::-webkit-scrollbar {\n display: none;\n }\n\n ${({ $isVisible }) =>\n !$isVisible &&\n css`\n transform: translateX(100%);\n opacity: 0;\n `}\n\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n`;\n\nconst loadingAnimation = keyframes`\n 0% {\n transform: rotate(0deg);\n }\n 100% {\n transform: rotate(360deg);\n }\n`;\n\nconst rotateGradient = keyframes`\n 0% {\n --gradient-angle: 0deg;\n }\n 100% {\n --gradient-angle: 360deg;\n }\n`;\n\nconst pulseGlow = keyframes`\n 0%, 100% {\n opacity: 0.5;\n filter: blur(16px);\n }\n 50% {\n opacity: 1;\n filter: blur(22px);\n }\n`;\n\nconst sparkleFloat = keyframes`\n 0%, 100% {\n opacity: 0;\n transform: translateY(0) scale(0);\n }\n 50% {\n opacity: 0.9;\n transform: translateY(-20px) scale(1);\n }\n`;\n\nconst shimmer = keyframes`\n 0% {\n background-position: 0% center;\n }\n 50% {\n background-position: 100% center;\n }\n 100% {\n background-position: 0% center;\n }\n`;\n\nconst StyledRainbowInputWrapper = styled.div<{\n theme: Theme;\n $isActive: boolean;\n}>`\n @property --gradient-angle {\n syntax: \"<angle>\";\n initial-value: 0deg;\n inherits: false;\n }\n\n position: relative;\n flex: 1;\n\n &::before {\n content: \"\";\n position: absolute;\n inset: -2px;\n border-radius: 14px;\n background: conic-gradient(\n from var(--gradient-angle),\n #cc5555,\n #d9a745,\n #3ab0cc,\n #cc7fc2,\n #4380cc,\n #4c1fa3,\n #cc5555\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation: ${rotateGradient} 3s linear infinite;\n z-index: 0;\n }\n\n &::after {\n content: \"\";\n position: absolute;\n inset: -10px;\n border-radius: 20px;\n background: conic-gradient(\n from var(--gradient-angle),\n #ff6b6b66,\n #feca5766,\n #48dbfb66,\n #ff9ff366,\n #54a0ff66,\n #5f27cd66,\n #ff6b6b66\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation:\n ${rotateGradient} 3s linear infinite,\n ${pulseGlow} 2s ease-in-out infinite;\n z-index: -1;\n pointer-events: none;\n }\n\n &:hover::before,\n &:focus-within::before {\n opacity: 1;\n }\n\n &:hover::after,\n &:focus-within::after {\n opacity: 1;\n }\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n &::before {\n opacity: 1;\n }\n &::after {\n opacity: 1;\n }\n `}\n`;\n\nconst StyledSparkleContainer = styled.div<{ $isActive: boolean }>`\n position: absolute;\n inset: -30px;\n pointer-events: none;\n overflow: hidden;\n border-radius: 30px;\n z-index: -2;\n opacity: 0;\n transition: opacity 0.4s ease;\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n opacity: 1;\n `}\n`;\n\nconst StyledSparkle = styled.div<{\n $color: string;\n $left: number;\n $top: number;\n $delay: number;\n}>`\n position: absolute;\n width: 4px;\n height: 4px;\n border-radius: 50%;\n background: ${({ $color }) => $color};\n box-shadow: 0 0 6px ${({ $color }) => $color};\n left: ${({ $left }) => $left}%;\n top: ${({ $top }) => $top}%;\n animation: ${sparkleFloat} 2s ease-in-out infinite;\n animation-delay: ${({ $delay }) => $delay}s;\n`;\n\nconst StyledRainbowInput = styled.input<{ theme: Theme }>`\n position: relative;\n z-index: 1;\n width: 100%;\n background: ${({ theme }) => theme.colors.light};\n border: 1px solid ${({ theme }) => theme.colors.grayLight};\n border-radius: 12px;\n padding: 12px 18px;\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n font-family: inherit;\n color: ${({ theme }) => theme.colors.dark};\n outline: none;\n transition:\n border-color 0.3s ease,\n box-shadow 0.3s ease;\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n }\n\n &::placeholder {\n color: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.dark} 40%, transparent)`};\n transition: color 0.3s ease;\n }\n\n &:focus::placeholder {\n color: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.dark} 60%, transparent)`};\n }\n\n &:focus {\n border-color: transparent;\n }\n`;\n\nconst StyledRainbowButton = styled(Button)<{\n theme: Theme;\n $hasContent: boolean;\n}>`\n padding-top: 10px;\n padding-bottom: 10px;\n position: relative;\n overflow: hidden;\n transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;\n color: ${({ theme }) => theme.colors.surface};\n\n &::before {\n content: \"\";\n position: absolute;\n inset: 0;\n background: linear-gradient(\n 135deg,\n #ff6b6b,\n #feca57,\n #48dbfb,\n #ff9ff3,\n #54a0ff\n );\n background-size: 300% 300%;\n opacity: 0;\n transition: opacity 0.3s ease;\n z-index: 0;\n animation: ${shimmer} 3s linear infinite;\n width: 200%;\n }\n\n ${({ $hasContent }) =>\n $hasContent &&\n css`\n &::before {\n opacity: 1;\n }\n `}\n\n &:hover::before {\n opacity: 1;\n }\n\n &:hover {\n transform: scale(1.05);\n }\n\n &:active {\n transform: scale(0.95);\n }\n\n & svg {\n position: relative;\n z-index: 1;\n transition: transform 0.3s ease;\n }\n\n &:disabled,\n &:disabled:hover {\n background: ${({ theme }) => theme.colors.primaryDark};\n transform: none;\n box-shadow: none;\n\n &::before {\n opacity: 0;\n }\n }\n`;\n\nconst StyledChatForm = styled.form<{ theme: Theme; $isVisible: boolean }>`\n display: flex;\n gap: 10px;\n justify-content: center;\n align-items: center;\n background: ${({ theme }) => theme.colors.light};\n padding: 20px;\n position: fixed;\n bottom: 0;\n right: 0;\n z-index: 1000;\n width: 100%;\n border-top: solid 1px ${({ theme }) => theme.colors.grayLight};\n transition: all 0.3s ease;\n transform: translateX(100%);\n opacity: 0;\n\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n\n ${({ $isVisible }) =>\n $isVisible &&\n css`\n opacity: 1;\n transform: translateX(0);\n `}\n\n & .loading {\n animation: ${loadingAnimation} 1s linear infinite;\n }\n`;\n\nconst StyledGlowSmallButton = styled(StyledSmallButton)<{\n theme: Theme;\n $hasContent: boolean;\n}>`\n @property --gradient-angle {\n syntax: \"<angle>\";\n initial-value: 0deg;\n inherits: false;\n }\n\n position: relative;\n isolation: isolate;\n margin-right: 0;\n background: ${({ theme }) => theme.colors.light};\n padding: 0;\n\n &::before {\n content: \"\";\n inset: -2px;\n border-radius: 8px;\n background: conic-gradient(\n from var(--gradient-angle),\n #cc5555,\n #d9a745,\n #3ab0cc,\n #cc7fc2,\n #4380cc,\n #4c1fa3,\n #cc5555\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation: ${rotateGradient} 3s linear infinite;\n z-index: -1;\n position: absolute;\n top: -2px;\n left: -2px;\n width: calc(100% + 4px);\n height: calc(100% + 4px);\n }\n\n &::after {\n content: \"\";\n position: absolute;\n inset: -8px;\n border-radius: 14px;\n background: conic-gradient(\n from var(--gradient-angle),\n #ff6b6b66,\n #feca5766,\n #48dbfb66,\n #ff9ff366,\n #54a0ff66,\n #5f27cd66,\n #ff6b6b66\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation:\n ${rotateGradient} 3s linear infinite,\n ${pulseGlow} 2s ease-in-out infinite;\n z-index: -2;\n pointer-events: none;\n }\n\n &:hover::before,\n &:hover::after {\n opacity: 1;\n }\n\n & span {\n padding: 6px 8px;\n display: flex;\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n gap: 6px;\n }\n\n ${({ $hasContent }) =>\n $hasContent &&\n css`\n &::before {\n opacity: 1;\n }\n &::after {\n opacity: 1;\n }\n `}\n`;\n\nconst StyledError = styled.div<{ theme: Theme }>`\n overflow-x: auto;\n ${thinScrollbar};\n background: ${({ theme }) => theme.colors.error};\n color: ${({ theme }) => theme.colors.surface};\n padding: 10px;\n border-radius: 8px;\n margin: 20px 0;\n width: 100%;\n ${styledText};\n`;\n\nconst loadingDotAnimation = keyframes`\n 0% {\n opacity: 0;\n }\n 50% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n`;\n\nconst StyledLoading = styled.div<{ theme: Theme }>`\n overflow-x: auto;\n ${thinScrollbar};\n margin: 20px 0;\n width: 100%;\n font-weight: 600;\n ${styledText};\n color: ${({ theme }) => theme.colors.dark};\n\n & span {\n &:nth-child(1) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n }\n &:nth-child(2) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n animation-delay: 0.2s;\n }\n &:nth-child(3) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n animation-delay: 0.4s;\n }\n }\n`;\n\nconst StyledAnswer = styled.div<{ theme: Theme; $isAnswer: boolean }>`\n overflow-x: auto;\n ${thinScrollbar};\n background: ${({ theme }) => theme.colors.primary};\n color: ${({ theme }) => theme.colors.surface};\n padding: 10px 15px;\n border-radius: 18px;\n margin: 20px 0 20px auto;\n width: fit-content;\n ${styledText};\n\n & p {\n ${styledText};\n }\n\n ${({ $isAnswer }) =>\n $isAnswer &&\n css`\n background: transparent;\n color: ${({ theme }) => theme.colors.dark};\n padding: 0;\n width: 100%;\n max-width: 100%;\n margin: 20px 0;\n border-radius: 0;\n `}\n\n & code:not([class]) {\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 20%, transparent)`};\n color: ${({ theme }) => theme.colors.dark};\n padding: 2px 4px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n white-space: pre;\n }\n\n ${styledAnchor};\n ${stylesLists};\n ${styledTable};\n\n & pre,\n & .hljs {\n margin: 10px 0;\n }\n\n & .code-wrapper pre {\n margin: 0;\n ${styledText};\n }\n\n & > *:first-child {\n margin-top: 0;\n }\n\n & > *:last-child {\n margin-bottom: 0;\n\n & > *:last-child {\n margin-bottom: 0;\n }\n }\n\n & ul,\n & ol {\n & li {\n ${styledText};\n }\n }\n\n & img,\n & video,\n & iframe {\n max-width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n margin: 10px 0;\n display: block;\n }\n\n & h1,\n & h2,\n & h3,\n & h4,\n & h5,\n & h6 {\n margin: 10px 0;\n padding: 0;\n }\n`;\n\nconst StyledSources = styled.div`\n display: flex;\n gap: 16px;\n flex-wrap: wrap;\n margin: -5px 0 20px;\n`;\n\nconst StyledSourceLink = styled(Link)<{ theme: Theme }>`\n position: relative;\n text-decoration: none;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: 1;\n color: ${({ theme }) => theme.colors.primary};\n display: flex;\n gap: 6px;\n transition: all 0.3s ease;\n font-weight: 600;\n white-space: nowrap;\n min-width: fit-content;\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 10%, transparent)`};\n padding: 6px 8px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n ${interactiveStyles};\n\n & * {\n margin: auto 0;\n }\n\n &:hover {\n color: ${({ theme }) => theme.colors.accent};\n }\n`;\n\nconst StyledChatTitle = styled.div<{ theme: Theme }>`\n display: flex;\n flex-wrap: nowrap;\n justify-content: space-between;\n position: sticky;\n margin: 0 -20px;\n padding: 16px 20px;\n height: 62px;\n top: 0;\n background: ${({ theme }) => theme.colors.light};\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n z-index: 1000;\n`;\n\nconst StyledChatTitleIconWrapper = styled.span<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 12px;\n color: ${({ theme }) => theme.colors.dark};\n`;\n\ntype Source = {\n id: string;\n path: string;\n uri: string;\n score: number;\n};\n\ntype Answer = {\n text: string;\n answer?: boolean;\n mdx?: MDXRemoteSerializeResult;\n sources?: Source[];\n};\n\nconst SPARKLE_COLORS = [\n \"#ff6b6b\",\n \"#feca57\",\n \"#48dbfb\",\n \"#ff9ff3\",\n \"#54a0ff\",\n \"#5f27cd\",\n];\n\n// Deterministic sparkle positions to avoid hydration mismatch\nconst SPARKLE_POSITIONS = [\n { left: 8, top: 35 },\n { left: 17, top: 55 },\n { left: 26, top: 28 },\n { left: 35, top: 68 },\n { left: 44, top: 42 },\n { left: 53, top: 75 },\n { left: 62, top: 32 },\n { left: 71, top: 58 },\n { left: 80, top: 45 },\n { left: 89, top: 65 },\n];\n\ninterface RainbowInputProps {\n id?: string;\n value: string;\n onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;\n placeholder?: string;\n autoComplete?: string;\n \"aria-label\"?: string;\n inputRef?: React.Ref<HTMLInputElement>;\n}\n\nfunction RainbowInput({\n id,\n value,\n onChange,\n placeholder,\n autoComplete,\n \"aria-label\": ariaLabel,\n inputRef,\n}: RainbowInputProps) {\n const [isFocused, setIsFocused] = useState(false);\n const [isHovered, setIsHovered] = useState(false);\n const isActive = isFocused || isHovered;\n\n const sparkles = SPARKLE_POSITIONS.map((pos, i) => ({\n color: SPARKLE_COLORS[i % SPARKLE_COLORS.length],\n left: pos.left,\n top: pos.top,\n delay: i * 0.12,\n }));\n\n return (\n <StyledRainbowInputWrapper\n $isActive={isActive}\n onMouseEnter={() => setIsHovered(true)}\n onMouseLeave={() => setIsHovered(false)}\n >\n <StyledSparkleContainer $isActive={isActive}>\n {sparkles.map((sparkle, i) => (\n <StyledSparkle\n key={i}\n $color={sparkle.color}\n $left={sparkle.left}\n $top={sparkle.top}\n $delay={sparkle.delay}\n />\n ))}\n </StyledSparkleContainer>\n <StyledRainbowInput\n ref={inputRef}\n id={id}\n value={value}\n onChange={onChange}\n placeholder={placeholder}\n autoComplete={autoComplete}\n aria-label={ariaLabel}\n onFocus={() => setIsFocused(true)}\n onBlur={() => setIsFocused(false)}\n />\n </StyledRainbowInputWrapper>\n );\n}\n\nfunction ChatButtonCTA() {\n const { toggleChat, isOpen } = useContext(ChatContext);\n\n return (\n <StyledGlowSmallButton\n onClick={toggleChat}\n aria-label=\"Ask AI Assistant\"\n $hasContent={isOpen}\n type=\"button\"\n >\n <span>\n <Sparkles size={16} />\n Ask AI\n </span>\n </StyledGlowSmallButton>\n );\n}\n\nfunction Chat() {\n const {\n isOpen,\n question,\n setQuestion,\n loading,\n error,\n answer,\n ask,\n closeChat,\n resetChat,\n chatInputRef,\n } = useContext(ChatContext);\n const endRef = useRef<HTMLDivElement | null>(null);\n\n useLockBodyScroll(isOpen);\n\n useEffect(() => {\n endRef.current?.scrollIntoView({ behavior: \"smooth\", block: \"end\" });\n }, [answer]);\n\n useEffect(() => {\n if (answer?.length > 0) {\n chatInputRef.current?.focus();\n }\n }, [answer, chatInputRef]);\n\n return (\n <>\n <StyledChat $isVisible={isOpen}>\n <StyledChatTitle>\n <StyledChatTitleIconWrapper>\n <Sparkles />\n <h3>AI Assistant</h3>\n </StyledChatTitleIconWrapper>\n <StyledChatTitleIconWrapper>\n <IconButton\n onClick={resetChat}\n aria-label=\"Reset chat history\"\n title=\"Reset chat history\"\n >\n <RotateCcw />\n </IconButton>\n <IconButton\n onClick={closeChat}\n aria-label=\"Close chat\"\n title=\"Close chat\"\n >\n <X />\n </IconButton>\n </StyledChatTitleIconWrapper>\n </StyledChatTitle>\n {answer &&\n answer.map((a, i) => (\n <React.Fragment key={i}>\n <StyledAnswer $isAnswer={a.answer ?? false}>\n {a.answer && a.mdx ? (\n <MDXRemote {...a.mdx} components={mdxComponents} />\n ) : (\n a.text\n )}\n </StyledAnswer>\n {a.answer && a.sources && a.sources.length > 0 && (\n <StyledSources>\n {a.sources.map((src) => {\n const slug = src.uri\n .replace(\"docs://\", \"\")\n .replace(/^\\/+/, \"\");\n const href = slug ? `/${slug}/` : \"/\";\n const label = slug\n ? slug\n .split(\"/\")\n .pop()!\n .replace(/-/g, \" \")\n .replace(/\\b\\w/g, (c: string) => c.toUpperCase())\n : \"Home\";\n return (\n <StyledSourceLink\n key={src.id}\n href={href}\n onClick={() => {\n if (window.innerWidth <= 992) {\n closeChat();\n }\n }}\n >\n {label}\n </StyledSourceLink>\n );\n })}\n </StyledSources>\n )}\n </React.Fragment>\n ))}\n {loading && !answer[answer.length - 1]?.answer && (\n <StyledLoading>\n Answering<span>.</span>\n <span>.</span>\n <span>.</span>\n </StyledLoading>\n )}\n {error && (\n <StyledError>\n <strong>Error:</strong> {error}\n </StyledError>\n )}\n <div ref={endRef} />\n </StyledChat>\n\n <StyledChatForm onSubmit={ask} $isVisible={isOpen}>\n <RainbowInput\n id=\"chat-bottom-input\"\n inputRef={chatInputRef}\n value={question}\n onChange={(e) => setQuestion(e.target.value)}\n placeholder=\"Ask AI Assistant...\"\n autoComplete=\"off\"\n aria-label=\"Ask a follow-up question\"\n />\n <StyledRainbowButton\n type=\"submit\"\n disabled={loading || question.trim() === \"\"}\n $hasContent={question.trim().length > 0}\n aria-label={loading ? \"Loading response\" : \"Submit question\"}\n >\n {loading ? <LoaderPinwheel className=\"loading\" /> : <ArrowUp />}\n </StyledRainbowButton>\n </StyledChatForm>\n </>\n );\n}\n\nconst ChatContext = createContext<{\n isOpen: boolean;\n setIsOpen: (isOpen: boolean) => void;\n toggleChat: () => void;\n isChatActive: boolean;\n question: string;\n setQuestion: (q: string) => void;\n loading: boolean;\n error: string | null;\n answer: Answer[];\n setAnswer: (answers: Answer[]) => void;\n ask: (e: React.FormEvent) => void;\n closeChat: () => void;\n resetChat: () => void;\n chatInputRef: React.RefObject<HTMLInputElement | null>;\n}>({\n isOpen: false,\n setIsOpen: () => {},\n toggleChat: () => {},\n isChatActive: false,\n question: \"\",\n setQuestion: () => {},\n loading: false,\n error: null,\n answer: [],\n setAnswer: () => {},\n ask: () => {},\n closeChat: () => {},\n resetChat: () => {},\n chatInputRef: { current: null },\n});\n\ninterface ChatContextProviderProps {\n children: React.ReactNode;\n isChatActive: boolean;\n}\n\nconst ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {\n const [isOpen, setIsOpen] = useState(false);\n const [question, setQuestion] = useState(\"\");\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const [answer, setAnswer] = useState<Answer[]>([]);\n const abortRef = useRef<AbortController | null>(null);\n const chatInputRef = useRef<HTMLInputElement | null>(null);\n const isOpenRef = useRef(isOpen);\n\n useEffect(() => {\n isOpenRef.current = isOpen;\n }, [isOpen]);\n\n // Open the assistant, seeding a greeting the first time and focusing the\n // input once the panel has slid in (matches the 0.3s panel transition).\n const openChat = useCallback(() => {\n setIsOpen(true);\n setAnswer((prev) =>\n prev.length === 0\n ? [{ text: \"Hey there, how can I assist you?\", answer: true }]\n : prev,\n );\n setTimeout(() => {\n chatInputRef.current?.focus();\n }, 350);\n }, []);\n\n const toggleChat = useCallback(() => {\n if (isOpenRef.current) {\n setIsOpen(false);\n } else {\n openChat();\n }\n }, [openChat]);\n\n // Global Cmd/Ctrl+I toggles the assistant, but only when chat is enabled.\n useEffect(() => {\n if (!isChatActive) return;\n function handleKeyDown(e: KeyboardEvent) {\n if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === \"i\") {\n e.preventDefault();\n toggleChat();\n }\n }\n document.addEventListener(\"keydown\", handleKeyDown);\n return () => document.removeEventListener(\"keydown\", handleKeyDown);\n }, [isChatActive, toggleChat]);\n\n async function ask(e: React.FormEvent) {\n e.preventDefault();\n if (loading || question.trim() === \"\") return;\n const currentQuestion = question;\n setQuestion(\"\");\n setIsOpen(true);\n setLoading(true);\n setError(null);\n\n const mergedQuestions =\n answer.length > 0\n ? [...answer, { text: currentQuestion, answer: false }]\n : [{ text: currentQuestion, answer: false }];\n\n setAnswer(mergedQuestions);\n\n const controller = new AbortController();\n abortRef.current = controller;\n\n try {\n const history = answer\n .filter((a) => a.text.trim() !== \"\")\n .map((a) => ({\n role: a.answer ? (\"assistant\" as const) : (\"user\" as const),\n content: a.text,\n }));\n\n const res = await fetch(\"/api/rag\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ question: currentQuestion, history }),\n signal: controller.signal,\n });\n\n if (!res.ok) {\n // The body may be a proxy/gateway HTML error page (e.g. Cloudflare\n // 524), not our JSON - never blindly JSON.parse it.\n const contentType = res.headers.get(\"content-type\") || \"\";\n let message = \"Request failed\";\n if (contentType.includes(\"application/json\")) {\n try {\n const errorData = await res.json();\n message = errorData.error || message;\n } catch {\n // Non-JSON despite the header - fall through to the default.\n }\n } else if ([502, 503, 504, 524].includes(res.status)) {\n message = \"The assistant took too long to respond. Please try again.\";\n }\n throw new Error(message);\n }\n\n const reader = res.body?.getReader();\n const decoder = new TextDecoder();\n const contentParts: string[] = [];\n let sources: Source[] = [];\n if (!reader) {\n throw new Error(\"Failed to get response reader\");\n }\n\n // Don't insert the answer bubble yet - an empty bubble renders a blank\n // gap above the \"Answering...\" loader. The bubble is created on the first\n // streamed token (or the done event) instead, so the loader stays put\n // until real text appears.\n const streamingAnswerIndex = mergedQuestions.length;\n\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n\n buffer += decoder.decode(value, { stream: true });\n const parts = buffer.split(\"\\n\");\n buffer = parts.pop() ?? \"\";\n\n for (const line of parts) {\n if (line.startsWith(\"data: \")) {\n try {\n const data = JSON.parse(line.slice(6));\n\n if (data.type === \"metadata\") {\n const allSources: Source[] = data.data?.sources ?? [];\n const seen = new Set<string>();\n sources = allSources.filter((s: Source) => {\n if (s.score < 0.4 || seen.has(s.uri)) return false;\n seen.add(s.uri);\n return true;\n });\n } else if (data.type === \"content\") {\n contentParts.push(data.data);\n const streamedContent = contentParts.join(\"\");\n\n setAnswer((prev) => {\n const newAnswers = [...prev];\n newAnswers[streamingAnswerIndex] = {\n text: streamedContent,\n answer: true,\n sources,\n };\n return newAnswers;\n });\n } else if (data.type === \"error\") {\n throw new Error(data.data);\n } else if (data.type === \"done\") {\n const streamedContent = contentParts.join(\"\");\n let mdxSource: MDXRemoteSerializeResult | null = null;\n try {\n mdxSource = await serialize(streamedContent, {\n parseFrontmatter: false,\n mdxOptions: {\n remarkPlugins: [remarkGfm],\n rehypePlugins: [rehypeHighlight],\n format: \"md\",\n development: false,\n },\n });\n } catch (mdxError: unknown) {\n console.error(\"MDX serialization error:\", mdxError);\n }\n\n setAnswer((prev) => {\n const newAnswers = [...prev];\n newAnswers[streamingAnswerIndex] = {\n text: streamedContent,\n answer: true,\n mdx: mdxSource || undefined,\n sources,\n };\n return newAnswers;\n });\n }\n } catch (parseError) {\n if (\n parseError instanceof Error &&\n parseError.message !== \"Unknown error\"\n ) {\n console.error(\"Failed to parse SSE data:\", parseError);\n }\n }\n }\n }\n }\n } catch (err: unknown) {\n if (err instanceof DOMException && err.name === \"AbortError\") return;\n setError(err instanceof Error ? err.message : \"Unknown error\");\n } finally {\n abortRef.current = null;\n setLoading(false);\n }\n }\n\n function closeChat() {\n setIsOpen(false);\n }\n\n function resetChat() {\n abortRef.current?.abort();\n setLoading(false);\n setError(null);\n setAnswer([{ text: \"Hey there, how can I assist you?\", answer: true }]);\n }\n\n return (\n <ChatContext.Provider\n value={{\n isOpen,\n setIsOpen,\n toggleChat,\n isChatActive,\n question,\n setQuestion,\n loading,\n error,\n answer,\n setAnswer,\n ask,\n closeChat,\n resetChat,\n chatInputRef,\n }}\n >\n {children}\n </ChatContext.Provider>\n );\n};\n\nexport { Chat, ChtProvider, ChatContext, ChatButtonCTA };\n";
|
|
1
|
+
export declare const chatTemplate = "\"use client\";\nimport React, {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useRef,\n useState,\n} from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { Button, IconButton } from \"cherry-styled-components\";\nimport { ArrowUp, LoaderPinwheel, RotateCcw, Sparkles, X } from \"lucide-react\";\nimport remarkGfm from \"remark-gfm\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport { MDXRemote, MDXRemoteSerializeResult } from \"next-mdx-remote\";\nimport { serialize } from \"next-mdx-remote/serialize\";\nimport Link from \"next/link\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { useLockBodyScroll } from \"@/components/LockBodyScroll\";\nimport { useMDXComponents as getMDXComponents } from \"@/components/MDXComponents\";\nimport {\n styledAnchor,\n styledTable,\n stylesLists,\n StyledSmallButton,\n interactiveStyles,\n thinScrollbar,\n} from \"@/components/layout/SharedStyled\";\n\nconst mdxComponents = getMDXComponents({});\n\nconst styledText = css<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.text.xs};\n line-height: ${({ theme }) => theme.lineHeights.text.xs};\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: ${({ theme }) => theme.lineHeights.small.lg};\n }\n`;\n\nconst StyledChat = styled.div<{ theme: Theme; $isVisible: boolean }>`\n margin: 0;\n position: fixed;\n top: 0;\n right: 0;\n width: 100%;\n height: calc(100dvh - 90px);\n overflow-y: scroll;\n overflow-x: hidden;\n z-index: 1000;\n padding: 0 20px;\n transition: all 0.3s ease;\n transform: translateX(0);\n background: ${({ theme }) => theme.colors.light};\n -webkit-overflow-scrolling: touch;\n opacity: 1;\n\n &::-webkit-scrollbar {\n display: none;\n }\n\n ${({ $isVisible }) =>\n !$isVisible &&\n css`\n transform: translateX(100%);\n opacity: 0;\n `}\n\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n`;\n\nconst loadingAnimation = keyframes`\n 0% {\n transform: rotate(0deg);\n }\n 100% {\n transform: rotate(360deg);\n }\n`;\n\nconst rotateGradient = keyframes`\n 0% {\n --gradient-angle: 0deg;\n }\n 100% {\n --gradient-angle: 360deg;\n }\n`;\n\nconst pulseGlow = keyframes`\n 0%, 100% {\n opacity: 0.5;\n filter: blur(16px);\n }\n 50% {\n opacity: 1;\n filter: blur(22px);\n }\n`;\n\nconst sparkleFloat = keyframes`\n 0%, 100% {\n opacity: 0;\n transform: translateY(0) scale(0);\n }\n 50% {\n opacity: 0.9;\n transform: translateY(-20px) scale(1);\n }\n`;\n\nconst shimmer = keyframes`\n 0% {\n background-position: 0% center;\n }\n 50% {\n background-position: 100% center;\n }\n 100% {\n background-position: 0% center;\n }\n`;\n\nconst StyledRainbowInputWrapper = styled.div<{\n theme: Theme;\n $isActive: boolean;\n}>`\n @property --gradient-angle {\n syntax: \"<angle>\";\n initial-value: 0deg;\n inherits: false;\n }\n\n position: relative;\n flex: 1;\n\n &::before {\n content: \"\";\n position: absolute;\n inset: -2px;\n border-radius: 14px;\n background: conic-gradient(\n from var(--gradient-angle),\n #cc5555,\n #d9a745,\n #3ab0cc,\n #cc7fc2,\n #4380cc,\n #4c1fa3,\n #cc5555\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation: ${rotateGradient} 3s linear infinite;\n z-index: 0;\n }\n\n &::after {\n content: \"\";\n position: absolute;\n inset: -10px;\n border-radius: 20px;\n background: conic-gradient(\n from var(--gradient-angle),\n #ff6b6b66,\n #feca5766,\n #48dbfb66,\n #ff9ff366,\n #54a0ff66,\n #5f27cd66,\n #ff6b6b66\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation:\n ${rotateGradient} 3s linear infinite,\n ${pulseGlow} 2s ease-in-out infinite;\n z-index: -1;\n pointer-events: none;\n }\n\n &:hover::before,\n &:focus-within::before {\n opacity: 1;\n }\n\n &:hover::after,\n &:focus-within::after {\n opacity: 1;\n }\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n &::before {\n opacity: 1;\n }\n &::after {\n opacity: 1;\n }\n `}\n`;\n\nconst StyledSparkleContainer = styled.div<{ $isActive: boolean }>`\n position: absolute;\n inset: -30px;\n pointer-events: none;\n overflow: hidden;\n border-radius: 30px;\n z-index: -2;\n opacity: 0;\n transition: opacity 0.4s ease;\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n opacity: 1;\n `}\n`;\n\nconst StyledSparkle = styled.div<{\n $color: string;\n $left: number;\n $top: number;\n $delay: number;\n}>`\n position: absolute;\n width: 4px;\n height: 4px;\n border-radius: 50%;\n background: ${({ $color }) => $color};\n box-shadow: 0 0 6px ${({ $color }) => $color};\n left: ${({ $left }) => $left}%;\n top: ${({ $top }) => $top}%;\n animation: ${sparkleFloat} 2s ease-in-out infinite;\n animation-delay: ${({ $delay }) => $delay}s;\n`;\n\nconst StyledRainbowInput = styled.input<{ theme: Theme }>`\n position: relative;\n z-index: 1;\n width: 100%;\n background: ${({ theme }) => theme.colors.light};\n border: 1px solid ${({ theme }) => theme.colors.grayLight};\n border-radius: 12px;\n padding: 12px 18px;\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n font-family: inherit;\n color: ${({ theme }) => theme.colors.dark};\n outline: none;\n transition:\n border-color 0.3s ease,\n box-shadow 0.3s ease;\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n }\n\n &::placeholder {\n color: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.dark} 40%, transparent)`};\n transition: color 0.3s ease;\n }\n\n &:focus::placeholder {\n color: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.dark} 60%, transparent)`};\n }\n\n &:focus {\n border-color: transparent;\n }\n`;\n\nconst StyledRainbowButton = styled(Button)<{\n theme: Theme;\n $hasContent: boolean;\n}>`\n padding-top: 10px;\n padding-bottom: 10px;\n position: relative;\n overflow: hidden;\n transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;\n color: ${({ theme }) => theme.colors.surface};\n\n &::before {\n content: \"\";\n position: absolute;\n inset: 0;\n background: linear-gradient(\n 135deg,\n #ff6b6b,\n #feca57,\n #48dbfb,\n #ff9ff3,\n #54a0ff\n );\n background-size: 300% 300%;\n opacity: 0;\n transition: opacity 0.3s ease;\n z-index: 0;\n animation: ${shimmer} 3s linear infinite;\n width: 200%;\n }\n\n ${({ $hasContent }) =>\n $hasContent &&\n css`\n &::before {\n opacity: 1;\n }\n `}\n\n &:hover::before {\n opacity: 1;\n }\n\n &:hover {\n transform: scale(1.05);\n }\n\n &:active {\n transform: scale(0.95);\n }\n\n & svg {\n position: relative;\n z-index: 1;\n transition: transform 0.3s ease;\n }\n\n &:disabled,\n &:disabled:hover {\n background: ${({ theme }) => theme.colors.primaryDark};\n transform: none;\n box-shadow: none;\n\n &::before {\n opacity: 0;\n }\n }\n`;\n\nconst StyledChatForm = styled.form<{ theme: Theme; $isVisible: boolean }>`\n display: flex;\n gap: 10px;\n justify-content: center;\n align-items: center;\n background: ${({ theme }) => theme.colors.light};\n padding: 20px;\n position: fixed;\n bottom: 0;\n right: 0;\n z-index: 1000;\n width: 100%;\n border-top: solid 1px ${({ theme }) => theme.colors.grayLight};\n transition: all 0.3s ease;\n transform: translateX(100%);\n opacity: 0;\n\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n\n ${({ $isVisible }) =>\n $isVisible &&\n css`\n opacity: 1;\n transform: translateX(0);\n `}\n\n & .loading {\n animation: ${loadingAnimation} 1s linear infinite;\n }\n`;\n\nconst StyledGlowSmallButton = styled(StyledSmallButton)<{\n theme: Theme;\n $hasContent: boolean;\n}>`\n @property --gradient-angle {\n syntax: \"<angle>\";\n initial-value: 0deg;\n inherits: false;\n }\n\n position: relative;\n isolation: isolate;\n margin-right: 0;\n background: ${({ theme }) => theme.colors.light};\n padding: 0;\n\n &::before {\n content: \"\";\n inset: -2px;\n border-radius: 8px;\n background: conic-gradient(\n from var(--gradient-angle),\n #cc5555,\n #d9a745,\n #3ab0cc,\n #cc7fc2,\n #4380cc,\n #4c1fa3,\n #cc5555\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation: ${rotateGradient} 3s linear infinite;\n z-index: -1;\n position: absolute;\n top: -2px;\n left: -2px;\n width: calc(100% + 4px);\n height: calc(100% + 4px);\n }\n\n &::after {\n content: \"\";\n position: absolute;\n inset: -8px;\n border-radius: 14px;\n background: conic-gradient(\n from var(--gradient-angle),\n #ff6b6b66,\n #feca5766,\n #48dbfb66,\n #ff9ff366,\n #54a0ff66,\n #5f27cd66,\n #ff6b6b66\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation:\n ${rotateGradient} 3s linear infinite,\n ${pulseGlow} 2s ease-in-out infinite;\n z-index: -2;\n pointer-events: none;\n }\n\n &:hover::before,\n &:hover::after {\n opacity: 1;\n }\n\n & span {\n padding: 6px 8px;\n display: flex;\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n gap: 6px;\n }\n\n ${({ $hasContent }) =>\n $hasContent &&\n css`\n &::before {\n opacity: 1;\n }\n &::after {\n opacity: 1;\n }\n `}\n`;\n\nconst StyledError = styled.div<{ theme: Theme }>`\n overflow-x: auto;\n ${thinScrollbar};\n background: ${({ theme }) => theme.colors.error};\n color: ${({ theme }) => theme.colors.surface};\n padding: 10px;\n border-radius: 8px;\n margin: 20px 0;\n width: 100%;\n ${styledText};\n`;\n\nconst loadingDotAnimation = keyframes`\n 0% {\n opacity: 0;\n }\n 50% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n`;\n\nconst StyledLoading = styled.div<{ theme: Theme }>`\n overflow-x: auto;\n ${thinScrollbar};\n margin: 20px 0;\n width: 100%;\n font-weight: 600;\n ${styledText};\n color: ${({ theme }) => theme.colors.dark};\n\n & span {\n &:nth-child(1) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n }\n &:nth-child(2) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n animation-delay: 0.2s;\n }\n &:nth-child(3) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n animation-delay: 0.4s;\n }\n }\n`;\n\nconst StyledAnswer = styled.div<{ theme: Theme; $isAnswer: boolean }>`\n overflow-x: auto;\n ${thinScrollbar};\n background: ${({ theme }) => theme.colors.primary};\n color: ${({ theme }) => theme.colors.surface};\n padding: 10px 15px;\n border-radius: 18px;\n margin: 20px 0 20px auto;\n width: fit-content;\n ${styledText};\n\n & p {\n ${styledText};\n }\n\n ${({ $isAnswer }) =>\n $isAnswer &&\n css`\n background: transparent;\n color: ${({ theme }) => theme.colors.dark};\n padding: 0;\n width: 100%;\n max-width: 100%;\n margin: 20px 0;\n border-radius: 0;\n `}\n\n & code:not([class]) {\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 20%, transparent)`};\n color: ${({ theme }) => theme.colors.dark};\n padding: 2px 4px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n white-space: pre;\n }\n\n ${styledAnchor};\n ${stylesLists};\n ${styledTable};\n\n & pre,\n & .hljs {\n margin: 10px 0;\n }\n\n & .code-wrapper pre {\n margin: 0;\n ${styledText};\n }\n\n & > *:first-child {\n margin-top: 0;\n }\n\n & > *:last-child {\n margin-bottom: 0;\n\n & > *:last-child {\n margin-bottom: 0;\n }\n }\n\n & ul,\n & ol {\n & li {\n ${styledText};\n }\n }\n\n & img,\n & video,\n & iframe {\n max-width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n margin: 10px 0;\n display: block;\n }\n\n & h1,\n & h2,\n & h3,\n & h4,\n & h5,\n & h6 {\n margin: 10px 0;\n padding: 0;\n }\n`;\n\nconst StyledSources = styled.div`\n display: flex;\n gap: 16px;\n flex-wrap: wrap;\n margin: -5px 0 20px;\n`;\n\nconst StyledSourceLink = styled(Link)<{ theme: Theme }>`\n position: relative;\n text-decoration: none;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: 1;\n color: ${({ theme }) => theme.colors.primary};\n display: flex;\n gap: 6px;\n transition: all 0.3s ease;\n font-weight: 600;\n white-space: nowrap;\n min-width: fit-content;\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 10%, transparent)`};\n padding: 6px 8px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n ${interactiveStyles};\n\n & * {\n margin: auto 0;\n }\n\n &:hover {\n color: ${({ theme }) => theme.colors.accent};\n }\n`;\n\nconst StyledChatTitle = styled.div<{ theme: Theme }>`\n display: flex;\n flex-wrap: nowrap;\n justify-content: space-between;\n position: sticky;\n margin: 0 -20px;\n padding: 16px 20px;\n height: 62px;\n top: 0;\n background: ${({ theme }) => theme.colors.light};\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n z-index: 1000;\n`;\n\nconst StyledChatTitleIconWrapper = styled.span<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 12px;\n color: ${({ theme }) => theme.colors.dark};\n`;\n\ntype Source = {\n id: string;\n path: string;\n uri: string;\n score: number;\n};\n\ntype Answer = {\n text: string;\n answer?: boolean;\n mdx?: MDXRemoteSerializeResult;\n sources?: Source[];\n};\n\nconst SPARKLE_COLORS = [\n \"#ff6b6b\",\n \"#feca57\",\n \"#48dbfb\",\n \"#ff9ff3\",\n \"#54a0ff\",\n \"#5f27cd\",\n];\n\n// Deterministic sparkle positions to avoid hydration mismatch\nconst SPARKLE_POSITIONS = [\n { left: 8, top: 35 },\n { left: 17, top: 55 },\n { left: 26, top: 28 },\n { left: 35, top: 68 },\n { left: 44, top: 42 },\n { left: 53, top: 75 },\n { left: 62, top: 32 },\n { left: 71, top: 58 },\n { left: 80, top: 45 },\n { left: 89, top: 65 },\n];\n\ninterface RainbowInputProps {\n id?: string;\n value: string;\n onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;\n placeholder?: string;\n autoComplete?: string;\n \"aria-label\"?: string;\n inputRef?: React.Ref<HTMLInputElement>;\n}\n\nfunction RainbowInput({\n id,\n value,\n onChange,\n placeholder,\n autoComplete,\n \"aria-label\": ariaLabel,\n inputRef,\n}: RainbowInputProps) {\n const [isFocused, setIsFocused] = useState(false);\n const [isHovered, setIsHovered] = useState(false);\n const isActive = isFocused || isHovered;\n\n const sparkles = SPARKLE_POSITIONS.map((pos, i) => ({\n color: SPARKLE_COLORS[i % SPARKLE_COLORS.length],\n left: pos.left,\n top: pos.top,\n delay: i * 0.12,\n }));\n\n return (\n <StyledRainbowInputWrapper\n $isActive={isActive}\n onMouseEnter={() => setIsHovered(true)}\n onMouseLeave={() => setIsHovered(false)}\n >\n <StyledSparkleContainer $isActive={isActive}>\n {sparkles.map((sparkle, i) => (\n <StyledSparkle\n key={i}\n $color={sparkle.color}\n $left={sparkle.left}\n $top={sparkle.top}\n $delay={sparkle.delay}\n />\n ))}\n </StyledSparkleContainer>\n <StyledRainbowInput\n ref={inputRef}\n id={id}\n value={value}\n onChange={onChange}\n placeholder={placeholder}\n autoComplete={autoComplete}\n aria-label={ariaLabel}\n onFocus={() => setIsFocused(true)}\n onBlur={() => setIsFocused(false)}\n />\n </StyledRainbowInputWrapper>\n );\n}\n\nfunction ChatButtonCTA() {\n const { toggleChat, isOpen } = useContext(ChatContext);\n\n return (\n <StyledGlowSmallButton\n onClick={toggleChat}\n aria-label=\"Ask AI Assistant\"\n $hasContent={isOpen}\n type=\"button\"\n >\n <span>\n <Sparkles size={16} />\n Ask AI\n </span>\n </StyledGlowSmallButton>\n );\n}\n\nfunction Chat() {\n const {\n isOpen,\n question,\n setQuestion,\n loading,\n error,\n answer,\n ask,\n closeChat,\n resetChat,\n chatInputRef,\n } = useContext(ChatContext);\n const endRef = useRef<HTMLDivElement | null>(null);\n\n useLockBodyScroll(isOpen);\n\n useEffect(() => {\n endRef.current?.scrollIntoView({ behavior: \"smooth\", block: \"end\" });\n }, [answer]);\n\n useEffect(() => {\n if (answer?.length > 0) {\n chatInputRef.current?.focus();\n }\n }, [answer, chatInputRef]);\n\n return (\n <>\n <StyledChat $isVisible={isOpen}>\n <StyledChatTitle>\n <StyledChatTitleIconWrapper>\n <Sparkles />\n <h3>AI Assistant</h3>\n </StyledChatTitleIconWrapper>\n <StyledChatTitleIconWrapper>\n <IconButton\n onClick={resetChat}\n aria-label=\"Reset chat history\"\n title=\"Reset chat history\"\n >\n <RotateCcw />\n </IconButton>\n <IconButton\n onClick={closeChat}\n aria-label=\"Close chat\"\n title=\"Close chat\"\n >\n <X />\n </IconButton>\n </StyledChatTitleIconWrapper>\n </StyledChatTitle>\n {answer &&\n answer.map((a, i) => (\n <React.Fragment key={i}>\n <StyledAnswer $isAnswer={a.answer ?? false}>\n {a.answer && a.mdx ? (\n <MDXRemote {...a.mdx} components={mdxComponents} />\n ) : (\n a.text\n )}\n </StyledAnswer>\n {a.answer && a.sources && a.sources.length > 0 && (\n <StyledSources>\n {a.sources.map((src) => {\n const slug = src.uri\n .replace(\"docs://\", \"\")\n .replace(/^\\/+/, \"\");\n const href = slug ? `/${slug}/` : \"/\";\n const label = slug\n ? slug\n .split(\"/\")\n .pop()!\n .replace(/-/g, \" \")\n .replace(/\\b\\w/g, (c: string) => c.toUpperCase())\n : \"Home\";\n return (\n <StyledSourceLink\n key={src.id}\n href={href}\n onClick={() => {\n if (window.innerWidth <= 992) {\n closeChat();\n }\n }}\n >\n {label}\n </StyledSourceLink>\n );\n })}\n </StyledSources>\n )}\n </React.Fragment>\n ))}\n {loading && !answer[answer.length - 1]?.answer && (\n <StyledLoading>\n Answering<span>.</span>\n <span>.</span>\n <span>.</span>\n </StyledLoading>\n )}\n {error && (\n <StyledError>\n <strong>Error:</strong> {error}\n </StyledError>\n )}\n <div ref={endRef} />\n </StyledChat>\n\n <StyledChatForm onSubmit={ask} $isVisible={isOpen}>\n <RainbowInput\n id=\"chat-bottom-input\"\n inputRef={chatInputRef}\n value={question}\n onChange={(e) => setQuestion(e.target.value)}\n placeholder=\"Ask AI Assistant...\"\n autoComplete=\"off\"\n aria-label=\"Ask a follow-up question\"\n />\n <StyledRainbowButton\n type=\"submit\"\n disabled={loading || question.trim() === \"\"}\n $hasContent={question.trim().length > 0}\n aria-label={loading ? \"Loading response\" : \"Submit question\"}\n >\n {loading ? <LoaderPinwheel className=\"loading\" /> : <ArrowUp />}\n </StyledRainbowButton>\n </StyledChatForm>\n </>\n );\n}\n\nconst ChatContext = createContext<{\n isOpen: boolean;\n setIsOpen: (isOpen: boolean) => void;\n toggleChat: () => void;\n isChatActive: boolean;\n question: string;\n setQuestion: (q: string) => void;\n loading: boolean;\n error: string | null;\n answer: Answer[];\n setAnswer: (answers: Answer[]) => void;\n ask: (e: React.FormEvent) => void;\n askAssistant: (question: string) => void;\n closeChat: () => void;\n resetChat: () => void;\n chatInputRef: React.RefObject<HTMLInputElement | null>;\n}>({\n isOpen: false,\n setIsOpen: () => {},\n toggleChat: () => {},\n isChatActive: false,\n question: \"\",\n setQuestion: () => {},\n loading: false,\n error: null,\n answer: [],\n setAnswer: () => {},\n ask: () => {},\n askAssistant: () => {},\n closeChat: () => {},\n resetChat: () => {},\n chatInputRef: { current: null },\n});\n\ninterface ChatContextProviderProps {\n children: React.ReactNode;\n isChatActive: boolean;\n}\n\nconst INITIAL_GREETING = \"Hey there, how can I assist you?\";\n\nconst ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {\n const [isOpen, setIsOpen] = useState(false);\n const [question, setQuestion] = useState(\"\");\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const [answer, setAnswer] = useState<Answer[]>([]);\n const abortRef = useRef<AbortController | null>(null);\n const chatInputRef = useRef<HTMLInputElement | null>(null);\n const isOpenRef = useRef(isOpen);\n\n useEffect(() => {\n isOpenRef.current = isOpen;\n }, [isOpen]);\n\n // Open the assistant, seeding a greeting the first time and focusing the\n // input once the panel has slid in (matches the 0.3s panel transition).\n const openChat = useCallback(() => {\n setIsOpen(true);\n setAnswer((prev) =>\n prev.length === 0 ? [{ text: INITIAL_GREETING, answer: true }] : prev,\n );\n setTimeout(() => {\n chatInputRef.current?.focus();\n }, 350);\n }, []);\n\n const toggleChat = useCallback(() => {\n if (isOpenRef.current) {\n setIsOpen(false);\n } else {\n openChat();\n }\n }, [openChat]);\n\n // Global Cmd/Ctrl+I toggles the assistant, but only when chat is enabled.\n useEffect(() => {\n if (!isChatActive) return;\n function handleKeyDown(e: KeyboardEvent) {\n if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === \"i\") {\n e.preventDefault();\n toggleChat();\n }\n }\n document.addEventListener(\"keydown\", handleKeyDown);\n return () => document.removeEventListener(\"keydown\", handleKeyDown);\n }, [isChatActive, toggleChat]);\n\n async function runQuestion(currentQuestion: string) {\n setQuestion(\"\");\n setIsOpen(true);\n setLoading(true);\n setError(null);\n\n const base =\n answer.length > 0 ? answer : [{ text: INITIAL_GREETING, answer: true }];\n const mergedQuestions = [...base, { text: currentQuestion, answer: false }];\n\n setAnswer(mergedQuestions);\n\n const controller = new AbortController();\n abortRef.current = controller;\n\n try {\n const history = answer\n .filter((a) => a.text.trim() !== \"\")\n .map((a) => ({\n role: a.answer ? (\"assistant\" as const) : (\"user\" as const),\n content: a.text,\n }));\n\n const res = await fetch(\"/api/rag\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ question: currentQuestion, history }),\n signal: controller.signal,\n });\n\n if (!res.ok) {\n // The body may be a proxy/gateway HTML error page (e.g. Cloudflare\n // 524), not our JSON - never blindly JSON.parse it.\n const contentType = res.headers.get(\"content-type\") || \"\";\n let message = \"Request failed\";\n if (contentType.includes(\"application/json\")) {\n try {\n const errorData = await res.json();\n message = errorData.error || message;\n } catch {\n // Non-JSON despite the header - fall through to the default.\n }\n } else if ([502, 503, 504, 524].includes(res.status)) {\n message = \"The assistant took too long to respond. Please try again.\";\n }\n throw new Error(message);\n }\n\n const reader = res.body?.getReader();\n const decoder = new TextDecoder();\n const contentParts: string[] = [];\n let sources: Source[] = [];\n if (!reader) {\n throw new Error(\"Failed to get response reader\");\n }\n\n // Don't insert the answer bubble yet - an empty bubble renders a blank\n // gap above the \"Answering...\" loader. The bubble is created on the first\n // streamed token (or the done event) instead, so the loader stays put\n // until real text appears.\n const streamingAnswerIndex = mergedQuestions.length;\n\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n\n buffer += decoder.decode(value, { stream: true });\n const parts = buffer.split(\"\\n\");\n buffer = parts.pop() ?? \"\";\n\n for (const line of parts) {\n if (line.startsWith(\"data: \")) {\n try {\n const data = JSON.parse(line.slice(6));\n\n if (data.type === \"metadata\") {\n const allSources: Source[] = data.data?.sources ?? [];\n const seen = new Set<string>();\n sources = allSources.filter((s: Source) => {\n if (s.score < 0.4 || seen.has(s.uri)) return false;\n seen.add(s.uri);\n return true;\n });\n } else if (data.type === \"content\") {\n contentParts.push(data.data);\n const streamedContent = contentParts.join(\"\");\n\n setAnswer((prev) => {\n const newAnswers = [...prev];\n newAnswers[streamingAnswerIndex] = {\n text: streamedContent,\n answer: true,\n sources,\n };\n return newAnswers;\n });\n } else if (data.type === \"error\") {\n throw new Error(data.data);\n } else if (data.type === \"done\") {\n const streamedContent = contentParts.join(\"\");\n let mdxSource: MDXRemoteSerializeResult | null = null;\n try {\n mdxSource = await serialize(streamedContent, {\n parseFrontmatter: false,\n mdxOptions: {\n remarkPlugins: [remarkGfm],\n rehypePlugins: [rehypeHighlight],\n format: \"md\",\n development: false,\n },\n });\n } catch (mdxError: unknown) {\n console.error(\"MDX serialization error:\", mdxError);\n }\n\n setAnswer((prev) => {\n const newAnswers = [...prev];\n newAnswers[streamingAnswerIndex] = {\n text: streamedContent,\n answer: true,\n mdx: mdxSource || undefined,\n sources,\n };\n return newAnswers;\n });\n }\n } catch (parseError) {\n if (\n parseError instanceof Error &&\n parseError.message !== \"Unknown error\"\n ) {\n console.error(\"Failed to parse SSE data:\", parseError);\n }\n }\n }\n }\n }\n } catch (err: unknown) {\n if (err instanceof DOMException && err.name === \"AbortError\") return;\n setError(err instanceof Error ? err.message : \"Unknown error\");\n } finally {\n abortRef.current = null;\n setLoading(false);\n }\n }\n\n async function ask(e: React.FormEvent) {\n e.preventDefault();\n if (loading || question.trim() === \"\") return;\n await runQuestion(question);\n }\n\n // Bridge from the search modal: hand the typed query to the assistant. If a\n // response is already streaming (loading), we can't submit yet - open the\n // panel and pre-fill the input so the user can send it the moment the\n // current answer finishes. Otherwise submit it right away.\n function askAssistant(rawQuestion: string) {\n const trimmed = rawQuestion.trim();\n if (trimmed === \"\") return;\n if (loading) {\n openChat();\n setQuestion(trimmed);\n } else {\n void runQuestion(trimmed);\n }\n }\n\n function closeChat() {\n setIsOpen(false);\n }\n\n function resetChat() {\n abortRef.current?.abort();\n setLoading(false);\n setError(null);\n setAnswer([{ text: INITIAL_GREETING, answer: true }]);\n }\n\n return (\n <ChatContext.Provider\n value={{\n isOpen,\n setIsOpen,\n toggleChat,\n isChatActive,\n question,\n setQuestion,\n loading,\n error,\n answer,\n setAnswer,\n ask,\n askAssistant,\n closeChat,\n resetChat,\n chatInputRef,\n }}\n >\n {children}\n </ChatContext.Provider>\n );\n};\n\nexport { Chat, ChtProvider, ChatContext, ChatButtonCTA };\n";
|
|
@@ -923,6 +923,7 @@ const ChatContext = createContext<{
|
|
|
923
923
|
answer: Answer[];
|
|
924
924
|
setAnswer: (answers: Answer[]) => void;
|
|
925
925
|
ask: (e: React.FormEvent) => void;
|
|
926
|
+
askAssistant: (question: string) => void;
|
|
926
927
|
closeChat: () => void;
|
|
927
928
|
resetChat: () => void;
|
|
928
929
|
chatInputRef: React.RefObject<HTMLInputElement | null>;
|
|
@@ -938,6 +939,7 @@ const ChatContext = createContext<{
|
|
|
938
939
|
answer: [],
|
|
939
940
|
setAnswer: () => {},
|
|
940
941
|
ask: () => {},
|
|
942
|
+
askAssistant: () => {},
|
|
941
943
|
closeChat: () => {},
|
|
942
944
|
resetChat: () => {},
|
|
943
945
|
chatInputRef: { current: null },
|
|
@@ -948,6 +950,8 @@ interface ChatContextProviderProps {
|
|
|
948
950
|
isChatActive: boolean;
|
|
949
951
|
}
|
|
950
952
|
|
|
953
|
+
const INITIAL_GREETING = "Hey there, how can I assist you?";
|
|
954
|
+
|
|
951
955
|
const ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {
|
|
952
956
|
const [isOpen, setIsOpen] = useState(false);
|
|
953
957
|
const [question, setQuestion] = useState("");
|
|
@@ -967,9 +971,7 @@ const ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {
|
|
|
967
971
|
const openChat = useCallback(() => {
|
|
968
972
|
setIsOpen(true);
|
|
969
973
|
setAnswer((prev) =>
|
|
970
|
-
prev.length === 0
|
|
971
|
-
? [{ text: "Hey there, how can I assist you?", answer: true }]
|
|
972
|
-
: prev,
|
|
974
|
+
prev.length === 0 ? [{ text: INITIAL_GREETING, answer: true }] : prev,
|
|
973
975
|
);
|
|
974
976
|
setTimeout(() => {
|
|
975
977
|
chatInputRef.current?.focus();
|
|
@@ -997,19 +999,15 @@ const ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {
|
|
|
997
999
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
998
1000
|
}, [isChatActive, toggleChat]);
|
|
999
1001
|
|
|
1000
|
-
async function
|
|
1001
|
-
e.preventDefault();
|
|
1002
|
-
if (loading || question.trim() === "") return;
|
|
1003
|
-
const currentQuestion = question;
|
|
1002
|
+
async function runQuestion(currentQuestion: string) {
|
|
1004
1003
|
setQuestion("");
|
|
1005
1004
|
setIsOpen(true);
|
|
1006
1005
|
setLoading(true);
|
|
1007
1006
|
setError(null);
|
|
1008
1007
|
|
|
1009
|
-
const
|
|
1010
|
-
answer.length > 0
|
|
1011
|
-
|
|
1012
|
-
: [{ text: currentQuestion, answer: false }];
|
|
1008
|
+
const base =
|
|
1009
|
+
answer.length > 0 ? answer : [{ text: INITIAL_GREETING, answer: true }];
|
|
1010
|
+
const mergedQuestions = [...base, { text: currentQuestion, answer: false }];
|
|
1013
1011
|
|
|
1014
1012
|
setAnswer(mergedQuestions);
|
|
1015
1013
|
|
|
@@ -1148,6 +1146,27 @@ const ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {
|
|
|
1148
1146
|
}
|
|
1149
1147
|
}
|
|
1150
1148
|
|
|
1149
|
+
async function ask(e: React.FormEvent) {
|
|
1150
|
+
e.preventDefault();
|
|
1151
|
+
if (loading || question.trim() === "") return;
|
|
1152
|
+
await runQuestion(question);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// Bridge from the search modal: hand the typed query to the assistant. If a
|
|
1156
|
+
// response is already streaming (loading), we can't submit yet - open the
|
|
1157
|
+
// panel and pre-fill the input so the user can send it the moment the
|
|
1158
|
+
// current answer finishes. Otherwise submit it right away.
|
|
1159
|
+
function askAssistant(rawQuestion: string) {
|
|
1160
|
+
const trimmed = rawQuestion.trim();
|
|
1161
|
+
if (trimmed === "") return;
|
|
1162
|
+
if (loading) {
|
|
1163
|
+
openChat();
|
|
1164
|
+
setQuestion(trimmed);
|
|
1165
|
+
} else {
|
|
1166
|
+
void runQuestion(trimmed);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1151
1170
|
function closeChat() {
|
|
1152
1171
|
setIsOpen(false);
|
|
1153
1172
|
}
|
|
@@ -1156,7 +1175,7 @@ const ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {
|
|
|
1156
1175
|
abortRef.current?.abort();
|
|
1157
1176
|
setLoading(false);
|
|
1158
1177
|
setError(null);
|
|
1159
|
-
setAnswer([{ text:
|
|
1178
|
+
setAnswer([{ text: INITIAL_GREETING, answer: true }]);
|
|
1160
1179
|
}
|
|
1161
1180
|
|
|
1162
1181
|
return (
|
|
@@ -1173,6 +1192,7 @@ const ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {
|
|
|
1173
1192
|
answer,
|
|
1174
1193
|
setAnswer,
|
|
1175
1194
|
ask,
|
|
1195
|
+
askAssistant,
|
|
1176
1196
|
closeChat,
|
|
1177
1197
|
resetChat,
|
|
1178
1198
|
chatInputRef,
|
|
@@ -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\n// Stable empty array so the derived contentResults keeps the same identity\n// across renders when there are no hits (memo deps stay stable).\nconst EMPTY_RESULTS: ContentHit[] = [];\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 // Latest completed content search, keyed by the query that produced it.\n // contentResults and isSearching are derived from it at render time, so\n // the debounced-search effect never sets state synchronously (which would\n // trigger cascading renders \u2014 react-hooks/set-state-in-effect).\n const [fetched, setFetched] = useState<{\n q: string;\n results: ContentHit[];\n } | null>(null);\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 setFetched(null);\n }, []);\n\n const trimmedQuery = query.trim();\n const contentResults =\n trimmedQuery.length >= 2 && fetched?.q === trimmedQuery\n ? fetched.results\n : EMPTY_RESULTS;\n const isSearching = trimmedQuery.length >= 2 && fetched?.q !== trimmedQuery;\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. State updates happen only inside the timeout's\n // async callback; short queries need no reset because the derived values\n // above ignore results whose query no longer matches.\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) return;\n\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 setFetched({ q, results: data.results ?? [] });\n } catch (err: unknown) {\n if (err instanceof DOMException && err.name === \"AbortError\") return;\n setFetched({ q, results: [] });\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";
|
|
1
|
+
export declare const searchDocsTemplate = "\"use client\";\nimport React, {\n createContext,\n useCallback,\n useContext,\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 { ChatContext } from \"@/components/Chat\";\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\n// Stable empty array so the derived contentResults keeps the same identity\n// across renders when there are no hits (memo deps stay stable).\nconst EMPTY_RESULTS: ContentHit[] = [];\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 // Latest completed content search, keyed by the query that produced it.\n // contentResults and isSearching are derived from it at render time, so\n // the debounced-search effect never sets state synchronously (which would\n // trigger cascading renders \u2014 react-hooks/set-state-in-effect).\n const [fetched, setFetched] = useState<{\n q: string;\n results: ContentHit[];\n } | null>(null);\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 const { isChatActive, askAssistant } = useContext(ChatContext);\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 setFetched(null);\n }, []);\n\n const trimmedQuery = query.trim();\n const contentResults =\n trimmedQuery.length >= 2 && fetched?.q === trimmedQuery\n ? fetched.results\n : EMPTY_RESULTS;\n const isSearching = trimmedQuery.length >= 2 && fetched?.q !== trimmedQuery;\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. State updates happen only inside the timeout's\n // async callback; short queries need no reset because the derived values\n // above ignore results whose query no longer matches.\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) return;\n\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 setFetched({ q, results: data.results ?? [] });\n } catch (err: unknown) {\n if (err instanceof DOMException && err.name === \"AbortError\") return;\n setFetched({ q, results: [] });\n }\n }, 300);\n\n return () => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n };\n }, [query]);\n\n const navigate = useCallback(\n (\n slug: string,\n modifiers?: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean },\n ) => {\n const url = `/${slug}`;\n\n // Shift+Enter / Shift+Click: open in a separate browser window. Window\n // features (dimensions) are what make the browser spawn a window instead\n // of a tab; noopener/noreferrer keep the new window from reaching back\n // into this one via window.opener.\n if (modifiers?.shiftKey) {\n const width = Math.min(1024, window.screen.availWidth);\n const height = Math.min(800, window.screen.availHeight);\n const left = Math.round((window.screen.availWidth - width) / 2);\n const top = Math.round((window.screen.availHeight - height) / 2);\n window.open(\n url,\n \"_blank\",\n `popup=yes,noopener,noreferrer,width=${width},height=${height},left=${left},top=${top}`,\n );\n return;\n }\n\n // Cmd+Enter / Ctrl+Enter / Cmd+Click: open in a new background tab. With\n // no dimension features the browser keeps this as a tab.\n if (modifiers?.metaKey || modifiers?.ctrlKey) {\n window.open(url, \"_blank\", \"noopener,noreferrer\");\n return;\n }\n\n // Plain Enter / Click: navigate in place within the app.\n closeSearch();\n router.push(url);\n },\n [closeSearch, router],\n );\n\n // Hand the current query to the AI assistant, then dismiss the search modal.\n // `askAssistant` opens the chat and either submits the question or (if a\n // response is already streaming) pre-fills it, ready to send.\n const askAssistantWithQuery = useCallback(() => {\n const q = query.trim();\n if (!q) return;\n askAssistant(q);\n closeSearch();\n }, [query, askAssistant, closeSearch]);\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 // Active-row scrolling now lives in SearchModalContent's layout effect,\n // alongside the highlight measurement, so highlight + scroll update together\n // before paint (no flicker) and stay in sync on query changes.\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 // Option/Alt+Enter is reserved for handing the query to the AI assistant.\n if (e.altKey) {\n if (isChatActive) askAssistantWithQuery();\n return;\n }\n if (merged[activeIndex]) {\n navigate(merged[activeIndex].page.slug, e);\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 canAskAssistant={isChatActive}\n onAskAssistant={askAssistantWithQuery}\n />\n )}\n </SearchContext.Provider>\n );\n}\n\nexport {\n SearchProvider,\n SearchContext,\n StyledKbd as SearchKbd,\n StyledSearchButton,\n};\n";
|
|
@@ -2,6 +2,7 @@ export const searchDocsTemplate = `"use client";
|
|
|
2
2
|
import React, {
|
|
3
3
|
createContext,
|
|
4
4
|
useCallback,
|
|
5
|
+
useContext,
|
|
5
6
|
useEffect,
|
|
6
7
|
useMemo,
|
|
7
8
|
useRef,
|
|
@@ -12,6 +13,7 @@ import dynamic from "next/dynamic";
|
|
|
12
13
|
import styled from "styled-components";
|
|
13
14
|
import { mq, Theme } from "@/app/theme";
|
|
14
15
|
import { interactiveStyles } from "@/components/layout/SharedStyled";
|
|
16
|
+
import { ChatContext } from "@/components/Chat";
|
|
15
17
|
import type { PageItem, MergedResult } from "@/components/SearchModalContent";
|
|
16
18
|
|
|
17
19
|
const SearchModalContent = dynamic(
|
|
@@ -108,6 +110,7 @@ function SearchProvider({
|
|
|
108
110
|
const abortRef = useRef<AbortController | null>(null);
|
|
109
111
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
|
110
112
|
const router = useRouter();
|
|
113
|
+
const { isChatActive, askAssistant } = useContext(ChatContext);
|
|
111
114
|
|
|
112
115
|
const sectionLabels = useMemo(() => {
|
|
113
116
|
const map: Record<string, string> = {};
|
|
@@ -217,13 +220,53 @@ function SearchProvider({
|
|
|
217
220
|
}, [query]);
|
|
218
221
|
|
|
219
222
|
const navigate = useCallback(
|
|
220
|
-
(
|
|
223
|
+
(
|
|
224
|
+
slug: string,
|
|
225
|
+
modifiers?: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean },
|
|
226
|
+
) => {
|
|
227
|
+
const url = \`/\${slug}\`;
|
|
228
|
+
|
|
229
|
+
// Shift+Enter / Shift+Click: open in a separate browser window. Window
|
|
230
|
+
// features (dimensions) are what make the browser spawn a window instead
|
|
231
|
+
// of a tab; noopener/noreferrer keep the new window from reaching back
|
|
232
|
+
// into this one via window.opener.
|
|
233
|
+
if (modifiers?.shiftKey) {
|
|
234
|
+
const width = Math.min(1024, window.screen.availWidth);
|
|
235
|
+
const height = Math.min(800, window.screen.availHeight);
|
|
236
|
+
const left = Math.round((window.screen.availWidth - width) / 2);
|
|
237
|
+
const top = Math.round((window.screen.availHeight - height) / 2);
|
|
238
|
+
window.open(
|
|
239
|
+
url,
|
|
240
|
+
"_blank",
|
|
241
|
+
\`popup=yes,noopener,noreferrer,width=\${width},height=\${height},left=\${left},top=\${top}\`,
|
|
242
|
+
);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Cmd+Enter / Ctrl+Enter / Cmd+Click: open in a new background tab. With
|
|
247
|
+
// no dimension features the browser keeps this as a tab.
|
|
248
|
+
if (modifiers?.metaKey || modifiers?.ctrlKey) {
|
|
249
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Plain Enter / Click: navigate in place within the app.
|
|
221
254
|
closeSearch();
|
|
222
|
-
router.push(
|
|
255
|
+
router.push(url);
|
|
223
256
|
},
|
|
224
257
|
[closeSearch, router],
|
|
225
258
|
);
|
|
226
259
|
|
|
260
|
+
// Hand the current query to the AI assistant, then dismiss the search modal.
|
|
261
|
+
// \`askAssistant\` opens the chat and either submits the question or (if a
|
|
262
|
+
// response is already streaming) pre-fills it, ready to send.
|
|
263
|
+
const askAssistantWithQuery = useCallback(() => {
|
|
264
|
+
const q = query.trim();
|
|
265
|
+
if (!q) return;
|
|
266
|
+
askAssistant(q);
|
|
267
|
+
closeSearch();
|
|
268
|
+
}, [query, askAssistant, closeSearch]);
|
|
269
|
+
|
|
227
270
|
// Global Cmd+K / Ctrl+K listener
|
|
228
271
|
const isVisibleRef = useRef(false);
|
|
229
272
|
|
|
@@ -246,12 +289,9 @@ function SearchProvider({
|
|
|
246
289
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
247
290
|
}, [closeSearch, openSearch]);
|
|
248
291
|
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const active = resultsRef.current.children[activeIndex] as HTMLElement;
|
|
253
|
-
active?.scrollIntoView({ block: "nearest" });
|
|
254
|
-
}, [activeIndex]);
|
|
292
|
+
// Active-row scrolling now lives in SearchModalContent's layout effect,
|
|
293
|
+
// alongside the highlight measurement, so highlight + scroll update together
|
|
294
|
+
// before paint (no flicker) and stay in sync on query changes.
|
|
255
295
|
|
|
256
296
|
function handleKeyDown(e: React.KeyboardEvent) {
|
|
257
297
|
if (e.key === "ArrowDown") {
|
|
@@ -262,8 +302,13 @@ function SearchProvider({
|
|
|
262
302
|
setActiveIndex((i) => (i > 0 ? i - 1 : merged.length - 1));
|
|
263
303
|
} else if (e.key === "Enter") {
|
|
264
304
|
e.preventDefault();
|
|
305
|
+
// Option/Alt+Enter is reserved for handing the query to the AI assistant.
|
|
306
|
+
if (e.altKey) {
|
|
307
|
+
if (isChatActive) askAssistantWithQuery();
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
265
310
|
if (merged[activeIndex]) {
|
|
266
|
-
navigate(merged[activeIndex].page.slug);
|
|
311
|
+
navigate(merged[activeIndex].page.slug, e);
|
|
267
312
|
}
|
|
268
313
|
} else if (e.key === "Escape") {
|
|
269
314
|
closeSearch();
|
|
@@ -288,6 +333,8 @@ function SearchProvider({
|
|
|
288
333
|
sectionLabels={sectionLabels}
|
|
289
334
|
isSearching={isSearching}
|
|
290
335
|
navigate={navigate}
|
|
336
|
+
canAskAssistant={isChatActive}
|
|
337
|
+
onAskAssistant={askAssistantWithQuery}
|
|
291
338
|
/>
|
|
292
339
|
)}
|
|
293
340
|
</SearchContext.Provider>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const searchModalContentTemplate = "\"use client\";\nimport React from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { Search, X } from \"lucide-react\";\nimport { IconButton } from \"cherry-styled-components\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { Spinner } from \"@/components/Spinner\";\nimport { thinScrollbar } from \"@/components/layout/SharedStyled\";\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 = 300;\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 /* Backdrop is intentionally near-black in both modes \u2014 covers everything\n behind the modal regardless of the user's theme. */\n background: rgba(0, 0, 0, 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 ${thinScrollbar};\n\n /* The results list is a keyboard-focusable scroll container; frame it with\n the app focus ring (inset so it hugs the visible viewport and isn't clipped\n by the scroll overflow) instead of the browser's default outline. */\n &:focus-visible {\n outline: none;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n box-shadow: inset 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\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: color-mix(\n in srgb,\n ${theme.colors.primaryLight} 20%,\n transparent\n );\n `}\n\n &:hover {\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 15%, transparent)`};\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 }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 35%, transparent)`};\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\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={(e) => {\n // animationend bubbles, so this handler also fires for animations\n // completing on descendants (the inner modal, icons, result items).\n // Only the backdrop's own exit animation should trigger the unmount -\n // otherwise a child finishing its animation first tears the modal down\n // before the close animation plays, making it vanish instantly.\n if (e.target === e.currentTarget) onCloseAnimationEnd();\n }}\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 <IconButton\n onClick={closeSearch}\n aria-label=\"Close search\"\n title=\"Close search\"\n >\n <X />\n </IconButton>\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
|
+
export declare const searchModalContentTemplate = "\"use client\";\nimport React from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { Search, X } from \"lucide-react\";\nimport { IconButton } from \"cherry-styled-components\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { Spinner } from \"@/components/Spinner\";\nimport {\n interactiveStyles,\n thinScrollbar,\n} from \"@/components/layout/SharedStyled\";\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: (\n slug: string,\n modifiers?: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean },\n ) => void;\n canAskAssistant: boolean;\n onAskAssistant: () => void;\n}\n\nconst ANIMATION_MS = 300;\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 /* Backdrop is intentionally near-black in both modes \u2014 covers everything\n behind the modal regardless of the user's theme. */\n background: rgba(0, 0, 0, 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\n// \"Ask AI\" affordance shown before the close button when the AI chat is\n// enabled. Desktop-only (like the header's Cmd+K hint) since it advertises the\n// Option+Enter shortcut; mobile users reach the assistant from the header CTA.\nconst StyledAskAssistant = styled.button<{ theme: Theme }>`\n ${interactiveStyles};\n display: none;\n align-items: center;\n gap: 6px;\n flex-shrink: 0;\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n background: ${({ theme }) => theme.colors.light};\n color: ${({ theme }) => theme.colors.grayDark};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n padding: 5px 8px;\n font-family: inherit;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n font-weight: 500;\n line-height: 1;\n white-space: nowrap;\n cursor: pointer;\n\n &:disabled {\n opacity: 0.5;\n cursor: default;\n\n &:hover {\n border-color: ${({ theme }) => theme.colors.grayLight};\n }\n }\n\n ${mq(\"lg\")} {\n display: inline-flex;\n }\n`;\n\nconst StyledAskKeys = styled.span`\n display: inline-flex;\n align-items: center;\n gap: 3px;\n`;\n\nconst StyledAskKbd = styled.kbd<{ theme: Theme }>`\n font-family: inherit;\n font-size: 12px;\n font-weight: 600;\n line-height: 1;\n min-width: 18px;\n height: 18px;\n padding: 0 4px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n background: ${({ theme }) => theme.colors.grayLight};\n color: ${({ theme }) => theme.colors.grayDark};\n border-radius: 4px;\n`;\n\n// The return glyph's ink sits high in its em box, so it reads top-aligned even\n// when the keycap flex-centers it. Nudge just the character (not the pill) down\n// a pixel so it optically centers and lines up with the option glyph.\nconst StyledReturnGlyph = styled.span`\n display: inline-block;\n transform: translateY(1.5px);\n`;\n\nconst StyledResults = styled.ul<{ theme: Theme }>`\n list-style: none;\n position: relative;\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 ${thinScrollbar};\n\n /* The results list is a keyboard-focusable scroll container; frame it with\n the app focus ring (inset so it hugs the visible viewport and isn't clipped\n by the scroll overflow) instead of the browser's default outline. */\n &:focus-visible {\n outline: none;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n box-shadow: inset 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n`;\n\n// Single indicator that slides between result items instead of each item\n// fading its own background in and out. Positioned/sized inline from the active\n// item's measured offset (see the layout effect below); the transition on\n// transform + height produces the up/down glide as the active item changes.\nconst StyledHighlight = styled.div<{ theme: Theme }>`\n position: absolute;\n left: 8px;\n right: 8px;\n top: 0;\n z-index: 0;\n pointer-events: none;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 20%, transparent)`};\n transition:\n transform 0.2s cubic-bezier(0.22, 1, 0.36, 1),\n height 0.2s cubic-bezier(0.22, 1, 0.36, 1);\n will-change: transform, height;\n\n @media (prefers-reduced-motion: reduce) {\n transition: none;\n }\n`;\n\nconst StyledResultItem = styled.li<{ theme: Theme; $isActive: boolean }>`\n position: relative;\n z-index: 1;\n padding: 10px 12px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n cursor: pointer;\n\n ${({ $isActive, theme }) =>\n $isActive &&\n css`\n & > span:first-of-type {\n color: ${theme.colors.primary};\n }\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 transition: color 0.15s ease;\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 }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 35%, transparent)`};\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\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 canAskAssistant,\n onAskAssistant,\n}: SearchModalContentProps) {\n // Measure the active item so the sliding highlight can be placed over it.\n // useLayoutEffect runs before paint, so the first position is committed\n // without a transition (no slide-in on open); later activeIndex/hover\n // changes glide because only the transform + height differ.\n const [indicator, setIndicator] = React.useState<{\n top: number;\n height: number;\n } | null>(null);\n\n React.useLayoutEffect(() => {\n const list = resultsRef.current;\n if (!list) {\n setIndicator(null);\n return;\n }\n const items = list.querySelectorAll(\"li\");\n const active = items[activeIndex] as HTMLElement | undefined;\n if (!active) {\n setIndicator(null);\n return;\n }\n setIndicator({ top: active.offsetTop, height: active.offsetHeight });\n\n // Keep the active row inside the scroll viewport in the SAME pre-paint pass\n // that positions the highlight, so the two never desync into a one-frame\n // flicker. merged is a dependency, so re-filtering (a new query) also\n // re-scrolls the reset active row into view even when its index is\n // unchanged. This list is flat (no group headers), so no header inclusion\n // is needed; a small margin keeps rows off the container's hard edges.\n // Adjust scrollTop directly (never scrollIntoView, which can also pan\n // ancestors).\n const margin = 8;\n const viewTop = active.offsetTop - margin;\n const viewBottom = active.offsetTop + active.offsetHeight + margin;\n\n if (viewTop < list.scrollTop) {\n list.scrollTop = viewTop;\n } else if (viewBottom > list.scrollTop + list.clientHeight) {\n list.scrollTop = viewBottom - list.clientHeight;\n }\n }, [activeIndex, merged, resultsRef]);\n\n return (\n <StyledBackdrop\n $isClosing={isClosing}\n onClick={closeSearch}\n onAnimationEnd={(e) => {\n // animationend bubbles, so this handler also fires for animations\n // completing on descendants (the inner modal, icons, result items).\n // Only the backdrop's own exit animation should trigger the unmount -\n // otherwise a child finishing its animation first tears the modal down\n // before the close animation plays, making it vanish instantly.\n if (e.target === e.currentTarget) onCloseAnimationEnd();\n }}\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 {canAskAssistant && (\n <StyledAskAssistant\n type=\"button\"\n onClick={onAskAssistant}\n disabled={!query.trim()}\n aria-label=\"Ask AI\"\n title=\"Ask AI\"\n >\n Ask AI\n <StyledAskKeys aria-hidden=\"true\">\n <StyledAskKbd>⌥</StyledAskKbd>\n <StyledAskKbd>\n <StyledReturnGlyph>↵</StyledReturnGlyph>\n </StyledAskKbd>\n </StyledAskKeys>\n </StyledAskAssistant>\n )}\n <IconButton\n onClick={closeSearch}\n aria-label=\"Close search\"\n title=\"Close search\"\n >\n <X />\n </IconButton>\n </StyledInputWrapper>\n {merged.length > 0 ? (\n <StyledResults ref={resultsRef}>\n {indicator && (\n <StyledHighlight\n aria-hidden=\"true\"\n style={{\n transform: `translateY(${indicator.top}px)`,\n height: `${indicator.height}px`,\n }}\n />\n )}\n {merged.map((result, index) => (\n <StyledResultItem\n key={result.page.slug + result.page.section}\n $isActive={index === activeIndex}\n onClick={(e) => navigate(result.page.slug, e)}\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";
|
|
@@ -5,7 +5,10 @@ import { Search, X } from "lucide-react";
|
|
|
5
5
|
import { IconButton } from "cherry-styled-components";
|
|
6
6
|
import { mq, Theme } from "@/app/theme";
|
|
7
7
|
import { Spinner } from "@/components/Spinner";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
interactiveStyles,
|
|
10
|
+
thinScrollbar,
|
|
11
|
+
} from "@/components/layout/SharedStyled";
|
|
9
12
|
|
|
10
13
|
export interface PageItem {
|
|
11
14
|
slug: string;
|
|
@@ -33,7 +36,12 @@ export interface SearchModalContentProps {
|
|
|
33
36
|
merged: MergedResult[];
|
|
34
37
|
sectionLabels: Record<string, string>;
|
|
35
38
|
isSearching: boolean;
|
|
36
|
-
navigate: (
|
|
39
|
+
navigate: (
|
|
40
|
+
slug: string,
|
|
41
|
+
modifiers?: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean },
|
|
42
|
+
) => void;
|
|
43
|
+
canAskAssistant: boolean;
|
|
44
|
+
onAskAssistant: () => void;
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
const ANIMATION_MS = 300;
|
|
@@ -127,8 +135,74 @@ const StyledInput = styled.input<{ theme: Theme }>\`
|
|
|
127
135
|
}
|
|
128
136
|
\`;
|
|
129
137
|
|
|
138
|
+
// "Ask AI" affordance shown before the close button when the AI chat is
|
|
139
|
+
// enabled. Desktop-only (like the header's Cmd+K hint) since it advertises the
|
|
140
|
+
// Option+Enter shortcut; mobile users reach the assistant from the header CTA.
|
|
141
|
+
const StyledAskAssistant = styled.button<{ theme: Theme }>\`
|
|
142
|
+
\${interactiveStyles};
|
|
143
|
+
display: none;
|
|
144
|
+
align-items: center;
|
|
145
|
+
gap: 6px;
|
|
146
|
+
flex-shrink: 0;
|
|
147
|
+
border: solid 1px \${({ theme }) => theme.colors.grayLight};
|
|
148
|
+
background: \${({ theme }) => theme.colors.light};
|
|
149
|
+
color: \${({ theme }) => theme.colors.grayDark};
|
|
150
|
+
border-radius: \${({ theme }) => theme.spacing.radius.xs};
|
|
151
|
+
padding: 5px 8px;
|
|
152
|
+
font-family: inherit;
|
|
153
|
+
font-size: \${({ theme }) => theme.fontSizes.small.lg};
|
|
154
|
+
font-weight: 500;
|
|
155
|
+
line-height: 1;
|
|
156
|
+
white-space: nowrap;
|
|
157
|
+
cursor: pointer;
|
|
158
|
+
|
|
159
|
+
&:disabled {
|
|
160
|
+
opacity: 0.5;
|
|
161
|
+
cursor: default;
|
|
162
|
+
|
|
163
|
+
&:hover {
|
|
164
|
+
border-color: \${({ theme }) => theme.colors.grayLight};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
\${mq("lg")} {
|
|
169
|
+
display: inline-flex;
|
|
170
|
+
}
|
|
171
|
+
\`;
|
|
172
|
+
|
|
173
|
+
const StyledAskKeys = styled.span\`
|
|
174
|
+
display: inline-flex;
|
|
175
|
+
align-items: center;
|
|
176
|
+
gap: 3px;
|
|
177
|
+
\`;
|
|
178
|
+
|
|
179
|
+
const StyledAskKbd = styled.kbd<{ theme: Theme }>\`
|
|
180
|
+
font-family: inherit;
|
|
181
|
+
font-size: 12px;
|
|
182
|
+
font-weight: 600;
|
|
183
|
+
line-height: 1;
|
|
184
|
+
min-width: 18px;
|
|
185
|
+
height: 18px;
|
|
186
|
+
padding: 0 4px;
|
|
187
|
+
display: inline-flex;
|
|
188
|
+
align-items: center;
|
|
189
|
+
justify-content: center;
|
|
190
|
+
background: \${({ theme }) => theme.colors.grayLight};
|
|
191
|
+
color: \${({ theme }) => theme.colors.grayDark};
|
|
192
|
+
border-radius: 4px;
|
|
193
|
+
\`;
|
|
194
|
+
|
|
195
|
+
// The return glyph's ink sits high in its em box, so it reads top-aligned even
|
|
196
|
+
// when the keycap flex-centers it. Nudge just the character (not the pill) down
|
|
197
|
+
// a pixel so it optically centers and lines up with the option glyph.
|
|
198
|
+
const StyledReturnGlyph = styled.span\`
|
|
199
|
+
display: inline-block;
|
|
200
|
+
transform: translateY(1.5px);
|
|
201
|
+
\`;
|
|
202
|
+
|
|
130
203
|
const StyledResults = styled.ul<{ theme: Theme }>\`
|
|
131
204
|
list-style: none;
|
|
205
|
+
position: relative;
|
|
132
206
|
margin: 8px 0 0 0;
|
|
133
207
|
padding: 0 8px;
|
|
134
208
|
overflow-y: auto;
|
|
@@ -147,26 +221,44 @@ const StyledResults = styled.ul<{ theme: Theme }>\`
|
|
|
147
221
|
}
|
|
148
222
|
\`;
|
|
149
223
|
|
|
224
|
+
// Single indicator that slides between result items instead of each item
|
|
225
|
+
// fading its own background in and out. Positioned/sized inline from the active
|
|
226
|
+
// item's measured offset (see the layout effect below); the transition on
|
|
227
|
+
// transform + height produces the up/down glide as the active item changes.
|
|
228
|
+
const StyledHighlight = styled.div<{ theme: Theme }>\`
|
|
229
|
+
position: absolute;
|
|
230
|
+
left: 8px;
|
|
231
|
+
right: 8px;
|
|
232
|
+
top: 0;
|
|
233
|
+
z-index: 0;
|
|
234
|
+
pointer-events: none;
|
|
235
|
+
border-radius: \${({ theme }) => theme.spacing.radius.xs};
|
|
236
|
+
background: \${({ theme }) =>
|
|
237
|
+
\`color-mix(in srgb, \${theme.colors.primaryLight} 20%, transparent)\`};
|
|
238
|
+
transition:
|
|
239
|
+
transform 0.2s cubic-bezier(0.22, 1, 0.36, 1),
|
|
240
|
+
height 0.2s cubic-bezier(0.22, 1, 0.36, 1);
|
|
241
|
+
will-change: transform, height;
|
|
242
|
+
|
|
243
|
+
@media (prefers-reduced-motion: reduce) {
|
|
244
|
+
transition: none;
|
|
245
|
+
}
|
|
246
|
+
\`;
|
|
247
|
+
|
|
150
248
|
const StyledResultItem = styled.li<{ theme: Theme; $isActive: boolean }>\`
|
|
249
|
+
position: relative;
|
|
250
|
+
z-index: 1;
|
|
151
251
|
padding: 10px 12px;
|
|
152
252
|
border-radius: \${({ theme }) => theme.spacing.radius.xs};
|
|
153
253
|
cursor: pointer;
|
|
154
|
-
transition: background 0.15s ease;
|
|
155
254
|
|
|
156
255
|
\${({ $isActive, theme }) =>
|
|
157
256
|
$isActive &&
|
|
158
257
|
css\`
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
transparent
|
|
163
|
-
);
|
|
258
|
+
& > span:first-of-type {
|
|
259
|
+
color: \${theme.colors.primary};
|
|
260
|
+
}
|
|
164
261
|
\`}
|
|
165
|
-
|
|
166
|
-
&:hover {
|
|
167
|
-
background: \${({ theme }) =>
|
|
168
|
-
\`color-mix(in srgb, \${theme.colors.primaryLight} 15%, transparent)\`};
|
|
169
|
-
}
|
|
170
262
|
\`;
|
|
171
263
|
|
|
172
264
|
const StyledResultTitle = styled.span<{ theme: Theme }>\`
|
|
@@ -174,6 +266,7 @@ const StyledResultTitle = styled.span<{ theme: Theme }>\`
|
|
|
174
266
|
font-weight: 500;
|
|
175
267
|
color: \${({ theme }) => theme.colors.dark};
|
|
176
268
|
display: block;
|
|
269
|
+
transition: color 0.15s ease;
|
|
177
270
|
\`;
|
|
178
271
|
|
|
179
272
|
const StyledResultMeta = styled.span<{ theme: Theme }>\`
|
|
@@ -246,7 +339,51 @@ export function SearchModalContent({
|
|
|
246
339
|
sectionLabels,
|
|
247
340
|
isSearching,
|
|
248
341
|
navigate,
|
|
342
|
+
canAskAssistant,
|
|
343
|
+
onAskAssistant,
|
|
249
344
|
}: SearchModalContentProps) {
|
|
345
|
+
// Measure the active item so the sliding highlight can be placed over it.
|
|
346
|
+
// useLayoutEffect runs before paint, so the first position is committed
|
|
347
|
+
// without a transition (no slide-in on open); later activeIndex/hover
|
|
348
|
+
// changes glide because only the transform + height differ.
|
|
349
|
+
const [indicator, setIndicator] = React.useState<{
|
|
350
|
+
top: number;
|
|
351
|
+
height: number;
|
|
352
|
+
} | null>(null);
|
|
353
|
+
|
|
354
|
+
React.useLayoutEffect(() => {
|
|
355
|
+
const list = resultsRef.current;
|
|
356
|
+
if (!list) {
|
|
357
|
+
setIndicator(null);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const items = list.querySelectorAll("li");
|
|
361
|
+
const active = items[activeIndex] as HTMLElement | undefined;
|
|
362
|
+
if (!active) {
|
|
363
|
+
setIndicator(null);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
setIndicator({ top: active.offsetTop, height: active.offsetHeight });
|
|
367
|
+
|
|
368
|
+
// Keep the active row inside the scroll viewport in the SAME pre-paint pass
|
|
369
|
+
// that positions the highlight, so the two never desync into a one-frame
|
|
370
|
+
// flicker. merged is a dependency, so re-filtering (a new query) also
|
|
371
|
+
// re-scrolls the reset active row into view even when its index is
|
|
372
|
+
// unchanged. This list is flat (no group headers), so no header inclusion
|
|
373
|
+
// is needed; a small margin keeps rows off the container's hard edges.
|
|
374
|
+
// Adjust scrollTop directly (never scrollIntoView, which can also pan
|
|
375
|
+
// ancestors).
|
|
376
|
+
const margin = 8;
|
|
377
|
+
const viewTop = active.offsetTop - margin;
|
|
378
|
+
const viewBottom = active.offsetTop + active.offsetHeight + margin;
|
|
379
|
+
|
|
380
|
+
if (viewTop < list.scrollTop) {
|
|
381
|
+
list.scrollTop = viewTop;
|
|
382
|
+
} else if (viewBottom > list.scrollTop + list.clientHeight) {
|
|
383
|
+
list.scrollTop = viewBottom - list.clientHeight;
|
|
384
|
+
}
|
|
385
|
+
}, [activeIndex, merged, resultsRef]);
|
|
386
|
+
|
|
250
387
|
return (
|
|
251
388
|
<StyledBackdrop
|
|
252
389
|
$isClosing={isClosing}
|
|
@@ -275,6 +412,23 @@ export function SearchModalContent({
|
|
|
275
412
|
autoComplete="off"
|
|
276
413
|
spellCheck={false}
|
|
277
414
|
/>
|
|
415
|
+
{canAskAssistant && (
|
|
416
|
+
<StyledAskAssistant
|
|
417
|
+
type="button"
|
|
418
|
+
onClick={onAskAssistant}
|
|
419
|
+
disabled={!query.trim()}
|
|
420
|
+
aria-label="Ask AI"
|
|
421
|
+
title="Ask AI"
|
|
422
|
+
>
|
|
423
|
+
Ask AI
|
|
424
|
+
<StyledAskKeys aria-hidden="true">
|
|
425
|
+
<StyledAskKbd>⌥</StyledAskKbd>
|
|
426
|
+
<StyledAskKbd>
|
|
427
|
+
<StyledReturnGlyph>↵</StyledReturnGlyph>
|
|
428
|
+
</StyledAskKbd>
|
|
429
|
+
</StyledAskKeys>
|
|
430
|
+
</StyledAskAssistant>
|
|
431
|
+
)}
|
|
278
432
|
<IconButton
|
|
279
433
|
onClick={closeSearch}
|
|
280
434
|
aria-label="Close search"
|
|
@@ -285,11 +439,20 @@ export function SearchModalContent({
|
|
|
285
439
|
</StyledInputWrapper>
|
|
286
440
|
{merged.length > 0 ? (
|
|
287
441
|
<StyledResults ref={resultsRef}>
|
|
442
|
+
{indicator && (
|
|
443
|
+
<StyledHighlight
|
|
444
|
+
aria-hidden="true"
|
|
445
|
+
style={{
|
|
446
|
+
transform: \`translateY(\${indicator.top}px)\`,
|
|
447
|
+
height: \`\${indicator.height}px\`,
|
|
448
|
+
}}
|
|
449
|
+
/>
|
|
450
|
+
)}
|
|
288
451
|
{merged.map((result, index) => (
|
|
289
452
|
<StyledResultItem
|
|
290
453
|
key={result.page.slug + result.page.section}
|
|
291
454
|
$isActive={index === activeIndex}
|
|
292
|
-
onClick={() => navigate(result.page.slug)}
|
|
455
|
+
onClick={(e) => navigate(result.page.slug, e)}
|
|
293
456
|
onMouseEnter={() => setActiveIndex(index)}
|
|
294
457
|
>
|
|
295
458
|
<StyledResultTitle>{result.page.title}</StyledResultTitle>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const sideBarTemplate = "\"use client\";\nimport { useContext, useEffect, useRef, useState } from \"react\";\nimport { usePathname } from \"next/navigation\";\nimport { Flex, Space, ThemeToggle } from \"cherry-styled-components\";\nimport {\n DocsSidebar,\n SectionBarContext,\n StyledSidebar,\n StyledSidebarList,\n StyledSidebarListItem,\n StyledStrong,\n StyledSidebarListItemLink,\n StyledSidebarGroupButton,\n StyledSidebarGroupRow,\n StyledSidebarGroupLink,\n StyledSidebarGroupChevron,\n StyledSidebarGroupContent,\n StyledSidebarFooter,\n StyleMobileBar,\n StyledMobileBurger,\n} from \"@/components/layout/DocsComponents\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { useLockBodyScroll } from \"@/components/LockBodyScroll\";\n\n// A link can be a leaf (slug + title) or a group with nested children. Both the\n// category icon and the per-link icon are optional Lucide names.\ntype NavItemLink = {\n slug?: string;\n title: string;\n icon?: string;\n links?: NavItemLink[];\n};\n\ntype NavItem = {\n label: string;\n icon?: string;\n links: NavItemLink[];\n};\n\ninterface SideBarProps {\n result: NavItem[];\n}\n\nfunction linkContainsActivePath(link: NavItemLink, pathname: string): boolean {\n if (link.slug !== undefined && pathname === `/${link.slug}`) {\n return true;\n }\n return (link.links ?? []).some((child) =>\n linkContainsActivePath(child, pathname),\n );\n}\n\nfunction SidebarNavLink({\n link,\n depth,\n pathname,\n onNavigate,\n}: {\n link: NavItemLink;\n depth: number;\n pathname: string;\n onNavigate: () => void;\n}) {\n const children = link.links ?? [];\n const hasChildren = children.length > 0;\n const href = link.slug !== undefined ? `/${link.slug}` : undefined;\n const isActive = href !== undefined && pathname === href;\n const indent = { paddingLeft: `${20 + depth * 14}px` };\n\n // Open collapsible groups that contain the active page so deep links land\n // with their ancestors already expanded.\n const [isOpen, setIsOpen] = useState(\n hasChildren\n ? children.some((child) => linkContainsActivePath(child, pathname))\n : false,\n );\n\n if (!hasChildren) {\n return (\n <StyledSidebarListItem>\n <StyledSidebarListItemLink\n href={href ?? \"#\"}\n $isActive={isActive}\n aria-current={isActive ? \"page\" : undefined}\n onClick={onNavigate}\n style={indent}\n >\n {link.icon && <Icon name={link.icon} size={16} />}\n {link.title}\n </StyledSidebarListItemLink>\n </StyledSidebarListItem>\n );\n }\n\n const toggle = () => setIsOpen((prev) => !prev);\n const toggleLabel = isOpen\n ? `Collapse ${link.title}`\n : `Expand ${link.title}`;\n const groupActive = linkContainsActivePath(link, pathname);\n\n return (\n <li>\n {href !== undefined ? (\n <StyledSidebarGroupRow\n $isActive={groupActive}\n $isOpen={isOpen}\n style={indent}\n >\n <StyledSidebarGroupLink\n href={href}\n aria-current={pathname === href ? \"page\" : undefined}\n onClick={onNavigate}\n >\n {link.icon && <Icon name={link.icon} size={16} />}\n {link.title}\n </StyledSidebarGroupLink>\n <StyledSidebarGroupChevron\n type=\"button\"\n onClick={toggle}\n aria-expanded={isOpen}\n aria-label={toggleLabel}\n >\n <Icon name=\"chevron-right\" size={16} />\n </StyledSidebarGroupChevron>\n </StyledSidebarGroupRow>\n ) : (\n <StyledSidebarGroupButton\n type=\"button\"\n onClick={toggle}\n $isActive={groupActive}\n $isOpen={isOpen}\n style={indent}\n aria-expanded={isOpen}\n aria-label={toggleLabel}\n >\n {link.icon && <Icon name={link.icon} size={16} />}\n {link.title}\n <Icon name=\"chevron-right\" size={16} />\n </StyledSidebarGroupButton>\n )}\n <StyledSidebarGroupContent $isOpen={isOpen}>\n {children.map((child: NavItemLink, index: number) => (\n <SidebarNavLink\n key={index}\n link={child}\n depth={depth + 1}\n pathname={pathname}\n onNavigate={onNavigate}\n />\n ))}\n </StyledSidebarGroupContent>\n </li>\n );\n}\n\nfunction SideBar({ result }: SideBarProps) {\n const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);\n const hasSectionBar = useContext(SectionBarContext);\n const pathname = usePathname();\n const navRef = useRef<HTMLElement>(null);\n const footerRef = useRef<HTMLDivElement>(null);\n\n useLockBodyScroll(isMobileMenuOpen);\n\n // Bring the current page's link into view within the sidebar's own scroll\n // area when it starts off-screen (deep pages, in-content links, search).\n // Scoped to the nav via scrollTo so the main document never jumps.\n useEffect(() => {\n const nav = navRef.current;\n if (!nav) return;\n const active = nav.querySelector<HTMLElement>('[aria-current=\"page\"]');\n if (!active) return;\n\n const navRect = nav.getBoundingClientRect();\n const activeRect = active.getBoundingClientRect();\n\n // The theme-toggle footer is sticky over the bottom of the scroll area\n // (~60px tall), so links underneath it are covered rather than visible.\n // Clamp the visible bottom to the footer's top edge when it's pinned.\n const footerRect = footerRef.current?.getBoundingClientRect();\n const visibleBottom = footerRect\n ? Math.min(navRect.bottom, footerRect.top)\n : navRect.bottom;\n\n const isOutOfView =\n activeRect.top < navRect.top || activeRect.bottom > visibleBottom;\n if (!isOutOfView) return;\n\n // Center within the visible band (nav top .. footer top), not the full\n // nav height, so the link never settles behind the footer.\n const visibleHeight = visibleBottom - navRect.top;\n const target =\n nav.scrollTop +\n (activeRect.top - navRect.top) -\n (visibleHeight - active.clientHeight) / 2;\n\n // Animate the scroll, but honor users who opt out of motion.\n const prefersReducedMotion = window.matchMedia(\n \"(prefers-reduced-motion: reduce)\",\n ).matches;\n nav.scrollTo({\n top: Math.max(0, target),\n behavior: prefersReducedMotion ? \"auto\" : \"smooth\",\n });\n }, [pathname]);\n\n return (\n <DocsSidebar>\n <StyleMobileBar\n onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}\n $isActive={isMobileMenuOpen}\n aria-label={\n isMobileMenuOpen ? \"Close navigation menu\" : \"Open navigation menu\"\n }\n aria-expanded={isMobileMenuOpen}\n >\n <StyledMobileBurger $isActive={isMobileMenuOpen} />\n </StyleMobileBar>\n\n <StyledSidebar\n ref={navRef}\n $isActive={isMobileMenuOpen}\n $hasSectionBar={hasSectionBar}\n >\n {result &&\n result.map((item: NavItem, index: number) => {\n return (\n <StyledSidebarList key={index}>\n <StyledSidebarListItem>\n <StyledStrong>\n {item.icon && <Icon name={item.icon} size={16} />}\n {item.label}\n </StyledStrong>{\" \"}\n </StyledSidebarListItem>\n <li>\n <Space $size={20} />\n </li>\n {item.links &&\n item.links.map((link: NavItemLink, indexChild: number) => (\n <SidebarNavLink\n key={indexChild}\n link={link}\n depth={0}\n pathname={pathname}\n onNavigate={() => setIsMobileMenuOpen(false)}\n />\n ))}\n <li aria-hidden=\"true\">\n <Space $size={20} />\n </li>\n </StyledSidebarList>\n );\n })}\n <StyledSidebarFooter ref={footerRef}>\n <Flex $xsJustifyContent=\"flex-start\" $lgJustifyContent=\"flex-end\">\n <ThemeToggle />\n </Flex>\n </StyledSidebarFooter>\n </StyledSidebar>\n </DocsSidebar>\n );\n}\n\nexport { SideBar };\n";
|
|
1
|
+
export declare const sideBarTemplate = "\"use client\";\nimport { useContext, useEffect, useRef, useState } from \"react\";\nimport { usePathname } from \"next/navigation\";\nimport { Flex, Space, ThemeToggle } from \"cherry-styled-components\";\nimport {\n DocsSidebar,\n SectionBarContext,\n StyledSidebar,\n StyledSidebarList,\n StyledSidebarListItem,\n StyledStrong,\n StyledSidebarListItemLink,\n StyledSidebarGroupButton,\n StyledSidebarGroupRow,\n StyledSidebarGroupLink,\n StyledSidebarGroupChevron,\n StyledSidebarGroupContent,\n StyledSidebarFooter,\n StyleMobileBar,\n StyledMobileBurger,\n} from \"@/components/layout/DocsComponents\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { useLockBodyScroll } from \"@/components/LockBodyScroll\";\n\n// A link can be a leaf (slug + title) or a group with nested children. Both the\n// category icon and the per-link icon are optional Lucide names.\ntype NavItemLink = {\n slug?: string;\n title: string;\n icon?: string;\n links?: NavItemLink[];\n};\n\ntype NavItem = {\n label: string;\n icon?: string;\n links: NavItemLink[];\n};\n\ninterface SideBarProps {\n result: NavItem[];\n}\n\nfunction linkContainsActivePath(link: NavItemLink, pathname: string): boolean {\n if (link.slug !== undefined && pathname === `/${link.slug}`) {\n return true;\n }\n return (link.links ?? []).some((child) =>\n linkContainsActivePath(child, pathname),\n );\n}\n\nfunction SidebarNavLink({\n link,\n depth,\n pathname,\n onNavigate,\n}: {\n link: NavItemLink;\n depth: number;\n pathname: string;\n onNavigate: () => void;\n}) {\n const children = link.links ?? [];\n const hasChildren = children.length > 0;\n const href = link.slug !== undefined ? `/${link.slug}` : undefined;\n const isActive = href !== undefined && pathname === href;\n const indent = { paddingLeft: `${20 + depth * 14}px` };\n\n // Open collapsible groups that contain the active page so deep links land\n // with their ancestors already expanded.\n const [isOpen, setIsOpen] = useState(\n hasChildren\n ? children.some((child) => linkContainsActivePath(child, pathname))\n : false,\n );\n\n if (!hasChildren) {\n return (\n <StyledSidebarListItem>\n <StyledSidebarListItemLink\n href={href ?? \"#\"}\n $isActive={isActive}\n aria-current={isActive ? \"page\" : undefined}\n onClick={onNavigate}\n style={indent}\n >\n {link.icon && <Icon name={link.icon} size={16} />}\n {link.title}\n </StyledSidebarListItemLink>\n </StyledSidebarListItem>\n );\n }\n\n const toggle = () => setIsOpen((prev) => !prev);\n const toggleLabel = isOpen\n ? `Collapse ${link.title}`\n : `Expand ${link.title}`;\n const groupActive = linkContainsActivePath(link, pathname);\n\n return (\n <li>\n {href !== undefined ? (\n <StyledSidebarGroupRow\n $isActive={groupActive}\n $isOpen={isOpen}\n style={indent}\n >\n <StyledSidebarGroupLink\n href={href}\n aria-current={pathname === href ? \"page\" : undefined}\n onClick={onNavigate}\n >\n {link.icon && <Icon name={link.icon} size={16} />}\n {link.title}\n </StyledSidebarGroupLink>\n <StyledSidebarGroupChevron\n type=\"button\"\n onClick={toggle}\n aria-expanded={isOpen}\n aria-label={toggleLabel}\n >\n <Icon name=\"chevron-right\" size={16} />\n </StyledSidebarGroupChevron>\n </StyledSidebarGroupRow>\n ) : (\n <StyledSidebarGroupButton\n type=\"button\"\n onClick={toggle}\n $isActive={groupActive}\n $isOpen={isOpen}\n style={indent}\n aria-expanded={isOpen}\n aria-label={toggleLabel}\n >\n {link.icon && <Icon name={link.icon} size={16} />}\n {link.title}\n <Icon name=\"chevron-right\" size={16} />\n </StyledSidebarGroupButton>\n )}\n <StyledSidebarGroupContent $isOpen={isOpen}>\n {children.map((child: NavItemLink, index: number) => (\n <SidebarNavLink\n key={index}\n link={child}\n depth={depth + 1}\n pathname={pathname}\n onNavigate={onNavigate}\n />\n ))}\n </StyledSidebarGroupContent>\n </li>\n );\n}\n\nfunction SideBar({ result }: SideBarProps) {\n const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);\n const hasSectionBar = useContext(SectionBarContext);\n const pathname = usePathname();\n const navRef = useRef<HTMLElement>(null);\n const footerRef = useRef<HTMLDivElement>(null);\n\n useLockBodyScroll(isMobileMenuOpen);\n\n // Bring the current page's link into view within the sidebar's own scroll\n // area when it starts off-screen (deep pages, in-content links, search).\n // Scoped to the nav via scrollTo so the main document never jumps.\n useEffect(() => {\n const nav = navRef.current;\n if (!nav) return;\n const active = nav.querySelector<HTMLElement>('[aria-current=\"page\"]');\n if (!active) return;\n\n const navRect = nav.getBoundingClientRect();\n const activeRect = active.getBoundingClientRect();\n\n // The theme-toggle footer is sticky over the bottom of the scroll area\n // (~60px tall), so links underneath it are covered rather than visible.\n // Clamp the visible bottom to the footer's top edge when it's pinned.\n const footerRect = footerRef.current?.getBoundingClientRect();\n const visibleBottom = footerRect\n ? Math.min(navRect.bottom, footerRect.top)\n : navRect.bottom;\n\n const isOutOfView =\n activeRect.top < navRect.top || activeRect.bottom > visibleBottom;\n if (!isOutOfView) return;\n\n // Center within the visible band (nav top .. footer top), not the full\n // nav height, so the link never settles behind the footer.\n const visibleHeight = visibleBottom - navRect.top;\n const target =\n nav.scrollTop +\n (activeRect.top - navRect.top) -\n (visibleHeight - active.clientHeight) / 2;\n\n // Animate the scroll, but honor users who opt out of motion.\n const prefersReducedMotion = window.matchMedia(\n \"(prefers-reduced-motion: reduce)\",\n ).matches;\n nav.scrollTo({\n top: Math.max(0, target),\n behavior: prefersReducedMotion ? \"auto\" : \"smooth\",\n });\n }, [pathname]);\n\n return (\n <DocsSidebar>\n <StyleMobileBar\n onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}\n $isActive={isMobileMenuOpen}\n aria-label={\n isMobileMenuOpen ? \"Close navigation menu\" : \"Open navigation menu\"\n }\n aria-expanded={isMobileMenuOpen}\n >\n <StyledMobileBurger $isActive={isMobileMenuOpen} />\n </StyleMobileBar>\n\n <StyledSidebar\n ref={navRef}\n $isActive={isMobileMenuOpen}\n $hasSectionBar={hasSectionBar}\n >\n {result &&\n result.map((item: NavItem, index: number) => {\n return (\n <StyledSidebarList key={index}>\n <StyledSidebarListItem>\n <StyledStrong>\n {item.icon && <Icon name={item.icon} size={16} />}\n {item.label}\n </StyledStrong>{\" \"}\n </StyledSidebarListItem>\n <li>\n <Space $size={20} />\n </li>\n {item.links &&\n item.links.map((link: NavItemLink, indexChild: number) => (\n <SidebarNavLink\n key={indexChild}\n link={link}\n depth={0}\n pathname={pathname}\n onNavigate={() => setIsMobileMenuOpen(false)}\n />\n ))}\n <li aria-hidden=\"true\">\n <Space $size={20} />\n </li>\n </StyledSidebarList>\n );\n })}\n <StyledSidebarFooter ref={footerRef}>\n <Flex $xsJustifyContent=\"flex-start\" $lgJustifyContent=\"flex-end\">\n <ThemeToggle $shortcut />\n </Flex>\n </StyledSidebarFooter>\n </StyledSidebar>\n </DocsSidebar>\n );\n}\n\nexport { SideBar };\n";
|
|
@@ -253,7 +253,7 @@ function SideBar({ result }: SideBarProps) {
|
|
|
253
253
|
})}
|
|
254
254
|
<StyledSidebarFooter ref={footerRef}>
|
|
255
255
|
<Flex $xsJustifyContent="flex-start" $lgJustifyContent="flex-end">
|
|
256
|
-
<ThemeToggle />
|
|
256
|
+
<ThemeToggle $shortcut />
|
|
257
257
|
</Flex>
|
|
258
258
|
</StyledSidebarFooter>
|
|
259
259
|
</StyledSidebar>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const siteGateComponentTemplate = "\"use client\";\nimport { useState, type FormEvent } from \"react\";\nimport styled from \"styled-components\";\nimport { Input, ThemeToggle } from \"cherry-styled-components\";\nimport { Lock } from \"lucide-react\";\nimport { Theme } from \"@/app/theme\";\nimport { Button } from \"@/components/layout/Button\";\nimport { Callout } from \"@/components/layout/Callout\";\nimport { config } from \"@/utils/config\";\n\nconst StyledWrapper = styled.div<{ theme: Theme }>`\n min-height: 100dvh;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 20px;\n background: ${({ theme }) => theme.colors.light};\n`;\n\nconst StyledInner = styled.div`\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 20px;\n width: 100%;\n max-width: 380px;\n`;\n\nconst StyledFooter = styled.div`\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 12px;\n`;\n\nconst StyledBranding = styled.p<{ theme: Theme }>`\n margin: 0;\n font-size: 14px;\n color: ${({ theme }) => theme.colors.grayDark};\n\n & a {\n color: ${({ theme }) => theme.colors.accent};\n font-weight: 700;\n text-decoration: none;\n transition: all 0.3s ease;\n }\n\n & a:hover {\n color: ${({ theme }) => theme.colors.primary};\n }\n`;\n\nconst StyledCard = styled.div<{ theme: Theme }>`\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 20px;\n width: 100%;\n padding: 40px 32px;\n background: ${({ theme }) => theme.colors.light};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n box-shadow: ${({ theme }) => theme.shadows.sm};\n text-align: center;\n`;\n\nconst StyledIcon = styled.div<{ theme: Theme }>`\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 48px;\n height: 48px;\n border-radius: 50%;\n color: ${({ theme }) => theme.colors.accent};\n background: ${({ theme }) => theme.colors.grayLight};\n\n & svg {\n width: 22px;\n height: 22px;\n }\n`;\n\nconst StyledTitle = styled.h1<{ theme: Theme }>`\n margin: 0;\n font-size: 22px;\n color: ${({ theme }) => theme.colors.accent};\n`;\n\nconst StyledLede = styled.p<{ theme: Theme }>`\n margin: 0;\n font-size: 15px;\n color: ${({ theme }) => theme.colors.grayDark};\n`;\n\nconst StyledForm = styled.form`\n display: flex;\n flex-direction: column;\n gap: 20px;\n width: 100%;\n`;\n\nconst StyledAlert = styled.div`\n width: 100%;\n text-align: left;\n`;\n\nexport function SiteGate({ hideBranding }: { hideBranding?: boolean }) {\n const [password, setPassword] = useState(\"\");\n const [status, setStatus] = useState<\"idle\" | \"loading\" | \"error\">(\"idle\");\n\n async function handleSubmit(event: FormEvent<HTMLFormElement>) {\n event.preventDefault();\n if (!password || status === \"loading\") return;\n\n setStatus(\"loading\");\n try {\n const res = await fetch(\"/api/gate\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ password }),\n });\n if (res.ok) {\n window.location.reload();\n return;\n }\n setStatus(\"error\");\n } catch {\n setStatus(\"error\");\n }\n }\n\n return (\n <StyledWrapper>\n <StyledInner>\n <StyledCard>\n <StyledIcon>\n <Lock />\n </StyledIcon>\n <StyledTitle>{config.name || \"Documentation\"}</StyledTitle>\n <StyledLede>This site is password protected.</StyledLede>\n <StyledForm onSubmit={handleSubmit} noValidate>\n <Input\n type=\"password\"\n name=\"password\"\n autoComplete=\"current-password\"\n placeholder=\"Enter password\"\n aria-label=\"Password\"\n autoFocus\n value={password}\n $error={status === \"error\"}\n $fullWidth\n onChange={(e) => {\n setPassword(e.target.value);\n if (status === \"error\") setStatus(\"idle\");\n }}\n />\n <Button\n type=\"submit\"\n fullWidth\n disabled={status === \"loading\" || !password}\n >\n {status === \"loading\" ? \"Unlocking...\" : \"Enter\"}\n </Button>\n </StyledForm>\n {status === \"error\" && (\n <StyledAlert>\n <Callout type=\"danger\">\n <p>Incorrect password. Try again.</p>\n </Callout>\n </StyledAlert>\n )}\n </StyledCard>\n <StyledFooter>\n <ThemeToggle />\n {!hideBranding && (\n <StyledBranding>\n Powered by{\" \"}\n <a\n href=\"https://doccupine.com\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n >\n Doccupine\n </a>\n </StyledBranding>\n )}\n </StyledFooter>\n </StyledInner>\n </StyledWrapper>\n );\n}\n";
|
|
1
|
+
export declare const siteGateComponentTemplate = "\"use client\";\nimport { useState, type FormEvent } from \"react\";\nimport styled from \"styled-components\";\nimport { Input, ThemeToggle } from \"cherry-styled-components\";\nimport { Lock } from \"lucide-react\";\nimport { Theme } from \"@/app/theme\";\nimport { Button } from \"@/components/layout/Button\";\nimport { Callout } from \"@/components/layout/Callout\";\nimport { config } from \"@/utils/config\";\n\nconst StyledWrapper = styled.div<{ theme: Theme }>`\n min-height: 100dvh;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 20px;\n background: ${({ theme }) => theme.colors.light};\n`;\n\nconst StyledInner = styled.div`\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 20px;\n width: 100%;\n max-width: 380px;\n`;\n\nconst StyledFooter = styled.div`\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 12px;\n`;\n\nconst StyledBranding = styled.p<{ theme: Theme }>`\n margin: 0;\n font-size: 14px;\n color: ${({ theme }) => theme.colors.grayDark};\n\n & a {\n color: ${({ theme }) => theme.colors.accent};\n font-weight: 700;\n text-decoration: none;\n transition: all 0.3s ease;\n }\n\n & a:hover {\n color: ${({ theme }) => theme.colors.primary};\n }\n`;\n\nconst StyledCard = styled.div<{ theme: Theme }>`\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 20px;\n width: 100%;\n padding: 40px 32px;\n background: ${({ theme }) => theme.colors.light};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n box-shadow: ${({ theme }) => theme.shadows.sm};\n text-align: center;\n`;\n\nconst StyledIcon = styled.div<{ theme: Theme }>`\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 48px;\n height: 48px;\n border-radius: 50%;\n color: ${({ theme }) => theme.colors.accent};\n background: ${({ theme }) => theme.colors.grayLight};\n\n & svg {\n width: 22px;\n height: 22px;\n }\n`;\n\nconst StyledTitle = styled.h1<{ theme: Theme }>`\n margin: 0;\n font-size: 22px;\n color: ${({ theme }) => theme.colors.accent};\n`;\n\nconst StyledLede = styled.p<{ theme: Theme }>`\n margin: 0;\n font-size: 15px;\n color: ${({ theme }) => theme.colors.grayDark};\n`;\n\nconst StyledForm = styled.form`\n display: flex;\n flex-direction: column;\n gap: 20px;\n width: 100%;\n`;\n\nconst StyledAlert = styled.div`\n width: 100%;\n text-align: left;\n`;\n\nexport function SiteGate({ hideBranding }: { hideBranding?: boolean }) {\n const [password, setPassword] = useState(\"\");\n const [status, setStatus] = useState<\"idle\" | \"loading\" | \"error\">(\"idle\");\n\n async function handleSubmit(event: FormEvent<HTMLFormElement>) {\n event.preventDefault();\n if (!password || status === \"loading\") return;\n\n setStatus(\"loading\");\n try {\n const res = await fetch(\"/api/gate\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ password }),\n });\n if (res.ok) {\n window.location.reload();\n return;\n }\n setStatus(\"error\");\n } catch {\n setStatus(\"error\");\n }\n }\n\n return (\n <StyledWrapper>\n <StyledInner>\n <StyledCard>\n <StyledIcon>\n <Lock />\n </StyledIcon>\n <StyledTitle>{config.name || \"Documentation\"}</StyledTitle>\n <StyledLede>This site is password protected.</StyledLede>\n <StyledForm onSubmit={handleSubmit} noValidate>\n <Input\n type=\"password\"\n name=\"password\"\n autoComplete=\"current-password\"\n placeholder=\"Enter password\"\n aria-label=\"Password\"\n autoFocus\n value={password}\n $error={status === \"error\"}\n $fullWidth\n onChange={(e) => {\n setPassword(e.target.value);\n if (status === \"error\") setStatus(\"idle\");\n }}\n />\n <Button\n type=\"submit\"\n fullWidth\n disabled={status === \"loading\" || !password}\n >\n {status === \"loading\" ? \"Unlocking...\" : \"Enter\"}\n </Button>\n </StyledForm>\n {status === \"error\" && (\n <StyledAlert>\n <Callout type=\"danger\">\n <p>Incorrect password. Try again.</p>\n </Callout>\n </StyledAlert>\n )}\n </StyledCard>\n <StyledFooter>\n <ThemeToggle $shortcut />\n {!hideBranding && (\n <StyledBranding>\n Powered by{\" \"}\n <a\n href=\"https://doccupine.com\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n >\n Doccupine\n </a>\n </StyledBranding>\n )}\n </StyledFooter>\n </StyledInner>\n </StyledWrapper>\n );\n}\n";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const platformAiAssistantMdxTemplate = "---\ntitle: \"AI Assistant\"\ndescription: \"Configure the built-in AI assistant that ships with every Doccupine documentation site.\"\ndate: \"2026-02-19\"\ncategory: \"Configuration\"\ncategoryOrder: 2\norder: 6\nsection: \"Platform\"\n---\n\n# AI Assistant\n\nEvery Doccupine site ships with a built-in AI assistant that helps visitors find answers across your documentation. The AI settings page lets you choose how it's powered.\n\n## Modes\n\n### Platform (default)\n\nUses Doccupine's built-in integration. Zero configuration needed - the AI assistant works out of the box with no API keys or setup.\n\nEach plan includes a monthly AI usage budget:\n\n| Plan | Monthly Budget |\n| ---------- | -------------- |\n| Trial | $2 |\n| Pro | $20 |\n| Enterprise | $50 |\n\nThe AI settings page shows a usage dashboard with your current spending and remaining budget. Usage resets automatically with your billing cycle.\n\n#### AI credit top-ups\n\nIf you run out of AI credits before your billing cycle resets, you can purchase a one-time top-up to increase your monthly limit. Available tiers:\n\n- **$5**\n- **$10**\n- **$20**\n\nTop-ups are added to your current cycle's budget immediately after purchase and reset when your billing cycle renews. You can purchase multiple top-ups in the same cycle.\n\n### Custom\n\nBring your own API key for full control over the AI model. Supported providers:\n\n- **OpenAI**\n- **Anthropic**\n- **Google**\n\nIn Custom mode, you can also configure:\n\n- **Embedding model** - the model used to index your documentation content\n- **Temperature** - controls response creativity (0.0 for focused answers, up to 1.0 for more varied responses)\n\nFor a complete list of available models, refer to the official documentation of your chosen provider.\n\n### Off\n\nCompletely disables the AI assistant on your site.\n\n<Callout type=\"warning\">\n AI settings are stored as environment variables on your deployment, not in a JSON file. After saving, a redeploy is triggered automatically to apply the changes.\n</Callout>\n\n
|
|
1
|
+
export declare const platformAiAssistantMdxTemplate = "---\ntitle: \"AI Assistant\"\ndescription: \"Configure the built-in AI assistant that ships with every Doccupine documentation site.\"\ndate: \"2026-02-19\"\ncategory: \"Configuration\"\ncategoryOrder: 2\norder: 6\nsection: \"Platform\"\n---\n\n# AI Assistant\n\nEvery Doccupine site ships with a built-in AI assistant that helps visitors find answers across your documentation. The AI settings page lets you choose how it's powered.\n\n## Modes\n\n### Platform (default)\n\nUses Doccupine's built-in integration. Zero configuration needed - the AI assistant works out of the box with no API keys or setup.\n\nEach plan includes a monthly AI usage budget:\n\n| Plan | Monthly Budget |\n| ---------- | -------------- |\n| Trial | $2 |\n| Pro | $20 |\n| Enterprise | $50 |\n\nThe AI settings page shows a usage dashboard with your current spending and remaining budget. Usage resets automatically with your billing cycle.\n\n#### AI credit top-ups\n\nIf you run out of AI credits before your billing cycle resets, you can purchase a one-time top-up to increase your monthly limit. Available tiers:\n\n- **$5**\n- **$10**\n- **$20**\n\nTop-ups are added to your current cycle's budget immediately after purchase and reset when your billing cycle renews. You can purchase multiple top-ups in the same cycle.\n\n### Custom\n\nBring your own API key for full control over the AI model. Supported providers:\n\n- **OpenAI**\n- **Anthropic**\n- **Google**\n\nIn Custom mode, you can also configure:\n\n- **Embedding model** - the model used to index your documentation content\n- **Temperature** - controls response creativity (0.0 for focused answers, up to 1.0 for more varied responses)\n\nFor a complete list of available models, refer to the official documentation of your chosen provider.\n\n### Off\n\nCompletely disables the AI assistant on your site.\n\n<Callout type=\"warning\">\n AI settings are stored as environment variables on your deployment, not in a JSON file. After saving, a redeploy is triggered automatically to apply the changes.\n</Callout>\n\n<Callout type=\"note\">\n Connecting external AI tools (Claude, Cursor, and others) to your site is configured on the dedicated [MCP](/platform/mcp) page, which also covers MCP server authentication.\n</Callout>";
|
|
@@ -61,12 +61,6 @@ Completely disables the AI assistant on your site.
|
|
|
61
61
|
AI settings are stored as environment variables on your deployment, not in a JSON file. After saving, a redeploy is triggered automatically to apply the changes.
|
|
62
62
|
</Callout>
|
|
63
63
|
|
|
64
|
-
## MCP server authentication
|
|
65
|
-
|
|
66
|
-
Every Doccupine site exposes an MCP (Model Context Protocol) endpoint at \`/api/mcp\`. This lets external AI tools query your documentation programmatically.
|
|
67
|
-
|
|
68
|
-
You can set an optional **API key** to restrict access to the MCP endpoint. When set, requests must include the key in their authorization header.
|
|
69
|
-
|
|
70
64
|
<Callout type="note">
|
|
71
|
-
|
|
65
|
+
Connecting external AI tools (Claude, Cursor, and others) to your site is configured on the dedicated [MCP](/platform/mcp) page, which also covers MCP server authentication.
|
|
72
66
|
</Callout>`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const platformMcpMdxTemplate = "---\ntitle: \"MCP\"\ndescription: \"Connect external AI apps like Claude and Cursor to your documentation with a Model Context Protocol server, and protect it with an API key.\"\ndate: \"2026-07-12\"\ncategory: \"Configuration\"\ncategoryOrder: 2\norder: 7\nsection: \"Platform\"\n---\n\n# MCP\n\nThe **MCP** settings page lets external AI applications connect to your documentation through a hosted Model Context Protocol (MCP) server. Every Doccupine site exposes an MCP endpoint at `/api/mcp` that AI tools can query to search and read your content.\n\n## Connect your site with AI apps\n\nThe **Connect your site with AI apps** card gives you a ready-to-paste MCP configuration for popular AI tools. Pick a tab, copy the snippet, and add it to that tool's MCP config:\n\n- **Claude** - add the configuration to Claude Desktop's `claude_desktop_config.json`, or add the server URL as a connector from Claude's settings.\n- **Cursor** - add the configuration to Cursor's `~/.cursor/mcp.json`, or connect it from Cursor's MCP settings.\n- **Others** - a generic `mcpServers` block for any MCP-compatible application.\n\nEvery snippet points to your site's own MCP endpoint, for example:\n\n```json\n{\n \"mcpServers\": {\n \"Your Project\": { \"url\": \"https://your-site.com/api/mcp\" }\n }\n}\n```\n\n<Callout type=\"note\">\n The connection snippet appears once your site has been deployed. If you haven't published yet, deploy your site first to get its MCP server URL.\n</Callout>\n\n## MCP server authentication\n\nBy default your MCP endpoint is publicly accessible. You can restrict access with an API key:\n\n1. Enable **Require API key for MCP access**.\n2. Enter an API key.\n3. Save.\n\nOnce enabled, clients must include the key as a Bearer token in the `Authorization` header of every request to `/api/mcp`.\n\n<Callout type=\"warning\">\n The API key is stored as an environment variable (`DOCS_API_KEY`) on your deployment, not in a JSON file. After saving, a redeploy is triggered automatically to apply the change.\n</Callout>\n\n<Callout type=\"note\">\n For the full reference on how the MCP server works - available tools, rate limits, and endpoint details - see the [Model Context Protocol documentation](/model-context-protocol).\n</Callout>";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export const platformMcpMdxTemplate = `---
|
|
2
|
+
title: "MCP"
|
|
3
|
+
description: "Connect external AI apps like Claude and Cursor to your documentation with a Model Context Protocol server, and protect it with an API key."
|
|
4
|
+
date: "2026-07-12"
|
|
5
|
+
category: "Configuration"
|
|
6
|
+
categoryOrder: 2
|
|
7
|
+
order: 7
|
|
8
|
+
section: "Platform"
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# MCP
|
|
12
|
+
|
|
13
|
+
The **MCP** settings page lets external AI applications connect to your documentation through a hosted Model Context Protocol (MCP) server. Every Doccupine site exposes an MCP endpoint at \`/api/mcp\` that AI tools can query to search and read your content.
|
|
14
|
+
|
|
15
|
+
## Connect your site with AI apps
|
|
16
|
+
|
|
17
|
+
The **Connect your site with AI apps** card gives you a ready-to-paste MCP configuration for popular AI tools. Pick a tab, copy the snippet, and add it to that tool's MCP config:
|
|
18
|
+
|
|
19
|
+
- **Claude** - add the configuration to Claude Desktop's \`claude_desktop_config.json\`, or add the server URL as a connector from Claude's settings.
|
|
20
|
+
- **Cursor** - add the configuration to Cursor's \`~/.cursor/mcp.json\`, or connect it from Cursor's MCP settings.
|
|
21
|
+
- **Others** - a generic \`mcpServers\` block for any MCP-compatible application.
|
|
22
|
+
|
|
23
|
+
Every snippet points to your site's own MCP endpoint, for example:
|
|
24
|
+
|
|
25
|
+
\`\`\`json
|
|
26
|
+
{
|
|
27
|
+
"mcpServers": {
|
|
28
|
+
"Your Project": { "url": "https://your-site.com/api/mcp" }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
\`\`\`
|
|
32
|
+
|
|
33
|
+
<Callout type="note">
|
|
34
|
+
The connection snippet appears once your site has been deployed. If you haven't published yet, deploy your site first to get its MCP server URL.
|
|
35
|
+
</Callout>
|
|
36
|
+
|
|
37
|
+
## MCP server authentication
|
|
38
|
+
|
|
39
|
+
By default your MCP endpoint is publicly accessible. You can restrict access with an API key:
|
|
40
|
+
|
|
41
|
+
1. Enable **Require API key for MCP access**.
|
|
42
|
+
2. Enter an API key.
|
|
43
|
+
3. Save.
|
|
44
|
+
|
|
45
|
+
Once enabled, clients must include the key as a Bearer token in the \`Authorization\` header of every request to \`/api/mcp\`.
|
|
46
|
+
|
|
47
|
+
<Callout type="warning">
|
|
48
|
+
The API key is stored as an environment variable (\`DOCS_API_KEY\`) on your deployment, not in a JSON file. After saving, a redeploy is triggered automatically to apply the change.
|
|
49
|
+
</Callout>
|
|
50
|
+
|
|
51
|
+
<Callout type="note">
|
|
52
|
+
For the full reference on how the MCP server works - available tools, rate limits, and endpoint details - see the [Model Context Protocol documentation](/model-context-protocol).
|
|
53
|
+
</Callout>`;
|
|
@@ -18,14 +18,14 @@ export const packageJsonTemplate = JSON.stringify({
|
|
|
18
18
|
"@mdx-js/react": "^3.1.1",
|
|
19
19
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
20
20
|
"@posthog/react": "^1.10.3",
|
|
21
|
-
"cherry-styled-components": "^0.2.
|
|
21
|
+
"cherry-styled-components": "^0.2.11",
|
|
22
22
|
langchain: "^1.5.3",
|
|
23
23
|
"lucide-react": "^1.24.0",
|
|
24
24
|
minisearch: "^7.2.0",
|
|
25
25
|
next: "16.2.10",
|
|
26
26
|
"next-mdx-remote": "^6.0.0",
|
|
27
|
-
"posthog-js": "^1.399.
|
|
28
|
-
"posthog-node": "^5.
|
|
27
|
+
"posthog-js": "^1.399.2",
|
|
28
|
+
"posthog-node": "^5.41.0",
|
|
29
29
|
react: "19.2.7",
|
|
30
30
|
"react-dom": "19.2.7",
|
|
31
31
|
"rehype-highlight": "^7.0.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "doccupine",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.123",
|
|
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": {
|