doccupine 0.0.122 → 0.0.124
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/MDXComponents.d.ts +1 -1
- package/dist/templates/components/MDXComponents.js +4 -2
- 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/Card.d.ts +1 -1
- package/dist/templates/components/layout/Card.js +2 -2
- package/dist/templates/components/layout/SiteGate.d.ts +1 -1
- package/dist/templates/components/layout/SiteGate.js +1 -1
- package/dist/templates/components/layout/Slug.d.ts +1 -1
- package/dist/templates/components/layout/Slug.js +1 -1
- package/dist/templates/components/layout/Steps.d.ts +1 -1
- package/dist/templates/components/layout/Steps.js +7 -5
- package/dist/templates/components/layout/Update.d.ts +1 -1
- package/dist/templates/components/layout/Update.js +5 -3
- package/dist/templates/mdx/cards.mdx.d.ts +1 -1
- package/dist/templates/mdx/cards.mdx.js +2 -2
- 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/mdx/steps.mdx.d.ts +1 -1
- package/dist/templates/mdx/steps.mdx.js +2 -2
- package/dist/templates/mdx/update.mdx.d.ts +1 -1
- package/dist/templates/mdx/update.mdx.js +2 -2
- 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 mdxComponentsTemplate = "import React from \"react\";\nimport Link from \"next/link\";\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ntype MDXComponents = Record<string, React.ComponentType<any>>;\nimport { Space } from \"cherry-styled-components\";\nimport { Code as CodeBlock, CodeTabs } from \"@/components/layout/Code\";\nimport { Card } from \"@/components/layout/Card\";\nimport { Accordion } from \"@/components/layout/Accordion\";\nimport { Tabs, TabContent } from \"@/components/layout/Tabs\";\nimport { Callout } from \"@/components/layout/Callout\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { Columns } from \"@/components/layout/Columns\";\nimport { Field } from \"@/components/layout/Field\";\nimport { Update } from \"@/components/layout/Update\";\nimport { Steps, Step } from \"@/components/layout/Steps\";\nimport { Button } from \"@/components/layout/Button\";\nimport { ColorSwatch, ColorSwatchGroup } from \"@/components/layout/ColorSwatch\";\nimport { DemoTheme } from \"@/components/layout/DemoTheme\";\nimport { createSlugger } from \"@/components/layout/Slug\";\n\ninterface HeadingProps extends React.HTMLAttributes<HTMLHeadingElement> {\n children?: React.ReactNode;\n}\n\ninterface PreProps extends React.HTMLAttributes<HTMLPreElement> {\n children?: React.ReactNode;\n}\n\nfunction extractAllTextFromChildren(children: React.ReactNode): string {\n if (children == null) return \"\";\n if (typeof children === \"string\") return children;\n if (typeof children === \"number\") return String(children);\n if (typeof children === \"boolean\") return \"\";\n if (Array.isArray(children))\n return children.map(extractAllTextFromChildren).join(\"\");\n if (React.isValidElement(children)) {\n const element = children as React.ReactElement<{\n children?: React.ReactNode;\n }>;\n return extractAllTextFromChildren(element.props.children);\n }\n return \"\";\n}\n\n// Map <pre><code class=\"language-xyz\"> to our <Code /> component\nfunction Pre(props: PreProps) {\n const child = React.Children.only(props.children) as React.ReactElement<{\n className?: string;\n children?: React.ReactNode;\n }> | null;\n if (child && child.type === \"code\") {\n const className = child.props.className || \"\";\n const match = /language-(\\w+)/.exec(className);\n const language = match ? match[1] : undefined;\n const code = extractAllTextFromChildren(child.props.children).replace(\n /\\n$/,\n \"\",\n );\n if (language) {\n return (\n <CodeBlock className={className} code={code} language={language} />\n );\n }\n }\n return <pre {...props} />;\n}\n\nexport function useMDXComponents(components: MDXComponents): MDXComponents {\n // One slugger per render so repeated heading text (and <Update> labels)\n // resolve to unique, document-order ids that match the index sidebar built\n // in components/Docs.tsx.\n const slug = createSlugger();\n return {\n // Headings with auto-generated ids for TOC and deep links\n h1: ({ children, ...props }: HeadingProps) => {\n const id = slug(extractAllTextFromChildren(children));\n return (\n <h1 id={id} {...props}>\n {children}\n </h1>\n );\n },\n h2: ({ children, ...props }: HeadingProps) => {\n const id = slug(extractAllTextFromChildren(children));\n return (\n <h2 id={id} {...props}>\n {children}\n </h2>\n );\n },\n h3: ({ children, ...props }: HeadingProps) => {\n const id = slug(extractAllTextFromChildren(children));\n return (\n <h3 id={id} {...props}>\n {children}\n </h3>\n );\n },\n h4: ({ children, ...props }: HeadingProps) => {\n const id = slug(extractAllTextFromChildren(children));\n return (\n <h4 id={id} {...props}>\n {children}\n </h4>\n );\n },\n h5: ({ children, ...props }: HeadingProps) => {\n const id = slug(extractAllTextFromChildren(children));\n return (\n <h5 id={id} {...props}>\n {children}\n </h5>\n );\n },\n h6: ({ children, ...props }: HeadingProps) => {\n const id = slug(extractAllTextFromChildren(children));\n return (\n <h6 id={id} {...props}>\n {children}\n </h6>\n );\n },\n\n // Links - use Next.js Link for internal paths\n a: ({\n href,\n children,\n ...props\n }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {\n if (href && href.startsWith(\"/\")) {\n return (\n <Link href={href} {...props}>\n {children}\n </Link>\n );\n }\n return (\n <a href={href} target=\"_blank\" rel=\"noopener noreferrer\" {...props}>\n {children}\n </a>\n );\n },\n\n // Tables - wrap in a div for responsive overflow\n table: (props: React.TableHTMLAttributes<HTMLTableElement>) => (\n <div className=\"table-wrapper\">\n <table {...props} />\n </div>\n ),\n\n // Code blocks\n pre: Pre,\n\n // Expose your custom components for MDX usage\n Code: CodeBlock,\n CodeTabs,\n Card,\n Accordion,\n Tabs,\n TabContent,\n Callout,\n Icon,\n Columns,\n Field,\n // Share the heading slugger so an <Update> label anchor stays unique and\n // in document order alongside the surrounding headings.\n Update: (props: React.ComponentProps<typeof Update>) => (\n <Update {...props} id={slug(props.label)} />\n ),\n Steps,\n Step,\n Button,\n ColorSwatch,\n ColorSwatchGroup,\n DemoTheme,\n Space,\n ...components,\n };\n}\n";
|
|
1
|
+
export declare const mdxComponentsTemplate = "import React from \"react\";\nimport Link from \"next/link\";\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ntype MDXComponents = Record<string, React.ComponentType<any>>;\nimport { Space } from \"cherry-styled-components\";\nimport { Code as CodeBlock, CodeTabs } from \"@/components/layout/Code\";\nimport { Card } from \"@/components/layout/Card\";\nimport { Accordion } from \"@/components/layout/Accordion\";\nimport { Tabs, TabContent } from \"@/components/layout/Tabs\";\nimport { Callout } from \"@/components/layout/Callout\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { Columns } from \"@/components/layout/Columns\";\nimport { Field } from \"@/components/layout/Field\";\nimport { Update } from \"@/components/layout/Update\";\nimport { Steps, Step } from \"@/components/layout/Steps\";\nimport { Button } from \"@/components/layout/Button\";\nimport { ColorSwatch, ColorSwatchGroup } from \"@/components/layout/ColorSwatch\";\nimport { DemoTheme } from \"@/components/layout/DemoTheme\";\nimport { createSlugger } from \"@/components/layout/Slug\";\n\ninterface HeadingProps extends React.HTMLAttributes<HTMLHeadingElement> {\n children?: React.ReactNode;\n}\n\ninterface PreProps extends React.HTMLAttributes<HTMLPreElement> {\n children?: React.ReactNode;\n}\n\nfunction extractAllTextFromChildren(children: React.ReactNode): string {\n if (children == null) return \"\";\n if (typeof children === \"string\") return children;\n if (typeof children === \"number\") return String(children);\n if (typeof children === \"boolean\") return \"\";\n if (Array.isArray(children))\n return children.map(extractAllTextFromChildren).join(\"\");\n if (React.isValidElement(children)) {\n const element = children as React.ReactElement<{\n children?: React.ReactNode;\n }>;\n return extractAllTextFromChildren(element.props.children);\n }\n return \"\";\n}\n\n// Map <pre><code class=\"language-xyz\"> to our <Code /> component\nfunction Pre(props: PreProps) {\n const child = React.Children.only(props.children) as React.ReactElement<{\n className?: string;\n children?: React.ReactNode;\n }> | null;\n if (child && child.type === \"code\") {\n const className = child.props.className || \"\";\n const match = /language-(\\w+)/.exec(className);\n const language = match ? match[1] : undefined;\n const code = extractAllTextFromChildren(child.props.children).replace(\n /\\n$/,\n \"\",\n );\n if (language) {\n return (\n <CodeBlock className={className} code={code} language={language} />\n );\n }\n }\n return <pre {...props} />;\n}\n\nexport function useMDXComponents(components: MDXComponents): MDXComponents {\n // One slugger per render so repeated heading text (and <Update> labels)\n // resolve to unique, document-order ids that match the index sidebar built\n // in components/Docs.tsx.\n const slug = createSlugger();\n return {\n // Headings with auto-generated ids for TOC and deep links\n h1: ({ children, ...props }: HeadingProps) => {\n const id = slug(extractAllTextFromChildren(children));\n return (\n <h1 id={id} {...props}>\n {children}\n </h1>\n );\n },\n h2: ({ children, ...props }: HeadingProps) => {\n const id = slug(extractAllTextFromChildren(children));\n return (\n <h2 id={id} {...props}>\n {children}\n </h2>\n );\n },\n h3: ({ children, ...props }: HeadingProps) => {\n const id = slug(extractAllTextFromChildren(children));\n return (\n <h3 id={id} {...props}>\n {children}\n </h3>\n );\n },\n h4: ({ children, ...props }: HeadingProps) => {\n const id = slug(extractAllTextFromChildren(children));\n return (\n <h4 id={id} {...props}>\n {children}\n </h4>\n );\n },\n h5: ({ children, ...props }: HeadingProps) => {\n const id = slug(extractAllTextFromChildren(children));\n return (\n <h5 id={id} {...props}>\n {children}\n </h5>\n );\n },\n h6: ({ children, ...props }: HeadingProps) => {\n const id = slug(extractAllTextFromChildren(children));\n return (\n <h6 id={id} {...props}>\n {children}\n </h6>\n );\n },\n\n // Links - use Next.js Link for internal paths\n a: ({\n href,\n children,\n ...props\n }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {\n if (href && href.startsWith(\"/\")) {\n return (\n <Link href={href} {...props}>\n {children}\n </Link>\n );\n }\n return (\n <a href={href} target=\"_blank\" rel=\"noopener noreferrer\" {...props}>\n {children}\n </a>\n );\n },\n\n // Tables - wrap in a div for responsive overflow\n table: (props: React.TableHTMLAttributes<HTMLTableElement>) => (\n <div className=\"table-wrapper\">\n <table {...props} />\n </div>\n ),\n\n // Code blocks\n pre: Pre,\n\n // Expose your custom components for MDX usage\n Code: CodeBlock,\n CodeTabs,\n Card,\n Accordion,\n Tabs,\n TabContent,\n Callout,\n Icon,\n Columns,\n Field,\n // Share the heading slugger so an <Update> label anchor stays unique and\n // in document order alongside the surrounding headings. Only consume a\n // slug when a label is present so a label-less <Update> does not crash the\n // slugger (Update itself renders gracefully without a label anchor).\n Update: (props: React.ComponentProps<typeof Update>) => (\n <Update {...props} id={props.label ? slug(props.label) : undefined} />\n ),\n Steps,\n Step,\n Button,\n ColorSwatch,\n ColorSwatchGroup,\n DemoTheme,\n Space,\n ...components,\n };\n}\n";
|
|
@@ -164,9 +164,11 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
|
|
|
164
164
|
Columns,
|
|
165
165
|
Field,
|
|
166
166
|
// Share the heading slugger so an <Update> label anchor stays unique and
|
|
167
|
-
// in document order alongside the surrounding headings.
|
|
167
|
+
// in document order alongside the surrounding headings. Only consume a
|
|
168
|
+
// slug when a label is present so a label-less <Update> does not crash the
|
|
169
|
+
// slugger (Update itself renders gracefully without a label anchor).
|
|
168
170
|
Update: (props: React.ComponentProps<typeof Update>) => (
|
|
169
|
-
<Update {...props} id={slug(props.label)} />
|
|
171
|
+
<Update {...props} id={props.label ? slug(props.label) : undefined} />
|
|
170
172
|
),
|
|
171
173
|
Steps,
|
|
172
174
|
Step,
|
|
@@ -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";
|