doccupine 0.0.64 → 0.0.65

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.
@@ -14,6 +14,7 @@ import { notFoundTemplate } from "../templates/app/not-found.js";
14
14
  import { themeTemplate } from "../templates/app/theme.js";
15
15
  import { chatTemplate } from "../templates/components/Chat.js";
16
16
  import { clickOutsideTemplate } from "../templates/components/ClickOutside.js";
17
+ import { lockBodyScrollTemplate } from "../templates/components/LockBodyScroll.js";
17
18
  import { docsTemplate } from "../templates/components/Docs.js";
18
19
  import { docsSideBarTemplate } from "../templates/components/DocsSideBar.js";
19
20
  import { mdxComponentsTemplate } from "../templates/components/MDXComponents.js";
@@ -131,6 +132,7 @@ export const appStructure = {
131
132
  "utils/config.ts": configTemplate,
132
133
  "components/Chat.tsx": chatTemplate,
133
134
  "components/ClickOutside.ts": clickOutsideTemplate,
135
+ "components/LockBodyScroll.ts": lockBodyScrollTemplate,
134
136
  "components/Docs.tsx": docsTemplate,
135
137
  "components/DocsSideBar.tsx": docsSideBarTemplate,
136
138
  "components/MDXComponents.tsx": mdxComponentsTemplate,
@@ -1 +1 @@
1
- export declare const ragRoutesTemplate = "import { NextResponse } from \"next/server\";\nimport { z } from \"zod\";\nimport { getLLMConfig, createChatModel } from \"@/services/llm\";\nimport {\n searchDocs,\n ensureDocsIndex,\n getIndexStatus,\n} from \"@/services/mcp/server\";\nimport { rateLimit } from \"@/utils/rateLimit\";\nimport { config } from \"@/utils/config\";\n\nconst messageSchema = z.object({\n role: z.enum([\"user\", \"assistant\"]),\n content: z.string().max(4000),\n});\n\nconst ragSchema = z.object({\n question: z.string().min(1).max(2000),\n history: z.array(messageSchema).max(20).optional(),\n refresh: z.boolean().optional(),\n});\n\nconst projectName = config.name || \"Doccupine\";\n\nconst systemContext = `You are AI Assistant, a documentation assistant for ${projectName}, Your name is ${projectName} AI Assistant.\n\n## Core Rules\n1. Answer ONLY from the provided context. Never fabricate information.\n2. If the answer isn't in the context, say so clearly and suggest relevant sections or pages the user might check.\n3. If the question is ambiguous, ask a brief clarifying question before answering.\n\n## Response Style\n- Be concise and direct. Lead with the answer, then provide details if needed.\n- Use code examples from the context when relevant.\n- Match the technical level of the user's question.\n\n## MDX/Code Formatting\nWhen including code blocks in your response:\n- Never nest fenced code blocks (triple backticks) inside other fenced code blocks.\n- If you need to show MDX source that itself contains code blocks, use indented code blocks or escape the inner backticks.\n- All output must be valid MDX that renders correctly.\n\n## Greetings & Small Talk\nIf the user sends a greeting or non-documentation question, respond briefly and ask how you can help with the documentation.`;\n\nexport async function POST(req: Request) {\n // Rate limit by IP\n const ip =\n req.headers.get(\"x-forwarded-for\")?.split(\",\")[0]?.trim() ?? \"unknown\";\n const { allowed, retryAfter } = rateLimit(ip);\n if (!allowed) {\n return NextResponse.json(\n { error: \"Too many requests\" },\n { status: 429, headers: { \"Retry-After\": String(retryAfter) } },\n );\n }\n\n try {\n const body = await req.json();\n const parsed = ragSchema.safeParse(body);\n if (!parsed.success) {\n return NextResponse.json(\n { error: \"Invalid input\", details: parsed.error.issues },\n { status: 400 },\n );\n }\n const { question, history, refresh } = parsed.data;\n\n let llmConfig;\n try {\n llmConfig = getLLMConfig();\n } catch (error: unknown) {\n const message =\n error instanceof Error ? error.message : \"LLM configuration error\";\n return NextResponse.json({ error: message }, { status: 500 });\n }\n\n // Use MCP service to ensure docs are indexed\n await ensureDocsIndex(Boolean(refresh));\n\n // Use MCP search_docs tool to find relevant documentation\n const searchResults = await searchDocs(question, 6);\n\n // Build context from search results\n const context = searchResults\n .map(\n ({ chunk, score }) =>\n `File: ${chunk.path}\\nScore: ${score.toFixed(3)}\\n----\\n${chunk.text}`,\n )\n .join(\"\\n\\n================\\n\\n\");\n\n // Create chat model and stream response\n const llm = createChatModel(llmConfig);\n const prompt: { role: \"system\" | \"user\" | \"assistant\"; content: string }[] =\n [\n {\n role: \"system\" as const,\n content: systemContext,\n },\n ];\n\n // Include conversation history for multi-turn context\n if (history && history.length > 0) {\n for (const msg of history) {\n prompt.push({\n role: msg.role,\n content: msg.content,\n });\n }\n }\n\n prompt.push({\n role: \"user\" as const,\n content: `Question: ${question}\\n\\nContext:\\n${context}`,\n });\n\n const stream = await llm.stream(prompt);\n\n // Build metadata from MCP search results\n const indexStatus = getIndexStatus();\n const metadata = {\n sources: searchResults.map(({ chunk, score }) => ({\n id: chunk.id,\n path: chunk.path,\n uri: chunk.uri,\n score,\n })),\n chunkCount: indexStatus.chunkCount,\n };\n\n const encoder = new TextEncoder();\n const readableStream = new ReadableStream({\n async start(controller) {\n try {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"metadata\", data: metadata })}\\n\\n`,\n ),\n );\n\n for await (const chunk of stream) {\n const content = chunk?.content || \"\";\n if (content) {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"content\", data: content })}\\n\\n`,\n ),\n );\n }\n }\n\n controller.enqueue(\n encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`),\n );\n controller.close();\n } catch (error: unknown) {\n const message =\n error instanceof Error ? error.message : \"Stream error\";\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", data: message })}\\n\\n`,\n ),\n );\n controller.close();\n }\n },\n });\n\n return new Response(readableStream, {\n headers: {\n \"Content-Type\": \"text/event-stream\",\n \"Cache-Control\": \"no-cache\",\n Connection: \"keep-alive\",\n },\n });\n } catch (e: unknown) {\n const message = e instanceof Error ? e.message : \"Unknown error\";\n return NextResponse.json({ error: message }, { status: 500 });\n }\n}\n\nexport async function GET() {\n const status = getIndexStatus();\n return NextResponse.json({\n ready: status.ready,\n chunks: status.chunkCount,\n });\n}\n";
1
+ export declare const ragRoutesTemplate = "import { NextResponse } from \"next/server\";\nimport { z } from \"zod\";\nimport { getLLMConfig, createChatModel } from \"@/services/llm\";\nimport {\n searchDocs,\n ensureDocsIndex,\n getIndexStatus,\n} from \"@/services/mcp/server\";\nimport { rateLimit } from \"@/utils/rateLimit\";\nimport { config } from \"@/utils/config\";\n\nconst messageSchema = z.object({\n role: z.enum([\"user\", \"assistant\"]),\n content: z.string().max(4000),\n});\n\nconst ragSchema = z.object({\n question: z.string().min(1).max(2000),\n history: z.array(messageSchema).max(20).optional(),\n refresh: z.boolean().optional(),\n});\n\nconst projectName = config.name || \"Doccupine\";\n\nconst systemContext = `You are AI Assistant, a documentation assistant for ${projectName}, Your name is ${projectName} AI Assistant.\n\n## Core Rules\n1. Answer ONLY from the provided context. Never fabricate information.\n2. If the answer isn't in the context, say so clearly and suggest relevant sections or pages the user might check.\n3. If the question is ambiguous, ask a brief clarifying question before answering.\n\n## Response Style\n- Be concise and direct. Lead with the answer, then provide details if needed.\n- Use code examples from the context when relevant.\n- Match the technical level of the user's question.\n\n## MDX/Code Formatting\nWhen including code blocks in your response:\n- Never nest fenced code blocks (triple backticks) inside other fenced code blocks.\n- If you need to show MDX source that itself contains code blocks, use indented code blocks or escape the inner backticks.\n- All output must be valid MDX that renders correctly.\n\n## Internal Links\nEach context chunk includes a \"URL:\" line with the pre-computed page URL. Use it directly when linking:\n- Format links as markdown: [Page Title](/slug/).\n- Never expose raw file paths like \"/app/.../page.tsx\" to the user.\n- Do NOT add a \"Related Pages\" section at the end - sources are shown separately by the UI.\n\n## Greetings & Small Talk\nIf the user sends a greeting or non-documentation question, respond briefly and ask how you can help with the documentation.`;\n\nexport async function POST(req: Request) {\n // Rate limit by IP\n const ip =\n req.headers.get(\"x-forwarded-for\")?.split(\",\")[0]?.trim() ?? \"unknown\";\n const { allowed, retryAfter } = rateLimit(ip);\n if (!allowed) {\n return NextResponse.json(\n { error: \"Too many requests\" },\n { status: 429, headers: { \"Retry-After\": String(retryAfter) } },\n );\n }\n\n try {\n const body = await req.json();\n const parsed = ragSchema.safeParse(body);\n if (!parsed.success) {\n return NextResponse.json(\n { error: \"Invalid input\", details: parsed.error.issues },\n { status: 400 },\n );\n }\n const { question, history, refresh } = parsed.data;\n\n let llmConfig;\n try {\n llmConfig = getLLMConfig();\n } catch (error: unknown) {\n const message =\n error instanceof Error ? error.message : \"LLM configuration error\";\n return NextResponse.json({ error: message }, { status: 500 });\n }\n\n // Use MCP service to ensure docs are indexed\n await ensureDocsIndex(Boolean(refresh));\n\n // Use MCP search_docs tool to find relevant documentation\n const searchResults = await searchDocs(question, 6);\n\n // Build context from search results\n const context = searchResults\n .map(({ chunk, score }) => {\n const slug = chunk.uri.replace(\"docs://\", \"\").replace(/^\\/+/, \"\");\n const url = slug ? `/${slug}/` : \"/\";\n return `File: ${chunk.path}\\nURL: ${url}\\nScore: ${score.toFixed(3)}\\n----\\n${chunk.text}`;\n })\n .join(\"\\n\\n================\\n\\n\");\n\n // Create chat model and stream response\n const llm = createChatModel(llmConfig);\n const prompt: { role: \"system\" | \"user\" | \"assistant\"; content: string }[] =\n [\n {\n role: \"system\" as const,\n content: systemContext,\n },\n ];\n\n // Include conversation history for multi-turn context\n if (history && history.length > 0) {\n for (const msg of history) {\n prompt.push({\n role: msg.role,\n content: msg.content,\n });\n }\n }\n\n prompt.push({\n role: \"user\" as const,\n content: `Question: ${question}\\n\\nContext:\\n${context}`,\n });\n\n const stream = await llm.stream(prompt);\n\n // Build metadata from MCP search results\n const indexStatus = getIndexStatus();\n const metadata = {\n sources: searchResults.map(({ chunk, score }) => ({\n id: chunk.id,\n path: chunk.path,\n uri: chunk.uri,\n score,\n })),\n chunkCount: indexStatus.chunkCount,\n };\n\n const encoder = new TextEncoder();\n const readableStream = new ReadableStream({\n async start(controller) {\n try {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"metadata\", data: metadata })}\\n\\n`,\n ),\n );\n\n for await (const chunk of stream) {\n const content = chunk?.content || \"\";\n if (content) {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"content\", data: content })}\\n\\n`,\n ),\n );\n }\n }\n\n controller.enqueue(\n encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`),\n );\n controller.close();\n } catch (error: unknown) {\n const message =\n error instanceof Error ? error.message : \"Stream error\";\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", data: message })}\\n\\n`,\n ),\n );\n controller.close();\n }\n },\n });\n\n return new Response(readableStream, {\n headers: {\n \"Content-Type\": \"text/event-stream\",\n \"Cache-Control\": \"no-cache\",\n Connection: \"keep-alive\",\n },\n });\n } catch (e: unknown) {\n const message = e instanceof Error ? e.message : \"Unknown error\";\n return NextResponse.json({ error: message }, { status: 500 });\n }\n}\n\nexport async function GET() {\n const status = getIndexStatus();\n return NextResponse.json({\n ready: status.ready,\n chunks: status.chunkCount,\n });\n}\n";
@@ -40,6 +40,12 @@ When including code blocks in your response:
40
40
  - If you need to show MDX source that itself contains code blocks, use indented code blocks or escape the inner backticks.
41
41
  - All output must be valid MDX that renders correctly.
42
42
 
43
+ ## Internal Links
44
+ Each context chunk includes a "URL:" line with the pre-computed page URL. Use it directly when linking:
45
+ - Format links as markdown: [Page Title](/slug/).
46
+ - Never expose raw file paths like "/app/.../page.tsx" to the user.
47
+ - Do NOT add a "Related Pages" section at the end - sources are shown separately by the UI.
48
+
43
49
  ## Greetings & Small Talk
44
50
  If the user sends a greeting or non-documentation question, respond briefly and ask how you can help with the documentation.\`;
45
51
 
@@ -83,10 +89,11 @@ export async function POST(req: Request) {
83
89
 
84
90
  // Build context from search results
85
91
  const context = searchResults
86
- .map(
87
- ({ chunk, score }) =>
88
- \`File: \${chunk.path}\\nScore: \${score.toFixed(3)}\\n----\\n\${chunk.text}\`,
89
- )
92
+ .map(({ chunk, score }) => {
93
+ const slug = chunk.uri.replace("docs://", "").replace(/^\\/+/, "");
94
+ const url = slug ? \`/\${slug}/\` : "/";
95
+ return \`File: \${chunk.path}\\nURL: \${url}\\nScore: \${score.toFixed(3)}\\n----\\n\${chunk.text}\`;
96
+ })
90
97
  .join("\\n\\n================\\n\\n");
91
98
 
92
99
  // Create chat model and stream response
@@ -1 +1 @@
1
- export declare const chatTemplate = "\"use client\";\nimport React, {\n createContext,\n useContext,\n useEffect,\n useRef,\n useState,\n} from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { rgba } from \"polished\";\nimport { Button } from \"cherry-styled-components\";\nimport { ArrowUp, LoaderPinwheel, 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 { mq, Theme } from \"@/app/theme\";\nimport { useMDXComponents as getMDXComponents } from \"@/components/MDXComponents\";\nimport {\n styledAnchor,\n styledTable,\n stylesLists,\n StyledSmallButton,\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(100vh - 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 ${rgba(\"#ff6b6b\", 0.4)},\n ${rgba(\"#feca57\", 0.4)},\n ${rgba(\"#48dbfb\", 0.4)},\n ${rgba(\"#ff9ff3\", 0.4)},\n ${rgba(\"#54a0ff\", 0.4)},\n ${rgba(\"#5f27cd\", 0.4)},\n ${rgba(\"#ff6b6b\", 0.4)}\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: 14px 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 }) => rgba(theme.colors.dark, 0.4)};\n transition: color 0.3s ease;\n }\n\n &:focus::placeholder {\n color: ${({ theme }) => rgba(theme.colors.dark, 0.6)};\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 }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\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 ${rgba(\"#ff6b6b\", 0.4)},\n ${rgba(\"#feca57\", 0.4)},\n ${rgba(\"#48dbfb\", 0.4)},\n ${rgba(\"#ff9ff3\", 0.4)},\n ${rgba(\"#54a0ff\", 0.4)},\n ${rgba(\"#5f27cd\", 0.4)},\n ${rgba(\"#ff6b6b\", 0.4)}\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 background: ${({ theme }) => theme.colors.error};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\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 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 background: ${({ theme }) => theme.colors.primary};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n padding: 10px;\n border-radius: 8px;\n margin: 20px 0;\n width: 100%;\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 `}\n\n & code:not([class]) {\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};\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 & ol {\n & > li {\n padding-left: 20px;\n\n &::before {\n position: absolute;\n top: 0;\n left: 0;\n }\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 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: 5px;\n color: ${({ theme }) => theme.colors.dark};\n`;\n\nconst StyledChatCloseButton = styled.button<{ theme: Theme }>`\n background: transparent;\n border: none;\n cursor: pointer;\n padding: 0;\n margin: 0;\n color: ${({ theme }) => theme.colors.primary};\n\n &:hover {\n color: ${({ theme }) => theme.colors.primaryDark};\n transform: scale(1.05);\n }\n\n &:active {\n transform: scale(0.95);\n }\n`;\n\ntype Answer = {\n text: string;\n answer?: boolean;\n mdx?: MDXRemoteSerializeResult;\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 { setIsOpen, isOpen, answer, setAnswer, chatInputRef } =\n useContext(ChatContext);\n\n return (\n <StyledGlowSmallButton\n onClick={() => {\n const next = !isOpen;\n setIsOpen(next);\n if (next) {\n if (answer.length === 0) {\n setAnswer([\n { text: \"Hey there, how can I assist you?\", answer: true },\n ]);\n }\n setTimeout(() => {\n chatInputRef.current?.focus();\n }, 350);\n }\n }}\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 clearChat,\n chatInputRef,\n } = useContext(ChatContext);\n const endRef = useRef<HTMLDivElement | null>(null);\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 <StyledChatCloseButton onClick={clearChat} aria-label=\"Close chat\">\n <X />\n </StyledChatCloseButton>\n </StyledChatTitle>\n {answer &&\n answer.map((a, i) => (\n <StyledAnswer key={i} $isAnswer={a.answer ?? false}>\n {a.answer && a.mdx ? (\n <MDXRemote {...a.mdx} components={mdxComponents} />\n ) : (\n a.text\n )}\n </StyledAnswer>\n ))}\n {loading && (\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 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 clearChat: () => void;\n chatInputRef: React.RefObject<HTMLInputElement | null>;\n}>({\n isOpen: false,\n setIsOpen: () => {},\n isChatActive: false,\n question: \"\",\n setQuestion: () => {},\n loading: false,\n error: null,\n answer: [],\n setAnswer: () => {},\n ask: () => {},\n clearChat: () => {},\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\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 const errorData = await res.json();\n throw new Error(errorData.error || \"Request failed\");\n }\n\n const reader = res.body?.getReader();\n const decoder = new TextDecoder();\n const contentParts: string[] = [];\n if (!reader) {\n throw new Error(\"Failed to get response reader\");\n }\n\n const streamingAnswerIndex = mergedQuestions.length;\n setAnswer([...mergedQuestions, { text: \"\", answer: true }]);\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 === \"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 };\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 };\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 clearChat() {\n abortRef.current?.abort();\n setAnswer([]);\n setIsOpen(false);\n }\n\n return (\n <ChatContext.Provider\n value={{\n isOpen,\n setIsOpen,\n isChatActive,\n question,\n setQuestion,\n loading,\n error,\n answer,\n setAnswer,\n ask,\n clearChat,\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 useContext,\n useEffect,\n useRef,\n useState,\n} from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { rgba } from \"polished\";\nimport { Button } 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} 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 ${rgba(\"#ff6b6b\", 0.4)},\n ${rgba(\"#feca57\", 0.4)},\n ${rgba(\"#48dbfb\", 0.4)},\n ${rgba(\"#ff9ff3\", 0.4)},\n ${rgba(\"#54a0ff\", 0.4)},\n ${rgba(\"#5f27cd\", 0.4)},\n ${rgba(\"#ff6b6b\", 0.4)}\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: 14px 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 }) => rgba(theme.colors.dark, 0.4)};\n transition: color 0.3s ease;\n }\n\n &:focus::placeholder {\n color: ${({ theme }) => rgba(theme.colors.dark, 0.6)};\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 }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\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 ${rgba(\"#ff6b6b\", 0.4)},\n ${rgba(\"#feca57\", 0.4)},\n ${rgba(\"#48dbfb\", 0.4)},\n ${rgba(\"#ff9ff3\", 0.4)},\n ${rgba(\"#54a0ff\", 0.4)},\n ${rgba(\"#5f27cd\", 0.4)},\n ${rgba(\"#ff6b6b\", 0.4)}\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 background: ${({ theme }) => theme.colors.error};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\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 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 background: ${({ theme }) => theme.colors.primary};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n padding: 10px;\n border-radius: 8px;\n margin: 20px 0;\n width: 100%;\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 `}\n\n & code:not([class]) {\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};\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 & ol {\n & > li {\n padding-left: 20px;\n\n &::before {\n position: absolute;\n top: 0;\n left: 0;\n }\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 }) => rgba(theme.colors.primaryLight, 0.1)};\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 }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\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\nconst StyledChatCloseButton = styled.button<{ theme: Theme }>`\n background: transparent;\n border: none;\n cursor: pointer;\n padding: 0;\n margin: 0;\n color: ${({ theme }) => theme.colors.primary};\n\n &:hover {\n color: ${({ theme }) => theme.colors.primaryDark};\n transform: scale(1.05);\n }\n\n &:active {\n transform: scale(0.95);\n }\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 { setIsOpen, isOpen, answer, setAnswer, chatInputRef } =\n useContext(ChatContext);\n\n return (\n <StyledGlowSmallButton\n onClick={() => {\n const next = !isOpen;\n setIsOpen(next);\n if (next) {\n if (answer.length === 0) {\n setAnswer([\n { text: \"Hey there, how can I assist you?\", answer: true },\n ]);\n }\n setTimeout(() => {\n chatInputRef.current?.focus();\n }, 350);\n }\n }}\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 <StyledChatCloseButton\n onClick={resetChat}\n aria-label=\"Reset chat history\"\n title=\"Reset chat history\"\n >\n <RotateCcw size={18} />\n </StyledChatCloseButton>\n <StyledChatCloseButton\n onClick={closeChat}\n aria-label=\"Close chat\"\n title=\"Close chat\"\n >\n <X />\n </StyledChatCloseButton>\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 key={src.id} href={href}>\n {label}\n </StyledSourceLink>\n );\n })}\n </StyledSources>\n )}\n </React.Fragment>\n ))}\n {loading && (\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 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 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\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 const errorData = await res.json();\n throw new Error(errorData.error || \"Request failed\");\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 const streamingAnswerIndex = mergedQuestions.length;\n setAnswer([...mergedQuestions, { text: \"\", answer: true }]);\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 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";
@@ -10,18 +10,21 @@ import React, {
10
10
  import styled, { css, keyframes } from "styled-components";
11
11
  import { rgba } from "polished";
12
12
  import { Button } from "cherry-styled-components";
13
- import { ArrowUp, LoaderPinwheel, Sparkles, X } from "lucide-react";
13
+ import { ArrowUp, LoaderPinwheel, RotateCcw, Sparkles, X } from "lucide-react";
14
14
  import remarkGfm from "remark-gfm";
15
15
  import rehypeHighlight from "rehype-highlight";
16
16
  import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote";
17
17
  import { serialize } from "next-mdx-remote/serialize";
18
+ import Link from "next/link";
18
19
  import { mq, Theme } from "@/app/theme";
20
+ import { useLockBodyScroll } from "@/components/LockBodyScroll";
19
21
  import { useMDXComponents as getMDXComponents } from "@/components/MDXComponents";
20
22
  import {
21
23
  styledAnchor,
22
24
  styledTable,
23
25
  stylesLists,
24
26
  StyledSmallButton,
27
+ interactiveStyles,
25
28
  } from "@/components/layout/SharedStyled";
26
29
 
27
30
  const mdxComponents = getMDXComponents({});
@@ -42,7 +45,7 @@ const StyledChat = styled.div<{ theme: Theme; $isVisible: boolean }>\`
42
45
  top: 0;
43
46
  right: 0;
44
47
  width: 100%;
45
- height: calc(100vh - 90px);
48
+ height: calc(100dvh - 90px);
46
49
  overflow-y: scroll;
47
50
  overflow-x: hidden;
48
51
  z-index: 1000;
@@ -608,6 +611,40 @@ const StyledAnswer = styled.div<{ theme: Theme; $isAnswer: boolean }>\`
608
611
  }
609
612
  \`;
610
613
 
614
+ const StyledSources = styled.div\`
615
+ display: flex;
616
+ gap: 16px;
617
+ flex-wrap: wrap;
618
+ margin: -5px 0 20px;
619
+ \`;
620
+
621
+ const StyledSourceLink = styled(Link)<{ theme: Theme }>\`
622
+ position: relative;
623
+ text-decoration: none;
624
+ font-size: \${({ theme }) => theme.fontSizes.small.lg};
625
+ line-height: 1;
626
+ color: \${({ theme }) => theme.colors.primary};
627
+ display: flex;
628
+ gap: 6px;
629
+ transition: all 0.3s ease;
630
+ font-weight: 600;
631
+ white-space: nowrap;
632
+ min-width: fit-content;
633
+ background: \${({ theme }) => rgba(theme.colors.primaryLight, 0.1)};
634
+ padding: 6px 8px;
635
+ border-radius: \${({ theme }) => theme.spacing.radius.xs};
636
+ \${interactiveStyles};
637
+
638
+ & * {
639
+ margin: auto 0;
640
+ }
641
+
642
+ &:hover {
643
+ color: \${({ theme }) =>
644
+ theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};
645
+ }
646
+ \`;
647
+
611
648
  const StyledChatTitle = styled.div<{ theme: Theme }>\`
612
649
  display: flex;
613
650
  flex-wrap: nowrap;
@@ -625,7 +662,7 @@ const StyledChatTitle = styled.div<{ theme: Theme }>\`
625
662
  const StyledChatTitleIconWrapper = styled.span<{ theme: Theme }>\`
626
663
  display: flex;
627
664
  align-items: center;
628
- gap: 5px;
665
+ gap: 12px;
629
666
  color: \${({ theme }) => theme.colors.dark};
630
667
  \`;
631
668
 
@@ -647,10 +684,18 @@ const StyledChatCloseButton = styled.button<{ theme: Theme }>\`
647
684
  }
648
685
  \`;
649
686
 
687
+ type Source = {
688
+ id: string;
689
+ path: string;
690
+ uri: string;
691
+ score: number;
692
+ };
693
+
650
694
  type Answer = {
651
695
  text: string;
652
696
  answer?: boolean;
653
697
  mdx?: MDXRemoteSerializeResult;
698
+ sources?: Source[];
654
699
  };
655
700
 
656
701
  const SPARKLE_COLORS = [
@@ -779,11 +824,14 @@ function Chat() {
779
824
  error,
780
825
  answer,
781
826
  ask,
782
- clearChat,
827
+ closeChat,
828
+ resetChat,
783
829
  chatInputRef,
784
830
  } = useContext(ChatContext);
785
831
  const endRef = useRef<HTMLDivElement | null>(null);
786
832
 
833
+ useLockBodyScroll(isOpen);
834
+
787
835
  useEffect(() => {
788
836
  endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
789
837
  }, [answer]);
@@ -802,19 +850,56 @@ function Chat() {
802
850
  <Sparkles />
803
851
  <h3>AI Assistant</h3>
804
852
  </StyledChatTitleIconWrapper>
805
- <StyledChatCloseButton onClick={clearChat} aria-label="Close chat">
806
- <X />
807
- </StyledChatCloseButton>
853
+ <StyledChatTitleIconWrapper>
854
+ <StyledChatCloseButton
855
+ onClick={resetChat}
856
+ aria-label="Reset chat history"
857
+ title="Reset chat history"
858
+ >
859
+ <RotateCcw size={18} />
860
+ </StyledChatCloseButton>
861
+ <StyledChatCloseButton
862
+ onClick={closeChat}
863
+ aria-label="Close chat"
864
+ title="Close chat"
865
+ >
866
+ <X />
867
+ </StyledChatCloseButton>
868
+ </StyledChatTitleIconWrapper>
808
869
  </StyledChatTitle>
809
870
  {answer &&
810
871
  answer.map((a, i) => (
811
- <StyledAnswer key={i} $isAnswer={a.answer ?? false}>
812
- {a.answer && a.mdx ? (
813
- <MDXRemote {...a.mdx} components={mdxComponents} />
814
- ) : (
815
- a.text
872
+ <React.Fragment key={i}>
873
+ <StyledAnswer $isAnswer={a.answer ?? false}>
874
+ {a.answer && a.mdx ? (
875
+ <MDXRemote {...a.mdx} components={mdxComponents} />
876
+ ) : (
877
+ a.text
878
+ )}
879
+ </StyledAnswer>
880
+ {a.answer && a.sources && a.sources.length > 0 && (
881
+ <StyledSources>
882
+ {a.sources.map((src) => {
883
+ const slug = src.uri
884
+ .replace("docs://", "")
885
+ .replace(/^\\/+/, "");
886
+ const href = slug ? \`/\${slug}/\` : "/";
887
+ const label = slug
888
+ ? slug
889
+ .split("/")
890
+ .pop()!
891
+ .replace(/-/g, " ")
892
+ .replace(/\\b\\w/g, (c: string) => c.toUpperCase())
893
+ : "Home";
894
+ return (
895
+ <StyledSourceLink key={src.id} href={href}>
896
+ {label}
897
+ </StyledSourceLink>
898
+ );
899
+ })}
900
+ </StyledSources>
816
901
  )}
817
- </StyledAnswer>
902
+ </React.Fragment>
818
903
  ))}
819
904
  {loading && (
820
905
  <StyledLoading>
@@ -865,7 +950,8 @@ const ChatContext = createContext<{
865
950
  answer: Answer[];
866
951
  setAnswer: (answers: Answer[]) => void;
867
952
  ask: (e: React.FormEvent) => void;
868
- clearChat: () => void;
953
+ closeChat: () => void;
954
+ resetChat: () => void;
869
955
  chatInputRef: React.RefObject<HTMLInputElement | null>;
870
956
  }>({
871
957
  isOpen: false,
@@ -878,7 +964,8 @@ const ChatContext = createContext<{
878
964
  answer: [],
879
965
  setAnswer: () => {},
880
966
  ask: () => {},
881
- clearChat: () => {},
967
+ closeChat: () => {},
968
+ resetChat: () => {},
882
969
  chatInputRef: { current: null },
883
970
  });
884
971
 
@@ -938,6 +1025,7 @@ const ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {
938
1025
  const reader = res.body?.getReader();
939
1026
  const decoder = new TextDecoder();
940
1027
  const contentParts: string[] = [];
1028
+ let sources: Source[] = [];
941
1029
  if (!reader) {
942
1030
  throw new Error("Failed to get response reader");
943
1031
  }
@@ -959,7 +1047,15 @@ const ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {
959
1047
  try {
960
1048
  const data = JSON.parse(line.slice(6));
961
1049
 
962
- if (data.type === "content") {
1050
+ if (data.type === "metadata") {
1051
+ const allSources: Source[] = data.data?.sources ?? [];
1052
+ const seen = new Set<string>();
1053
+ sources = allSources.filter((s: Source) => {
1054
+ if (s.score < 0.4 || seen.has(s.uri)) return false;
1055
+ seen.add(s.uri);
1056
+ return true;
1057
+ });
1058
+ } else if (data.type === "content") {
963
1059
  contentParts.push(data.data);
964
1060
  const streamedContent = contentParts.join("");
965
1061
 
@@ -968,6 +1064,7 @@ const ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {
968
1064
  newAnswers[streamingAnswerIndex] = {
969
1065
  text: streamedContent,
970
1066
  answer: true,
1067
+ sources,
971
1068
  };
972
1069
  return newAnswers;
973
1070
  });
@@ -996,6 +1093,7 @@ const ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {
996
1093
  text: streamedContent,
997
1094
  answer: true,
998
1095
  mdx: mdxSource || undefined,
1096
+ sources,
999
1097
  };
1000
1098
  return newAnswers;
1001
1099
  });
@@ -1020,12 +1118,17 @@ const ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {
1020
1118
  }
1021
1119
  }
1022
1120
 
1023
- function clearChat() {
1024
- abortRef.current?.abort();
1025
- setAnswer([]);
1121
+ function closeChat() {
1026
1122
  setIsOpen(false);
1027
1123
  }
1028
1124
 
1125
+ function resetChat() {
1126
+ abortRef.current?.abort();
1127
+ setLoading(false);
1128
+ setError(null);
1129
+ setAnswer([{ text: "Hey there, how can I assist you?", answer: true }]);
1130
+ }
1131
+
1029
1132
  return (
1030
1133
  <ChatContext.Provider
1031
1134
  value={{
@@ -1039,7 +1142,8 @@ const ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {
1039
1142
  answer,
1040
1143
  setAnswer,
1041
1144
  ask,
1042
- clearChat,
1145
+ closeChat,
1146
+ resetChat,
1043
1147
  chatInputRef,
1044
1148
  }}
1045
1149
  >
@@ -1 +1 @@
1
- export declare const docsSideBarTemplate = "\"use client\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { Space } from \"cherry-styled-components\";\nimport {\n StyledIndexSidebar,\n StyledIndexSidebarLink,\n StyledIndexSidebarLabel,\n StyledIndexSidebarLi,\n} from \"@/components/layout/DocsComponents\";\n\ninterface Heading {\n id: string;\n text: string;\n level: number;\n}\n\nconst FALLBACK_OFFSET = 60;\n\nfunction getOffset() {\n const header = document.getElementById(\"header\");\n return (header ? header.offsetHeight : FALLBACK_OFFSET) + 20;\n}\n\nexport function DocsSideBar({ headings }: { headings: Heading[] }) {\n const [activeId, setActiveId] = useState<string>(\"\");\n const activeRef = useRef<HTMLLIElement>(null);\n\n const handleScroll = useCallback(() => {\n if (headings.length === 0) return;\n\n const headingElements = headings\n .map((heading) => document.getElementById(heading.id))\n .filter((el): el is HTMLElement => el !== null);\n\n if (headingElements.length === 0) return;\n\n const windowHeight = window.innerHeight;\n\n const visibleHeadings = headingElements.filter((element) => {\n const rect = element.getBoundingClientRect();\n const elementTop = rect.top;\n const elementBottom = rect.bottom;\n return elementTop < windowHeight && elementBottom > -50;\n });\n\n if (visibleHeadings.length > 0) {\n let closestHeading = visibleHeadings[0];\n let closestDistance = Math.abs(\n closestHeading.getBoundingClientRect().top - getOffset(),\n );\n for (const heading of visibleHeadings) {\n const distance = Math.abs(heading.getBoundingClientRect().top - getOffset());\n if (\n distance < closestDistance &&\n heading.getBoundingClientRect().top <= windowHeight * 0.3\n ) {\n closestDistance = distance;\n closestHeading = heading;\n }\n }\n setActiveId(closestHeading.id);\n return;\n }\n\n let currentActiveId = headings[0].id;\n for (const element of headingElements) {\n const rect = element.getBoundingClientRect();\n if (rect.top <= getOffset()) {\n currentActiveId = element.id;\n } else {\n break;\n }\n }\n setActiveId(currentActiveId);\n }, [headings]);\n\n useEffect(() => {\n if (headings.length === 0) return;\n // Set active heading from URL hash immediately on mount\n if (window.location.hash) {\n setActiveId(window.location.hash.slice(1));\n }\n // Run initial scroll check on next frame to avoid synchronous setState in effect\n const rafId = requestAnimationFrame(handleScroll);\n // Re-check after browser finishes scrolling to hash target on new tab/page load\n const delayedId = setTimeout(handleScroll, 300);\n let timeoutId: NodeJS.Timeout;\n const throttledHandleScroll = () => {\n clearTimeout(timeoutId);\n timeoutId = setTimeout(handleScroll, 50);\n };\n window.addEventListener(\"scroll\", throttledHandleScroll);\n window.addEventListener(\"resize\", handleScroll);\n return () => {\n window.removeEventListener(\"scroll\", throttledHandleScroll);\n window.removeEventListener(\"resize\", handleScroll);\n cancelAnimationFrame(rafId);\n clearTimeout(delayedId);\n clearTimeout(timeoutId);\n };\n }, [handleScroll, headings]);\n\n useEffect(() => {\n const el = activeRef.current;\n const container = el?.closest(\"[data-sidebar]\") as HTMLElement | null;\n if (!el || !container) return;\n const elRect = el.getBoundingClientRect();\n const cRect = container.getBoundingClientRect();\n const pad = 140;\n if (elRect.bottom + pad > cRect.bottom) {\n container.scrollBy({ top: elRect.bottom - cRect.bottom + pad, behavior: \"smooth\" });\n } else if (elRect.top - pad < cRect.top) {\n container.scrollBy({ top: elRect.top - cRect.top - pad, behavior: \"smooth\" });\n }\n }, [activeId]);\n\n const handleHeadingClick = (headingId: string) => {\n const element = document.getElementById(headingId);\n if (element) {\n const elementPosition =\n element.getBoundingClientRect().top + window.scrollY;\n window.scrollTo({ top: elementPosition - getOffset(), behavior: \"smooth\" });\n }\n };\n\n return (\n <StyledIndexSidebar data-sidebar>\n {headings?.length > 0 && (\n <>\n <StyledIndexSidebarLabel>On this page</StyledIndexSidebarLabel>\n <Space $size={15} />\n </>\n )}\n {headings.map((heading, index) => (\n <StyledIndexSidebarLi\n key={index}\n ref={activeId === heading.id ? activeRef : null}\n $isActive={activeId === heading.id}\n style={{ paddingLeft: `${(heading.level - 1) * 16}px` }}\n >\n <StyledIndexSidebarLink\n href={`#${heading.id}`}\n onClick={(e) => {\n e.preventDefault();\n handleHeadingClick(heading.id);\n }}\n $isActive={activeId === heading.id}\n >\n {heading.text}\n </StyledIndexSidebarLink>\n </StyledIndexSidebarLi>\n ))}\n </StyledIndexSidebar>\n );\n}\n";
1
+ export declare const docsSideBarTemplate = "\"use client\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { Space } from \"cherry-styled-components\";\nimport {\n StyledIndexSidebar,\n StyledIndexSidebarLink,\n StyledIndexSidebarLabel,\n StyledIndexSidebarLi,\n} from \"@/components/layout/DocsComponents\";\n\ninterface Heading {\n id: string;\n text: string;\n level: number;\n}\n\nconst FALLBACK_OFFSET = 60;\n\nfunction getOffset() {\n const header = document.getElementById(\"header\");\n return (header ? header.offsetHeight : FALLBACK_OFFSET) + 20;\n}\n\nexport function DocsSideBar({ headings }: { headings: Heading[] }) {\n const [activeId, setActiveId] = useState<string>(\"\");\n const activeRef = useRef<HTMLLIElement>(null);\n\n const handleScroll = useCallback(() => {\n if (headings.length === 0) return;\n\n const headingElements = headings\n .map((heading) => document.getElementById(heading.id))\n .filter((el): el is HTMLElement => el !== null);\n\n if (headingElements.length === 0) return;\n\n const windowHeight = window.innerHeight;\n\n const visibleHeadings = headingElements.filter((element) => {\n const rect = element.getBoundingClientRect();\n const elementTop = rect.top;\n const elementBottom = rect.bottom;\n return elementTop < windowHeight && elementBottom > -50;\n });\n\n if (visibleHeadings.length > 0) {\n let closestHeading = visibleHeadings[0];\n let closestDistance = Math.abs(\n closestHeading.getBoundingClientRect().top - getOffset(),\n );\n for (const heading of visibleHeadings) {\n const distance = Math.abs(\n heading.getBoundingClientRect().top - getOffset(),\n );\n if (\n distance < closestDistance &&\n heading.getBoundingClientRect().top <= windowHeight * 0.3\n ) {\n closestDistance = distance;\n closestHeading = heading;\n }\n }\n setActiveId(closestHeading.id);\n return;\n }\n\n let currentActiveId = headings[0].id;\n for (const element of headingElements) {\n const rect = element.getBoundingClientRect();\n if (rect.top <= getOffset()) {\n currentActiveId = element.id;\n } else {\n break;\n }\n }\n setActiveId(currentActiveId);\n }, [headings]);\n\n useEffect(() => {\n if (headings.length === 0) return;\n // Set active heading from URL hash immediately on mount\n if (window.location.hash) {\n setActiveId(window.location.hash.slice(1));\n }\n // Run initial scroll check on next frame to avoid synchronous setState in effect\n const rafId = requestAnimationFrame(handleScroll);\n // Re-check after browser finishes scrolling to hash target on new tab/page load\n const delayedId = setTimeout(handleScroll, 300);\n let timeoutId: NodeJS.Timeout;\n const throttledHandleScroll = () => {\n clearTimeout(timeoutId);\n timeoutId = setTimeout(handleScroll, 50);\n };\n window.addEventListener(\"scroll\", throttledHandleScroll);\n window.addEventListener(\"resize\", handleScroll);\n return () => {\n window.removeEventListener(\"scroll\", throttledHandleScroll);\n window.removeEventListener(\"resize\", handleScroll);\n cancelAnimationFrame(rafId);\n clearTimeout(delayedId);\n clearTimeout(timeoutId);\n };\n }, [handleScroll, headings]);\n\n useEffect(() => {\n const el = activeRef.current;\n const container = el?.closest(\"[data-sidebar]\") as HTMLElement | null;\n if (!el || !container) return;\n const elRect = el.getBoundingClientRect();\n const cRect = container.getBoundingClientRect();\n const pad = 140;\n if (elRect.bottom + pad > cRect.bottom) {\n container.scrollBy({\n top: elRect.bottom - cRect.bottom + pad,\n behavior: \"smooth\",\n });\n } else if (elRect.top - pad < cRect.top) {\n container.scrollBy({\n top: elRect.top - cRect.top - pad,\n behavior: \"smooth\",\n });\n }\n }, [activeId]);\n\n const handleHeadingClick = (headingId: string) => {\n const element = document.getElementById(headingId);\n if (element) {\n const elementPosition =\n element.getBoundingClientRect().top + window.scrollY;\n window.scrollTo({\n top: elementPosition - getOffset(),\n behavior: \"smooth\",\n });\n }\n };\n\n return (\n <StyledIndexSidebar data-sidebar>\n {headings?.length > 0 && (\n <>\n <StyledIndexSidebarLabel>On this page</StyledIndexSidebarLabel>\n <Space $size={15} />\n </>\n )}\n {headings.map((heading, index) => (\n <StyledIndexSidebarLi\n key={index}\n ref={activeId === heading.id ? activeRef : null}\n $isActive={activeId === heading.id}\n style={{ paddingLeft: `${(heading.level - 1) * 16}px` }}\n >\n <StyledIndexSidebarLink\n href={`#${heading.id}`}\n onClick={(e) => {\n e.preventDefault();\n handleHeadingClick(heading.id);\n }}\n $isActive={activeId === heading.id}\n >\n {heading.text}\n </StyledIndexSidebarLink>\n </StyledIndexSidebarLi>\n ))}\n </StyledIndexSidebar>\n );\n}\n";
@@ -49,7 +49,9 @@ export function DocsSideBar({ headings }: { headings: Heading[] }) {
49
49
  closestHeading.getBoundingClientRect().top - getOffset(),
50
50
  );
51
51
  for (const heading of visibleHeadings) {
52
- const distance = Math.abs(heading.getBoundingClientRect().top - getOffset());
52
+ const distance = Math.abs(
53
+ heading.getBoundingClientRect().top - getOffset(),
54
+ );
53
55
  if (
54
56
  distance < closestDistance &&
55
57
  heading.getBoundingClientRect().top <= windowHeight * 0.3
@@ -108,9 +110,15 @@ export function DocsSideBar({ headings }: { headings: Heading[] }) {
108
110
  const cRect = container.getBoundingClientRect();
109
111
  const pad = 140;
110
112
  if (elRect.bottom + pad > cRect.bottom) {
111
- container.scrollBy({ top: elRect.bottom - cRect.bottom + pad, behavior: "smooth" });
113
+ container.scrollBy({
114
+ top: elRect.bottom - cRect.bottom + pad,
115
+ behavior: "smooth",
116
+ });
112
117
  } else if (elRect.top - pad < cRect.top) {
113
- container.scrollBy({ top: elRect.top - cRect.top - pad, behavior: "smooth" });
118
+ container.scrollBy({
119
+ top: elRect.top - cRect.top - pad,
120
+ behavior: "smooth",
121
+ });
114
122
  }
115
123
  }, [activeId]);
116
124
 
@@ -119,7 +127,10 @@ export function DocsSideBar({ headings }: { headings: Heading[] }) {
119
127
  if (element) {
120
128
  const elementPosition =
121
129
  element.getBoundingClientRect().top + window.scrollY;
122
- window.scrollTo({ top: elementPosition - getOffset(), behavior: "smooth" });
130
+ window.scrollTo({
131
+ top: elementPosition - getOffset(),
132
+ behavior: "smooth",
133
+ });
123
134
  }
124
135
  };
125
136
 
@@ -0,0 +1 @@
1
+ export declare const lockBodyScrollTemplate = "import { useEffect } from \"react\";\n\nexport function useLockBodyScroll(isActive: boolean) {\n useEffect(() => {\n const mql = window.matchMedia(\"(max-width: 992px)\");\n function update() {\n document.body.style.overflow = isActive && mql.matches ? \"hidden\" : \"\";\n }\n update();\n mql.addEventListener(\"change\", update);\n return () => {\n mql.removeEventListener(\"change\", update);\n document.body.style.overflow = \"\";\n };\n }, [isActive]);\n}\n";
@@ -0,0 +1,17 @@
1
+ export const lockBodyScrollTemplate = `import { useEffect } from "react";
2
+
3
+ export function useLockBodyScroll(isActive: boolean) {
4
+ useEffect(() => {
5
+ const mql = window.matchMedia("(max-width: 992px)");
6
+ function update() {
7
+ document.body.style.overflow = isActive && mql.matches ? "hidden" : "";
8
+ }
9
+ update();
10
+ mql.addEventListener("change", update);
11
+ return () => {
12
+ mql.removeEventListener("change", update);
13
+ document.body.style.overflow = "";
14
+ };
15
+ }, [isActive]);
16
+ }
17
+ `;
@@ -1 +1 @@
1
- export declare const sideBarTemplate = "\"use client\";\nimport { useContext, useState, Suspense } from \"react\";\nimport { usePathname } from \"next/navigation\";\nimport { Flex, Space } from \"cherry-styled-components\";\nimport {\n DocsSidebar,\n SectionBarContext,\n StyledSidebar,\n StyledSidebarList,\n StyledSidebarListItem,\n StyledStrong,\n StyledSidebarListItemLink,\n StyleMobileBar,\n StyledMobileBurger,\n} from \"@/components/layout/DocsComponents\";\nimport {\n ToggleTheme,\n ToggleThemeLoading,\n} from \"@/components/layout/ThemeToggle\";\n\ntype NavItem = {\n label: string;\n links: NavItemLink[];\n};\n\ntype NavItemLink = {\n slug: string;\n title: string;\n};\n\ninterface SideBarProps {\n result: NavItem[];\n}\n\nfunction SideBar({ result }: SideBarProps) {\n const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);\n const hasSectionBar = useContext(SectionBarContext);\n const pathname = usePathname();\n\n return (\n <DocsSidebar>\n <StyleMobileBar\n onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}\n $isActive={isMobileMenuOpen}\n >\n <StyledMobileBurger $isActive={isMobileMenuOpen} />\n </StyleMobileBar>\n\n <StyledSidebar\n $isActive={isMobileMenuOpen}\n $hasSectionBar={hasSectionBar}\n >\n {result &&\n result.map((item: NavItem, index: number) => {\n return (\n <StyledSidebarList key={index}>\n <StyledSidebarListItem>\n <StyledStrong>{item.label}</StyledStrong>{\" \"}\n </StyledSidebarListItem>\n <li>\n <Space $size={20} />\n </li>\n {item.links &&\n item.links.map((link: NavItemLink, indexChild: number) => {\n return (\n <StyledSidebarListItem key={indexChild}>\n <StyledSidebarListItemLink\n href={`/${link.slug}`}\n $isActive={pathname === `/${link.slug}`}\n onClick={() => setIsMobileMenuOpen(false)}\n >\n {link.title}\n </StyledSidebarListItemLink>\n </StyledSidebarListItem>\n );\n })}\n <Space $size={20} />\n </StyledSidebarList>\n );\n })}\n <Space $xs={40} $lg={20} />\n <Flex $xsJustifyContent=\"flex-start\" $lgJustifyContent=\"flex-end\">\n <Suspense fallback={<ToggleThemeLoading />}>\n <ToggleTheme />\n </Suspense>\n </Flex>\n </StyledSidebar>\n </DocsSidebar>\n );\n}\n\nexport { SideBar };\n";
1
+ export declare const sideBarTemplate = "\"use client\";\nimport { useContext, useState, Suspense } from \"react\";\nimport { usePathname } from \"next/navigation\";\nimport { Flex, Space } from \"cherry-styled-components\";\nimport {\n DocsSidebar,\n SectionBarContext,\n StyledSidebar,\n StyledSidebarList,\n StyledSidebarListItem,\n StyledStrong,\n StyledSidebarListItemLink,\n StyleMobileBar,\n StyledMobileBurger,\n} from \"@/components/layout/DocsComponents\";\nimport {\n ToggleTheme,\n ToggleThemeLoading,\n} from \"@/components/layout/ThemeToggle\";\nimport { useLockBodyScroll } from \"@/components/LockBodyScroll\";\n\ntype NavItem = {\n label: string;\n links: NavItemLink[];\n};\n\ntype NavItemLink = {\n slug: string;\n title: string;\n};\n\ninterface SideBarProps {\n result: NavItem[];\n}\n\nfunction SideBar({ result }: SideBarProps) {\n const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);\n const hasSectionBar = useContext(SectionBarContext);\n const pathname = usePathname();\n\n useLockBodyScroll(isMobileMenuOpen);\n\n return (\n <DocsSidebar>\n <StyleMobileBar\n onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}\n $isActive={isMobileMenuOpen}\n >\n <StyledMobileBurger $isActive={isMobileMenuOpen} />\n </StyleMobileBar>\n\n <StyledSidebar\n $isActive={isMobileMenuOpen}\n $hasSectionBar={hasSectionBar}\n >\n {result &&\n result.map((item: NavItem, index: number) => {\n return (\n <StyledSidebarList key={index}>\n <StyledSidebarListItem>\n <StyledStrong>{item.label}</StyledStrong>{\" \"}\n </StyledSidebarListItem>\n <li>\n <Space $size={20} />\n </li>\n {item.links &&\n item.links.map((link: NavItemLink, indexChild: number) => {\n return (\n <StyledSidebarListItem key={indexChild}>\n <StyledSidebarListItemLink\n href={`/${link.slug}`}\n $isActive={pathname === `/${link.slug}`}\n onClick={() => setIsMobileMenuOpen(false)}\n >\n {link.title}\n </StyledSidebarListItemLink>\n </StyledSidebarListItem>\n );\n })}\n <Space $size={20} />\n </StyledSidebarList>\n );\n })}\n <Space $xs={40} $lg={20} />\n <Flex $xsJustifyContent=\"flex-start\" $lgJustifyContent=\"flex-end\">\n <Suspense fallback={<ToggleThemeLoading />}>\n <ToggleTheme />\n </Suspense>\n </Flex>\n </StyledSidebar>\n </DocsSidebar>\n );\n}\n\nexport { SideBar };\n";
@@ -17,6 +17,7 @@ import {
17
17
  ToggleTheme,
18
18
  ToggleThemeLoading,
19
19
  } from "@/components/layout/ThemeToggle";
20
+ import { useLockBodyScroll } from "@/components/LockBodyScroll";
20
21
 
21
22
  type NavItem = {
22
23
  label: string;
@@ -37,6 +38,8 @@ function SideBar({ result }: SideBarProps) {
37
38
  const hasSectionBar = useContext(SectionBarContext);
38
39
  const pathname = usePathname();
39
40
 
41
+ useLockBodyScroll(isMobileMenuOpen);
42
+
40
43
  return (
41
44
  <DocsSidebar>
42
45
  <StyleMobileBar
@@ -1 +1 @@
1
- export declare const actionBarTemplate = "\"use client\";\nimport { useContext, useState } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { rgba } from \"polished\";\nimport { resetButton, Textarea } from \"cherry-styled-components\";\nimport { ChatContext } from \"@/components/Chat\";\nimport { SectionBarContext } from \"@/components/layout/DocsComponents\";\nimport { StyledSmallButton } from \"@/components/layout/SharedStyled\";\n\ninterface ActionBarProps {\n children: React.ReactNode;\n content: string;\n}\n\nconst StyledActionBar = styled.div<{\n theme: Theme;\n $isChatOpen?: boolean;\n}>`\n position: absolute;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n left: 0;\n padding: 12px 20px;\n display: flex;\n justify-content: space-between;\n width: 100%;\n transition: all 0.3s ease;\n\n ${mq(\"lg\")} {\n left: 50%;\n transform: translateX(-50%);\n max-width: calc(100vw - 560px);\n width: 100%;\n padding: 12px;\n margin: 0;\n\n ${({ $isChatOpen }) =>\n $isChatOpen &&\n css`\n padding-right: 152px;\n `}\n }\n`;\n\nconst StyledActionBarContent = styled.div`\n margin: auto 0;\n`;\n\nconst StyledCopyButton = styled(StyledSmallButton)<{\n theme: Theme;\n $copied: boolean;\n}>`\n border: solid 1px\n ${({ theme, $copied }) =>\n $copied ? theme.colors.success : theme.colors.grayLight};\n color: ${({ theme, $copied }) =>\n $copied ? theme.colors.success : theme.colors.primary};\n\n & svg.lucide {\n color: ${({ theme, $copied }) =>\n $copied ? theme.colors.success : theme.colors.primary};\n }\n`;\n\nconst StyledToggle = styled.button<{ theme: Theme; $isActive?: boolean }>`\n ${resetButton}\n width: 56px;\n height: 32px;\n border-radius: 30px;\n display: flex;\n position: relative;\n margin: auto 0;\n transform: scale(1);\n background: ${({ theme }) => theme.colors.light};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n &::after {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};\n transition: all 0.3s ease;\n z-index: 1;\n ${({ $isActive }) =>\n !$isActive &&\n css`\n transform: translateX(24px);\n `}\n }\n\n & svg {\n width: 16px;\n height: 16px;\n object-fit: contain;\n margin: auto;\n transition: all 0.3s ease;\n position: relative;\n z-index: 2;\n }\n\n & .lucide-eye {\n transform: translateX(1px);\n }\n\n & .lucide-code-xml {\n transform: translateX(-1px);\n }\n\n & svg[stroke] {\n stroke: ${({ theme }) => theme.colors.primary};\n }\n\n &:hover {\n transform: scale(1.05);\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n\n & svg[stroke] {\n stroke: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n }\n }\n\n &:active {\n transform: scale(0.97);\n }\n`;\n\nconst StyledContent = styled.div<{\n theme: Theme;\n $isChatActive?: boolean;\n $isChatOpen?: boolean;\n $hasSectionBar?: boolean;\n}>`\n padding-top: 77px;\n transition: all 0.3s ease;\n\n & textarea {\n max-width: 640px;\n margin: auto;\n width: 100%;\n height: 100%;\n min-height: calc(\n 100vh - ${({ $hasSectionBar }) => ($hasSectionBar ? 202 : 160)}px\n );\n\n ${mq(\"lg\")} {\n min-height: calc(100vh - 159px);\n }\n }\n`;\n\nfunction ActionBar({ children, content }: ActionBarProps) {\n const [isView, setIsView] = useState(true);\n const [copied, setCopied] = useState(false);\n const { isOpen, isChatActive } = useContext(ChatContext);\n const hasSectionBar = useContext(SectionBarContext);\n\n const handleCopyContent = async () => {\n try {\n await navigator.clipboard.writeText(content);\n setCopied(true);\n setTimeout(() => setCopied(false), 2000);\n } catch (err) {\n console.error(\"Failed to copy:\", err);\n }\n };\n\n return (\n <>\n <StyledActionBar $isChatOpen={isOpen}>\n <StyledCopyButton onClick={handleCopyContent} $copied={copied}>\n {copied ? (\n <>\n <Icon name=\"check\" size={16} />\n Copied!\n </>\n ) : (\n <>\n <Icon name=\"copy\" size={16} />\n Copy Content\n </>\n )}\n </StyledCopyButton>\n <StyledActionBarContent>\n <StyledToggle\n onClick={() => setIsView(!isView)}\n aria-label=\"Toggle Theme\"\n $isActive={isView}\n >\n <Icon name=\"Eye\" />\n <Icon name=\"CodeXml\" />\n </StyledToggle>\n </StyledActionBarContent>\n </StyledActionBar>\n {isView && (\n <StyledContent\n $isChatActive={isChatActive}\n $isChatOpen={isOpen}\n $hasSectionBar={hasSectionBar}\n >\n {children}\n </StyledContent>\n )}\n {!isView && (\n <StyledContent\n $isChatActive={isChatActive}\n $isChatOpen={isOpen}\n $hasSectionBar={hasSectionBar}\n >\n <Textarea defaultValue={content} $fullWidth />\n </StyledContent>\n )}\n </>\n );\n}\n\nexport { ActionBar };\n";
1
+ export declare const actionBarTemplate = "\"use client\";\nimport { useContext, useState } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { rgba } from \"polished\";\nimport { resetButton, Textarea } from \"cherry-styled-components\";\nimport { SectionBarContext } from \"@/components/layout/DocsComponents\";\nimport { StyledSmallButton } from \"@/components/layout/SharedStyled\";\n\ninterface ActionBarProps {\n children: React.ReactNode;\n content: string;\n}\n\nconst StyledActionBar = styled.div<{\n theme: Theme;\n $isChatOpen?: boolean;\n}>`\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n left: 0;\n padding: 12px 0;\n display: flex;\n justify-content: space-between;\n width: 100%;\n max-width: 640px;\n margin: auto;\n transition: all 0.3s ease;\n\n ${mq(\"lg\")} {\n padding: 12px 0;\n }\n`;\n\nconst StyledActionBarContent = styled.div`\n margin: auto 0;\n`;\n\nconst StyledCopyButton = styled(StyledSmallButton)<{\n theme: Theme;\n $copied: boolean;\n}>`\n border: solid 1px\n ${({ theme, $copied }) =>\n $copied ? theme.colors.success : theme.colors.grayLight};\n color: ${({ theme, $copied }) =>\n $copied ? theme.colors.success : theme.colors.primary};\n\n & svg.lucide {\n color: ${({ theme, $copied }) =>\n $copied ? theme.colors.success : theme.colors.primary};\n }\n`;\n\nconst StyledToggle = styled.button<{ theme: Theme; $isActive?: boolean }>`\n ${resetButton}\n width: 56px;\n height: 32px;\n border-radius: 30px;\n display: flex;\n position: relative;\n margin: auto 0;\n transform: scale(1);\n background: ${({ theme }) => theme.colors.light};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n &::after {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};\n transition: all 0.3s ease;\n z-index: 1;\n ${({ $isActive }) =>\n !$isActive &&\n css`\n transform: translateX(24px);\n `}\n }\n\n & svg {\n width: 16px;\n height: 16px;\n object-fit: contain;\n margin: auto;\n transition: all 0.3s ease;\n position: relative;\n z-index: 2;\n }\n\n & .lucide-eye {\n transform: translateX(1px);\n }\n\n & .lucide-code-xml {\n transform: translateX(-1px);\n }\n\n & svg[stroke] {\n stroke: ${({ theme }) => theme.colors.primary};\n }\n\n &:hover {\n transform: scale(1.05);\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n\n & svg[stroke] {\n stroke: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n }\n }\n\n &:active {\n transform: scale(0.97);\n }\n`;\n\nconst StyledContent = styled.div<{\n theme: Theme;\n $hasSectionBar?: boolean;\n}>`\n padding-top: 20px;\n transition: all 0.3s ease;\n\n & textarea {\n max-width: 640px;\n margin: auto;\n width: 100%;\n height: 100%;\n min-height: calc(\n 100vh - ${({ $hasSectionBar }) => ($hasSectionBar ? 202 : 160)}px\n );\n\n ${mq(\"lg\")} {\n min-height: calc(100vh - 159px);\n }\n }\n`;\n\nfunction ActionBar({ children, content }: ActionBarProps) {\n const [isView, setIsView] = useState(true);\n const [copied, setCopied] = useState(false);\n const hasSectionBar = useContext(SectionBarContext);\n\n const handleCopyContent = async () => {\n try {\n await navigator.clipboard.writeText(content);\n setCopied(true);\n setTimeout(() => setCopied(false), 2000);\n } catch (err) {\n console.error(\"Failed to copy:\", err);\n }\n };\n\n return (\n <>\n <StyledActionBar>\n <StyledCopyButton onClick={handleCopyContent} $copied={copied}>\n {copied ? (\n <>\n <Icon name=\"check\" size={16} />\n Copied!\n </>\n ) : (\n <>\n <Icon name=\"copy\" size={16} />\n Copy content\n </>\n )}\n </StyledCopyButton>\n <StyledActionBarContent>\n <StyledToggle\n onClick={() => setIsView(!isView)}\n aria-label=\"Toggle Theme\"\n $isActive={isView}\n >\n <Icon name=\"Eye\" />\n <Icon name=\"CodeXml\" />\n </StyledToggle>\n </StyledActionBarContent>\n </StyledActionBar>\n {isView && (\n <StyledContent $hasSectionBar={hasSectionBar}>{children}</StyledContent>\n )}\n {!isView && (\n <StyledContent $hasSectionBar={hasSectionBar}>\n <Textarea defaultValue={content} $fullWidth />\n </StyledContent>\n )}\n </>\n );\n}\n\nexport { ActionBar };\n";
@@ -1,4 +1,3 @@
1
- import { SIDEBAR_WIDTH } from "../../app/theme.js";
2
1
  export const actionBarTemplate = `"use client";
3
2
  import { useContext, useState } from "react";
4
3
  import styled, { css } from "styled-components";
@@ -6,7 +5,6 @@ import { Icon } from "@/components/layout/Icon";
6
5
  import { mq, Theme } from "@/app/theme";
7
6
  import { rgba } from "polished";
8
7
  import { resetButton, Textarea } from "cherry-styled-components";
9
- import { ChatContext } from "@/components/Chat";
10
8
  import { SectionBarContext } from "@/components/layout/DocsComponents";
11
9
  import { StyledSmallButton } from "@/components/layout/SharedStyled";
12
10
 
@@ -19,28 +17,18 @@ const StyledActionBar = styled.div<{
19
17
  theme: Theme;
20
18
  $isChatOpen?: boolean;
21
19
  }>\`
22
- position: absolute;
23
20
  border-bottom: solid 1px \${({ theme }) => theme.colors.grayLight};
24
21
  left: 0;
25
- padding: 12px 20px;
22
+ padding: 12px 0;
26
23
  display: flex;
27
24
  justify-content: space-between;
28
25
  width: 100%;
26
+ max-width: 640px;
27
+ margin: auto;
29
28
  transition: all 0.3s ease;
30
29
 
31
30
  \${mq("lg")} {
32
- left: 50%;
33
- transform: translateX(-50%);
34
- max-width: calc(100vw - ${SIDEBAR_WIDTH * 2}px);
35
- width: 100%;
36
- padding: 12px;
37
- margin: 0;
38
-
39
- \${({ $isChatOpen }) =>
40
- $isChatOpen &&
41
- css\`
42
- padding-right: 152px;
43
- \`}
31
+ padding: 12px 0;
44
32
  }
45
33
  \`;
46
34
 
@@ -134,11 +122,9 @@ const StyledToggle = styled.button<{ theme: Theme; $isActive?: boolean }>\`
134
122
 
135
123
  const StyledContent = styled.div<{
136
124
  theme: Theme;
137
- $isChatActive?: boolean;
138
- $isChatOpen?: boolean;
139
125
  $hasSectionBar?: boolean;
140
126
  }>\`
141
- padding-top: 77px;
127
+ padding-top: 20px;
142
128
  transition: all 0.3s ease;
143
129
 
144
130
  & textarea {
@@ -159,7 +145,6 @@ const StyledContent = styled.div<{
159
145
  function ActionBar({ children, content }: ActionBarProps) {
160
146
  const [isView, setIsView] = useState(true);
161
147
  const [copied, setCopied] = useState(false);
162
- const { isOpen, isChatActive } = useContext(ChatContext);
163
148
  const hasSectionBar = useContext(SectionBarContext);
164
149
 
165
150
  const handleCopyContent = async () => {
@@ -174,7 +159,7 @@ function ActionBar({ children, content }: ActionBarProps) {
174
159
 
175
160
  return (
176
161
  <>
177
- <StyledActionBar $isChatOpen={isOpen}>
162
+ <StyledActionBar>
178
163
  <StyledCopyButton onClick={handleCopyContent} $copied={copied}>
179
164
  {copied ? (
180
165
  <>
@@ -184,7 +169,7 @@ function ActionBar({ children, content }: ActionBarProps) {
184
169
  ) : (
185
170
  <>
186
171
  <Icon name="copy" size={16} />
187
- Copy Content
172
+ Copy content
188
173
  </>
189
174
  )}
190
175
  </StyledCopyButton>
@@ -200,20 +185,10 @@ function ActionBar({ children, content }: ActionBarProps) {
200
185
  </StyledActionBarContent>
201
186
  </StyledActionBar>
202
187
  {isView && (
203
- <StyledContent
204
- $isChatActive={isChatActive}
205
- $isChatOpen={isOpen}
206
- $hasSectionBar={hasSectionBar}
207
- >
208
- {children}
209
- </StyledContent>
188
+ <StyledContent $hasSectionBar={hasSectionBar}>{children}</StyledContent>
210
189
  )}
211
190
  {!isView && (
212
- <StyledContent
213
- $isChatActive={isChatActive}
214
- $isChatOpen={isOpen}
215
- $hasSectionBar={hasSectionBar}
216
- >
191
+ <StyledContent $hasSectionBar={hasSectionBar}>
217
192
  <Textarea defaultValue={content} $fullWidth />
218
193
  </StyledContent>
219
194
  )}
@@ -1 +1 @@
1
- export declare const codeTemplate = "\"use client\";\nimport { useState, useCallback, useMemo } from \"react\";\nimport styled from \"styled-components\";\nimport { Theme, styledCode } from \"cherry-styled-components\";\nimport { rgba } from \"polished\";\nimport { unified } from \"unified\";\nimport rehypeParse from \"rehype-parse\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport rehypeStringify from \"rehype-stringify\";\nimport { Icon } from \"@/components/layout/Icon\";\n\ninterface CodeProps extends Omit<\n React.HTMLAttributes<HTMLDivElement>,\n \"theme\"\n> {\n code: string;\n language?: string;\n theme?: Theme;\n}\n\nconst CodeWrapper = styled.span<{ theme: Theme }>`\n position: relative;\n z-index: 2;\n display: block;\n width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n border: solid 1px\n ${({ theme }) =>\n theme.isDark\n ? rgba(theme.colors.dark, 0.2)\n : rgba(theme.colors.dark, 0.1)};\n`;\n\nconst TopBar = styled.div<{ theme: Theme }>`\n background: ${({ theme }) => (theme.isDark ? \"#0d1117\" : \"#f6f8fa\")};\n border-top-left-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-top-right-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-bottom: solid 1px\n ${({ theme }) =>\n theme.isDark ? rgba(\"#ffffff\", 0.1) : rgba(\"#000000\", 0.1)};\n height: 33px;\n width: 100%;\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 5px;\n padding: 0 10px;\n`;\n\nconst DotsContainer = styled.div`\n display: flex;\n gap: 5px;\n`;\n\nconst Dot = styled.span<{ theme: Theme }>`\n width: 10px;\n height: 10px;\n border-radius: 50%;\n background: ${({ theme }) =>\n theme.isDark ? rgba(\"#ffffff\", 0.1) : rgba(\"#000000\", 0.1)};\n`;\n\nconst CopyButton = styled.button<{ theme: Theme; $copied: boolean }>`\n background: ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? rgba(\"#7ee787\", 0.2)\n : rgba(\"#2da44e\", 0.1)\n : \"transparent\"};\n border: solid 1px\n ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? \"#7ee787\"\n : \"#2da44e\"\n : theme.isDark\n ? rgba(\"#ffffff\", 0.1)\n : rgba(\"#000000\", 0.1)};\n color: ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? \"#7ee787\"\n : \"#2da44e\"\n : theme.isDark\n ? \"#c9d1d9\"\n : \"#57606a\"};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n padding: 4px 8px;\n font-size: 12px;\n font-family: ${({ theme }) => theme.fonts.mono};\n cursor: pointer;\n transition: all 0.2s ease;\n display: flex;\n align-items: center;\n gap: 4px;\n margin-right: -6px;\n\n & svg.lucide {\n color: ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? \"#7ee787\"\n : \"#2da44e\"\n : theme.isDark\n ? \"#c9d1d9\"\n : \"#57606a\"};\n }\n\n &:hover {\n background: ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? rgba(\"#7ee787\", 0.3)\n : rgba(\"#2da44e\", 0.2)\n : theme.isDark\n ? rgba(\"#ffffff\", 0.1)\n : rgba(\"#000000\", 0.05)};\n border-color: ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? \"#7ee787\"\n : \"#2da44e\"\n : theme.isDark\n ? rgba(\"#ffffff\", 0.2)\n : rgba(\"#000000\", 0.2)};\n }\n\n &:active {\n transform: scale(0.95);\n }\n`;\n\nconst Body = styled.div<{ theme: Theme }>`\n background: ${({ theme }) => (theme.isDark ? \"#0d1117\" : \"#ffffff\")};\n border-bottom-left-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-bottom-right-radius: ${({ theme }) => theme.spacing.radius.lg};\n color: ${({ theme }) => (theme.isDark ? \"#ffffff\" : \"#24292f\")};\n padding: 20px;\n font-family: ${({ theme }) => theme.fonts.mono};\n text-align: left;\n overflow-x: auto;\n overflow-y: auto;\n max-height: calc(100svh - 400px);\n ${({ theme }) => styledCode(theme)};\n\n /* Dark mode syntax highlighting (GitHub Dark) */\n ${({ theme }) =>\n theme.isDark &&\n `\n & .hljs {\n color: #c9d1d9;\n background: #0d1117;\n }\n\n & .hljs-doctag,\n & .hljs-keyword,\n & .hljs-meta .hljs-keyword,\n & .hljs-template-tag,\n & .hljs-template-variable,\n & .hljs-type,\n & .hljs-variable.language_ {\n color: #ff7b72;\n }\n\n & .hljs-title,\n & .hljs-title.class_,\n & .hljs-title.class_.inherited__,\n & .hljs-title.function_ {\n color: #d2a8ff;\n }\n\n & .hljs-attr,\n & .hljs-attribute,\n & .hljs-literal,\n & .hljs-meta,\n & .hljs-number,\n & .hljs-operator,\n & .hljs-selector-attr,\n & .hljs-selector-class,\n & .hljs-selector-id,\n & .hljs-variable {\n color: #79c0ff;\n }\n\n & .hljs-meta .hljs-string,\n & .hljs-regexp,\n & .hljs-string {\n color: #a5d6ff;\n }\n\n & .hljs-built_in,\n & .hljs-symbol {\n color: #ffa657;\n }\n\n & .hljs-code,\n & .hljs-comment,\n & .hljs-formula {\n color: #8b949e;\n }\n\n & .hljs-name,\n & .hljs-quote,\n & .hljs-selector-pseudo,\n & .hljs-selector-tag {\n color: #7ee787;\n }\n\n & .hljs-subst {\n color: #c9d1d9;\n }\n\n & .hljs-section {\n color: #1f6feb;\n font-weight: 700;\n }\n\n & .hljs-bullet {\n color: #f2cc60;\n }\n\n & .hljs-emphasis {\n color: #c9d1d9;\n font-style: italic;\n }\n\n & .hljs-strong {\n color: #c9d1d9;\n font-weight: 700;\n }\n\n & .hljs-addition {\n color: #aff5b4;\n background-color: #033a16;\n }\n\n & .hljs-deletion {\n color: #ffdcd7;\n background-color: #67060c;\n }\n `}\n\n /* Light mode syntax highlighting (GitHub Light) */\n ${({ theme }) =>\n !theme.isDark &&\n `\n & .hljs {\n color: #24292f;\n background: #ffffff;\n }\n\n & .hljs-doctag,\n & .hljs-keyword,\n & .hljs-meta .hljs-keyword,\n & .hljs-template-tag,\n & .hljs-template-variable,\n & .hljs-type,\n & .hljs-variable.language_ {\n color: #cf222e;\n }\n\n & .hljs-title,\n & .hljs-title.class_,\n & .hljs-title.class_.inherited__,\n & .hljs-title.function_ {\n color: #8250df;\n }\n\n & .hljs-attr,\n & .hljs-attribute,\n & .hljs-literal,\n & .hljs-meta,\n & .hljs-number,\n & .hljs-operator,\n & .hljs-selector-attr,\n & .hljs-selector-class,\n & .hljs-selector-id,\n & .hljs-variable {\n color: #0550ae;\n }\n\n & .hljs-meta .hljs-string,\n & .hljs-regexp,\n & .hljs-string {\n color: #0a3069;\n }\n\n & .hljs-built_in,\n & .hljs-symbol {\n color: #953800;\n }\n\n & .hljs-code,\n & .hljs-comment,\n & .hljs-formula {\n color: #6e7781;\n }\n\n & .hljs-name,\n & .hljs-quote,\n & .hljs-selector-pseudo,\n & .hljs-selector-tag {\n color: #116329;\n }\n\n & .hljs-subst {\n color: #24292f;\n }\n\n & .hljs-section {\n color: #0550ae;\n font-weight: 700;\n }\n\n & .hljs-bullet {\n color: #953800;\n }\n\n & .hljs-emphasis {\n color: #24292f;\n font-style: italic;\n }\n\n & .hljs-strong {\n color: #24292f;\n font-weight: 700;\n }\n\n & .hljs-addition {\n color: #116329;\n background-color: #dafbe1;\n }\n\n & .hljs-deletion {\n color: #82071e;\n background-color: #ffebe9;\n }\n `}\n`;\n\nconst escapeHtml = (unsafe: string): string => {\n return unsafe\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#039;\");\n};\n\nconst sanitizeLanguage = (lang: string): string =>\n lang.replace(/[^a-zA-Z0-9_-]/g, \"\");\n\nconst highlightCode = (code: string, language: string): string => {\n const escapedCode = escapeHtml(code);\n const safeLang = sanitizeLanguage(language);\n const result = unified()\n .use(rehypeParse, { fragment: true })\n .use(rehypeHighlight, {\n detect: true,\n ignoreMissing: true,\n })\n .use(rehypeStringify)\n .processSync(\n `<pre><code class=\"language-${safeLang}\">${escapedCode}</code></pre>`,\n );\n\n return String(result);\n};\n\nfunction Code({ code, language = \"javascript\", theme, className }: CodeProps) {\n const [copied, setCopied] = useState(false);\n const highlightedCode = useMemo(\n () => highlightCode(code, language),\n [code, language],\n );\n\n const handleCopy = useCallback(async () => {\n try {\n await navigator.clipboard.writeText(code);\n setCopied(true);\n setTimeout(() => setCopied(false), 2000);\n } catch (err) {\n console.error(\"Failed to copy code:\", err);\n }\n }, [code]);\n\n return (\n <CodeWrapper\n className={`${className ?? \"\"} code-wrapper`.trim()}\n theme={theme}\n >\n <TopBar theme={theme}>\n <DotsContainer>\n <Dot theme={theme} />\n <Dot theme={theme} />\n <Dot theme={theme} />\n </DotsContainer>\n <CopyButton onClick={handleCopy} $copied={copied} theme={theme}>\n {copied ? (\n <>\n <Icon name=\"check\" size={12} />\n <span>Copied!</span>\n </>\n ) : (\n <>\n <Icon name=\"copy\" size={12} />\n <span>Copy</span>\n </>\n )}\n </CopyButton>\n </TopBar>\n <Body\n dangerouslySetInnerHTML={{ __html: highlightedCode }}\n theme={theme}\n className=\"code-wrapper-body\"\n />\n </CodeWrapper>\n );\n}\n\nexport { Code };\n";
1
+ export declare const codeTemplate = "\"use client\";\nimport { useState, useCallback, useMemo } from \"react\";\nimport styled from \"styled-components\";\nimport { Theme, styledCode } from \"cherry-styled-components\";\nimport { rgba } from \"polished\";\nimport { unified } from \"unified\";\nimport rehypeParse from \"rehype-parse\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport rehypeStringify from \"rehype-stringify\";\nimport { Icon } from \"@/components/layout/Icon\";\n\ninterface CodeProps extends Omit<\n React.HTMLAttributes<HTMLDivElement>,\n \"theme\"\n> {\n code: string;\n language?: string;\n theme?: Theme;\n}\n\nconst CodeWrapper = styled.span<{ theme: Theme }>`\n position: relative;\n z-index: 2;\n display: block;\n width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n border: solid 1px\n ${({ theme }) =>\n theme.isDark\n ? rgba(theme.colors.dark, 0.2)\n : rgba(theme.colors.dark, 0.1)};\n`;\n\nconst TopBar = styled.div<{ theme: Theme }>`\n background: ${({ theme }) => (theme.isDark ? \"#0d1117\" : \"#f6f8fa\")};\n border-top-left-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-top-right-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-bottom: solid 1px\n ${({ theme }) =>\n theme.isDark ? rgba(\"#ffffff\", 0.1) : rgba(\"#000000\", 0.1)};\n height: 33px;\n width: 100%;\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 5px;\n padding: 0 10px;\n`;\n\nconst DotsContainer = styled.div`\n display: flex;\n gap: 5px;\n`;\n\nconst Dot = styled.span<{ theme: Theme }>`\n width: 10px;\n height: 10px;\n border-radius: 50%;\n background: ${({ theme }) =>\n theme.isDark ? rgba(\"#ffffff\", 0.1) : rgba(\"#000000\", 0.1)};\n`;\n\nconst CopyButton = styled.button<{ theme: Theme; $copied: boolean }>`\n background: ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? rgba(\"#7ee787\", 0.2)\n : rgba(\"#2da44e\", 0.1)\n : \"transparent\"};\n border: solid 1px\n ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? \"#7ee787\"\n : \"#2da44e\"\n : theme.isDark\n ? rgba(\"#ffffff\", 0.1)\n : rgba(\"#000000\", 0.1)};\n color: ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? \"#7ee787\"\n : \"#2da44e\"\n : theme.isDark\n ? \"#c9d1d9\"\n : \"#57606a\"};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n padding: 4px 8px;\n font-size: 12px;\n font-family: ${({ theme }) => theme.fonts.mono};\n cursor: pointer;\n transition: all 0.2s ease;\n display: flex;\n align-items: center;\n gap: 4px;\n margin-right: -6px;\n\n & svg.lucide {\n color: ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? \"#7ee787\"\n : \"#2da44e\"\n : theme.isDark\n ? \"#c9d1d9\"\n : \"#57606a\"};\n }\n\n &:hover {\n background: ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? rgba(\"#7ee787\", 0.3)\n : rgba(\"#2da44e\", 0.2)\n : theme.isDark\n ? rgba(\"#ffffff\", 0.1)\n : rgba(\"#000000\", 0.05)};\n border-color: ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? \"#7ee787\"\n : \"#2da44e\"\n : theme.isDark\n ? rgba(\"#ffffff\", 0.2)\n : rgba(\"#000000\", 0.2)};\n }\n\n &:active {\n transform: scale(0.95);\n }\n`;\n\nconst Body = styled.div<{ theme: Theme }>`\n background: ${({ theme }) => (theme.isDark ? \"#0d1117\" : \"#ffffff\")};\n border-bottom-left-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-bottom-right-radius: ${({ theme }) => theme.spacing.radius.lg};\n color: ${({ theme }) => (theme.isDark ? \"#ffffff\" : \"#24292f\")};\n padding: 20px;\n font-family: ${({ theme }) => theme.fonts.mono};\n text-align: left;\n overflow-x: auto;\n overflow-y: auto;\n max-height: calc(100dvh - 400px);\n ${({ theme }) => styledCode(theme)};\n\n /* Dark mode syntax highlighting (GitHub Dark) */\n ${({ theme }) =>\n theme.isDark &&\n `\n & .hljs {\n color: #c9d1d9;\n background: #0d1117;\n }\n\n & .hljs-doctag,\n & .hljs-keyword,\n & .hljs-meta .hljs-keyword,\n & .hljs-template-tag,\n & .hljs-template-variable,\n & .hljs-type,\n & .hljs-variable.language_ {\n color: #ff7b72;\n }\n\n & .hljs-title,\n & .hljs-title.class_,\n & .hljs-title.class_.inherited__,\n & .hljs-title.function_ {\n color: #d2a8ff;\n }\n\n & .hljs-attr,\n & .hljs-attribute,\n & .hljs-literal,\n & .hljs-meta,\n & .hljs-number,\n & .hljs-operator,\n & .hljs-selector-attr,\n & .hljs-selector-class,\n & .hljs-selector-id,\n & .hljs-variable {\n color: #79c0ff;\n }\n\n & .hljs-meta .hljs-string,\n & .hljs-regexp,\n & .hljs-string {\n color: #a5d6ff;\n }\n\n & .hljs-built_in,\n & .hljs-symbol {\n color: #ffa657;\n }\n\n & .hljs-code,\n & .hljs-comment,\n & .hljs-formula {\n color: #8b949e;\n }\n\n & .hljs-name,\n & .hljs-quote,\n & .hljs-selector-pseudo,\n & .hljs-selector-tag {\n color: #7ee787;\n }\n\n & .hljs-subst {\n color: #c9d1d9;\n }\n\n & .hljs-section {\n color: #1f6feb;\n font-weight: 700;\n }\n\n & .hljs-bullet {\n color: #f2cc60;\n }\n\n & .hljs-emphasis {\n color: #c9d1d9;\n font-style: italic;\n }\n\n & .hljs-strong {\n color: #c9d1d9;\n font-weight: 700;\n }\n\n & .hljs-addition {\n color: #aff5b4;\n background-color: #033a16;\n }\n\n & .hljs-deletion {\n color: #ffdcd7;\n background-color: #67060c;\n }\n `}\n\n /* Light mode syntax highlighting (GitHub Light) */\n ${({ theme }) =>\n !theme.isDark &&\n `\n & .hljs {\n color: #24292f;\n background: #ffffff;\n }\n\n & .hljs-doctag,\n & .hljs-keyword,\n & .hljs-meta .hljs-keyword,\n & .hljs-template-tag,\n & .hljs-template-variable,\n & .hljs-type,\n & .hljs-variable.language_ {\n color: #cf222e;\n }\n\n & .hljs-title,\n & .hljs-title.class_,\n & .hljs-title.class_.inherited__,\n & .hljs-title.function_ {\n color: #8250df;\n }\n\n & .hljs-attr,\n & .hljs-attribute,\n & .hljs-literal,\n & .hljs-meta,\n & .hljs-number,\n & .hljs-operator,\n & .hljs-selector-attr,\n & .hljs-selector-class,\n & .hljs-selector-id,\n & .hljs-variable {\n color: #0550ae;\n }\n\n & .hljs-meta .hljs-string,\n & .hljs-regexp,\n & .hljs-string {\n color: #0a3069;\n }\n\n & .hljs-built_in,\n & .hljs-symbol {\n color: #953800;\n }\n\n & .hljs-code,\n & .hljs-comment,\n & .hljs-formula {\n color: #6e7781;\n }\n\n & .hljs-name,\n & .hljs-quote,\n & .hljs-selector-pseudo,\n & .hljs-selector-tag {\n color: #116329;\n }\n\n & .hljs-subst {\n color: #24292f;\n }\n\n & .hljs-section {\n color: #0550ae;\n font-weight: 700;\n }\n\n & .hljs-bullet {\n color: #953800;\n }\n\n & .hljs-emphasis {\n color: #24292f;\n font-style: italic;\n }\n\n & .hljs-strong {\n color: #24292f;\n font-weight: 700;\n }\n\n & .hljs-addition {\n color: #116329;\n background-color: #dafbe1;\n }\n\n & .hljs-deletion {\n color: #82071e;\n background-color: #ffebe9;\n }\n `}\n`;\n\nconst escapeHtml = (unsafe: string): string => {\n return unsafe\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#039;\");\n};\n\nconst sanitizeLanguage = (lang: string): string =>\n lang.replace(/[^a-zA-Z0-9_-]/g, \"\");\n\nconst highlightCode = (code: string, language: string): string => {\n const escapedCode = escapeHtml(code);\n const safeLang = sanitizeLanguage(language);\n const result = unified()\n .use(rehypeParse, { fragment: true })\n .use(rehypeHighlight, {\n detect: true,\n ignoreMissing: true,\n })\n .use(rehypeStringify)\n .processSync(\n `<pre><code class=\"language-${safeLang}\">${escapedCode}</code></pre>`,\n );\n\n return String(result);\n};\n\nfunction Code({ code, language = \"javascript\", theme, className }: CodeProps) {\n const [copied, setCopied] = useState(false);\n const highlightedCode = useMemo(\n () => highlightCode(code, language),\n [code, language],\n );\n\n const handleCopy = useCallback(async () => {\n try {\n await navigator.clipboard.writeText(code);\n setCopied(true);\n setTimeout(() => setCopied(false), 2000);\n } catch (err) {\n console.error(\"Failed to copy code:\", err);\n }\n }, [code]);\n\n return (\n <CodeWrapper\n className={`${className ?? \"\"} code-wrapper`.trim()}\n theme={theme}\n >\n <TopBar theme={theme}>\n <DotsContainer>\n <Dot theme={theme} />\n <Dot theme={theme} />\n <Dot theme={theme} />\n </DotsContainer>\n <CopyButton onClick={handleCopy} $copied={copied} theme={theme}>\n {copied ? (\n <>\n <Icon name=\"check\" size={12} />\n <span>Copied!</span>\n </>\n ) : (\n <>\n <Icon name=\"copy\" size={12} />\n <span>Copy</span>\n </>\n )}\n </CopyButton>\n </TopBar>\n <Body\n dangerouslySetInnerHTML={{ __html: highlightedCode }}\n theme={theme}\n className=\"code-wrapper-body\"\n />\n </CodeWrapper>\n );\n}\n\nexport { Code };\n";
@@ -140,7 +140,7 @@ const Body = styled.div<{ theme: Theme }>\`
140
140
  text-align: left;
141
141
  overflow-x: auto;
142
142
  overflow-y: auto;
143
- max-height: calc(100svh - 400px);
143
+ max-height: calc(100dvh - 400px);
144
144
  \${({ theme }) => styledCode(theme)};
145
145
 
146
146
  /* Dark mode syntax highlighting (GitHub Dark) */
@@ -1 +1 @@
1
- export declare const docsComponentsTemplate = "\"use client\";\nimport { darken, lighten, rgba } from \"polished\";\nimport React, { createContext, useContext } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport {\n resetButton,\n styledSmall,\n styledStrong,\n styledText,\n} from \"cherry-styled-components\";\nimport Link from \"next/link\";\nimport { mq, Theme } from \"@/app/theme\";\nimport {\n styledAnchor,\n styledTable,\n stylesLists,\n} from \"@/components/layout/SharedStyled\";\nimport { ChatContext } from \"@/components/Chat\";\n\nconst SectionBarContext = createContext(false);\n\nfunction SectionBarProvider({\n hasSectionBar,\n children,\n}: {\n hasSectionBar: boolean;\n children: React.ReactNode;\n}) {\n return (\n <SectionBarContext.Provider value={hasSectionBar}>\n {children}\n </SectionBarContext.Provider>\n );\n}\n\ninterface DocsProps {\n children: React.ReactNode;\n}\n\nconst StyledDocsWrapper = styled.div<{ theme: Theme }>`\n position: relative;\n`;\n\nconst StyledDocsSidebar = styled.div<{ theme: Theme }>`\n clear: both;\n`;\n\nconst StyledDocsContainer = styled.div<{ theme: Theme; $isChatOpen?: boolean }>`\n position: relative;\n padding: 0 20px 100px 20px;\n width: 100%;\n ${({ theme }) => styledText(theme)};\n transition: all 0.3s ease;\n\n ${mq(\"lg\")} {\n padding: 0 300px 80px 300px;\n\n ${({ $isChatOpen }) =>\n $isChatOpen &&\n css`\n padding: 0 440px 80px 300px;\n `}\n }\n\n & p {\n color: ${({ theme }) => theme.colors.grayDark};\n hyphens: auto;\n }\n\n & pre {\n max-width: 100%;\n }\n\n ${styledAnchor};\n ${stylesLists};\n ${styledTable};\n\n & img,\n & video,\n & iframe {\n max-width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n }\n\n & code:not([class]) {\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};\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 & .lucide {\n color: ${({ theme }) => theme.colors.primary};\n }\n\n & .aspect-video {\n aspect-ratio: 16 / 9;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n }\n`;\n\nexport const StyledMarkdownContainer = styled.div`\n display: flex;\n flex-direction: column;\n gap: 20px;\n flex-wrap: wrap;\n flex: 1;\n max-width: 640px;\n margin: auto;\n`;\n\ninterface Props {\n theme?: Theme;\n $isActive?: boolean;\n $hasSectionBar?: boolean;\n}\n\nexport const StyledSidebar = styled.nav<Props>`\n position: fixed;\n overflow-y: auto;\n max-height: calc(\n 100svh - ${({ $hasSectionBar }) => ($hasSectionBar ? 105 : 63)}px\n );\n width: 100%;\n z-index: 99;\n top: ${({ $hasSectionBar }) => ($hasSectionBar ? 105 : 63)}px;\n height: 100%;\n padding: 20px 20px 80px 20px;\n opacity: 0;\n pointer-events: none;\n transition: all 0.3s ease;\n transform: translateY(30px);\n left: 0;\n background: ${({ theme }) => theme.colors.light};\n border-right: solid 1px ${({ theme }) => theme.colors.grayLight};\n -webkit-overflow-scrolling: touch;\n\n &::-webkit-scrollbar {\n display: none;\n }\n\n ${mq(\"lg\")} {\n transition: none;\n max-height: 100svh;\n width: 220px;\n background: transparent;\n padding: 82px 20px 20px 20px;\n opacity: 1;\n pointer-events: all;\n transform: translateY(0);\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.05)};\n top: 0;\n width: 280px;\n }\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n transform: translateY(0);\n opacity: 1;\n pointer-events: all;\n `}\n`;\n\nexport const StyledIndexSidebar = styled.ul<{ theme: Theme }>`\n display: none;\n list-style: none;\n margin: 0;\n padding: 0;\n position: fixed;\n top: 0;\n right: 0;\n width: 280px;\n height: 100svh;\n overflow-y: auto;\n z-index: 1;\n padding: 82px 20px 20px 20px;\n background: ${({ theme }) => theme.colors.light};\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n -webkit-overflow-scrolling: touch;\n\n &::-webkit-scrollbar {\n display: none;\n }\n\n ${mq(\"lg\")} {\n display: block;\n }\n\n & li {\n padding: 5px 0;\n }\n`;\n\nexport const StyledIndexSidebarLabel = styled.span<{ theme: Theme }>`\n ${({ theme }) => styledSmall(theme)};\n color: ${({ theme }) => theme.colors.grayDark};\n`;\n\nexport const StyledIndexSidebarLi = styled.li<{\n theme: Theme;\n $isActive: boolean;\n}>`\n &::before {\n content: \"\";\n display: block;\n position: absolute;\n left: 0;\n height: 20px;\n width: 1px;\n background: transparent;\n transition: all 0.3s ease;\n }\n\n ${({ $isActive, theme }) =>\n $isActive &&\n css`\n &::before {\n background: ${theme.colors.primary};\n }\n `}\n`;\n\nexport const StyledIndexSidebarLink = styled.a<{\n theme: Theme;\n $isActive: boolean;\n}>`\n ${({ theme }) => styledSmall(theme)};\n color: ${({ theme, $isActive }) =>\n $isActive ? theme.colors.primary : theme.colors.dark};\n font-weight: ${({ $isActive }) => ($isActive ? \"600\" : \"400\")};\n text-decoration: none;\n transition: all 0.3s ease;\n\n &:hover {\n color: ${({ theme }) => theme.colors.primary};\n }\n`;\n\nexport const StyledSidebarList = styled.ul`\n list-style: none;\n margin: 0;\n padding: 0;\n`;\n\nexport const StyledStrong = styled.strong<{ theme: Theme }>`\n font-weight: 600;\n ${({ theme }) => styledStrong(theme)};\n color: ${({ theme }) =>\n theme.isDark\n ? lighten(0.1, theme.colors.primaryLight)\n : darken(0.1, theme.colors.primaryDark)};\n`;\n\nexport const StyledSidebarListItem = styled.li`\n display: flex;\n gap: 10px;\n clear: both;\n`;\n\nexport const StyledSidebarListItemLink = styled(Link)<Props>`\n text-decoration: none;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: 1.6;\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.grayDark : theme.colors.primary};\n padding: 5px 0 5px 20px;\n display: flex;\n transition: all 0.3s ease;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n &:hover {\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n border-color: ${({ theme }) => theme.colors.primary};\n }\n\n ${({ $isActive, theme }) =>\n $isActive &&\n `\n\t\t\tcolor: ${theme.isDark ? lighten(0.1, theme.colors.primaryLight) : darken(0.1, theme.colors.primaryDark)};\n\t\t\tborder-color: ${theme.colors.primary};\n\t\t\tfont-weight: 600;\n\t`};\n`;\n\nexport const StyleMobileBar = styled.button<Props>`\n ${resetButton};\n position: fixed;\n z-index: 999;\n bottom: 0;\n right: 20px;\n font-size: ${({ theme }) => theme.fontSizes.strong.lg};\n line-height: ${({ theme }) => theme.fontSizes.strong.lg};\n box-shadow: ${({ theme }) => theme.shadows.sm};\n background: ${({ theme }) =>\n theme.isDark\n ? rgba(theme.colors.grayLight, 0.7)\n : rgba(theme.colors.light, 0.7)};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.primary};\n backdrop-filter: blur(10px);\n -webkit-backdrop-filter: blur(10px);\n padding: 20px;\n border-radius: 100px;\n margin: 0 0 20px 0;\n font-weight: 600;\n display: flex;\n justify-content: flex-start;\n width: auto;\n\n ${mq(\"lg\")} {\n display: none;\n }\n\n ${({ $isActive }) => $isActive && `position: fixed;`};\n`;\n\nexport const StyledMobileBurger = styled.span<Props>`\n display: block;\n margin: auto 0;\n width: 18px;\n height: 18px;\n position: relative;\n overflow: hidden;\n background: transparent;\n position: relative;\n\n &::before,\n &::after {\n content: \"\";\n display: block;\n position: absolute;\n width: 18px;\n height: 3px;\n border-radius: 3px;\n background: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.primary};\n transition: all 0.3s ease;\n }\n\n &::before {\n top: 3px;\n }\n\n &::after {\n bottom: 3px;\n }\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n &::before {\n transform: translateY(5px) rotate(45deg);\n }\n\n &::after {\n transform: translateY(-4px) rotate(-45deg);\n }\n `};\n`;\n\ninterface DocsWrapperProps {\n children: React.ReactNode;\n}\n\nfunction DocsWrapper({ children }: DocsWrapperProps) {\n return <StyledDocsWrapper>{children}</StyledDocsWrapper>;\n}\n\nfunction DocsSidebar({ children }: DocsProps) {\n return <StyledDocsSidebar>{children}</StyledDocsSidebar>;\n}\n\nfunction DocsContainer({ children }: DocsProps) {\n const { isOpen } = useContext(ChatContext);\n\n return (\n <StyledDocsContainer $isChatOpen={isOpen}>{children}</StyledDocsContainer>\n );\n}\n\nexport {\n DocsWrapper,\n DocsSidebar,\n DocsContainer,\n SectionBarContext,\n SectionBarProvider,\n};\n";
1
+ export declare const docsComponentsTemplate = "\"use client\";\nimport { darken, lighten, rgba } from \"polished\";\nimport React, { createContext, useContext } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport {\n resetButton,\n styledSmall,\n styledStrong,\n styledText,\n} from \"cherry-styled-components\";\nimport Link from \"next/link\";\nimport { mq, Theme } from \"@/app/theme\";\nimport {\n styledAnchor,\n styledTable,\n stylesLists,\n} from \"@/components/layout/SharedStyled\";\nimport { ChatContext } from \"@/components/Chat\";\n\nconst SectionBarContext = createContext(false);\n\nfunction SectionBarProvider({\n hasSectionBar,\n children,\n}: {\n hasSectionBar: boolean;\n children: React.ReactNode;\n}) {\n return (\n <SectionBarContext.Provider value={hasSectionBar}>\n {children}\n </SectionBarContext.Provider>\n );\n}\n\ninterface DocsProps {\n children: React.ReactNode;\n}\n\nconst StyledDocsWrapper = styled.div<{ theme: Theme }>`\n position: relative;\n`;\n\nconst StyledDocsSidebar = styled.div<{ theme: Theme }>`\n clear: both;\n`;\n\nconst StyledDocsContainer = styled.div<{ theme: Theme; $isChatOpen?: boolean }>`\n position: relative;\n padding: 0 20px 100px 20px;\n width: 100%;\n ${({ theme }) => styledText(theme)};\n transition: all 0.3s ease;\n\n ${mq(\"lg\")} {\n padding: 0 300px 80px 300px;\n\n ${({ $isChatOpen }) =>\n $isChatOpen &&\n css`\n padding: 0 440px 80px 300px;\n `}\n }\n\n & p {\n color: ${({ theme }) => theme.colors.grayDark};\n hyphens: auto;\n }\n\n & pre {\n max-width: 100%;\n }\n\n ${styledAnchor};\n ${stylesLists};\n ${styledTable};\n\n & img,\n & video,\n & iframe {\n max-width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n }\n\n & code:not([class]) {\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};\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 & .lucide {\n color: ${({ theme }) => theme.colors.primary};\n }\n\n & .aspect-video {\n aspect-ratio: 16 / 9;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n }\n`;\n\nexport const StyledMarkdownContainer = styled.div`\n display: flex;\n flex-direction: column;\n gap: 20px;\n flex-wrap: wrap;\n flex: 1;\n max-width: 640px;\n margin: auto;\n`;\n\ninterface Props {\n theme?: Theme;\n $isActive?: boolean;\n $hasSectionBar?: boolean;\n}\n\nexport const StyledSidebar = styled.nav<Props>`\n position: fixed;\n overflow-y: auto;\n max-height: calc(\n 100dvh - ${({ $hasSectionBar }) => ($hasSectionBar ? 104 : 62)}px\n );\n width: 100%;\n z-index: 99;\n top: ${({ $hasSectionBar }) => ($hasSectionBar ? 104 : 62)}px;\n height: 100%;\n padding: 20px 20px 80px 20px;\n opacity: 0;\n pointer-events: none;\n transition: all 0.3s ease;\n transform: translateY(30px);\n left: 0;\n background: ${({ theme }) => theme.colors.light};\n -webkit-overflow-scrolling: touch;\n\n &::-webkit-scrollbar {\n display: none;\n }\n\n ${mq(\"lg\")} {\n border-right: solid 1px ${({ theme }) => theme.colors.grayLight};\n transition: none;\n max-height: 100dvh;\n width: 220px;\n background: transparent;\n padding: 82px 20px 20px 20px;\n opacity: 1;\n pointer-events: all;\n transform: translateY(0);\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.05)};\n top: 0;\n width: 280px;\n }\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n transform: translateY(0);\n opacity: 1;\n pointer-events: all;\n `}\n`;\n\nexport const StyledIndexSidebar = styled.ul<{ theme: Theme }>`\n display: none;\n list-style: none;\n margin: 0;\n padding: 0;\n position: fixed;\n top: 0;\n right: 0;\n width: 280px;\n height: 100dvh;\n overflow-y: auto;\n z-index: 1;\n padding: 82px 20px 20px 20px;\n background: ${({ theme }) => theme.colors.light};\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n -webkit-overflow-scrolling: touch;\n\n &::-webkit-scrollbar {\n display: none;\n }\n\n ${mq(\"lg\")} {\n display: block;\n }\n\n & li {\n padding: 5px 0;\n }\n`;\n\nexport const StyledIndexSidebarLabel = styled.span<{ theme: Theme }>`\n ${({ theme }) => styledSmall(theme)};\n color: ${({ theme }) => theme.colors.grayDark};\n`;\n\nexport const StyledIndexSidebarLi = styled.li<{\n theme: Theme;\n $isActive: boolean;\n}>`\n &::before {\n content: \"\";\n display: block;\n position: absolute;\n left: 0;\n height: 20px;\n width: 1px;\n background: transparent;\n transition: all 0.3s ease;\n }\n\n ${({ $isActive, theme }) =>\n $isActive &&\n css`\n &::before {\n background: ${theme.colors.primary};\n }\n `}\n`;\n\nexport const StyledIndexSidebarLink = styled.a<{\n theme: Theme;\n $isActive: boolean;\n}>`\n ${({ theme }) => styledSmall(theme)};\n color: ${({ theme, $isActive }) =>\n $isActive ? theme.colors.primary : theme.colors.dark};\n font-weight: ${({ $isActive }) => ($isActive ? \"600\" : \"400\")};\n text-decoration: none;\n transition: all 0.3s ease;\n\n &:hover {\n color: ${({ theme }) => theme.colors.primary};\n }\n`;\n\nexport const StyledSidebarList = styled.ul`\n list-style: none;\n margin: 0;\n padding: 0;\n`;\n\nexport const StyledStrong = styled.strong<{ theme: Theme }>`\n font-weight: 600;\n ${({ theme }) => styledStrong(theme)};\n color: ${({ theme }) =>\n theme.isDark\n ? lighten(0.1, theme.colors.primaryLight)\n : darken(0.1, theme.colors.primaryDark)};\n`;\n\nexport const StyledSidebarListItem = styled.li`\n display: flex;\n gap: 10px;\n clear: both;\n`;\n\nexport const StyledSidebarListItemLink = styled(Link)<Props>`\n text-decoration: none;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: 1.6;\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.grayDark : theme.colors.primary};\n padding: 5px 0 5px 20px;\n display: flex;\n transition: all 0.3s ease;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n &:hover {\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n border-color: ${({ theme }) => theme.colors.primary};\n }\n\n ${({ $isActive, theme }) =>\n $isActive &&\n `\n\t\t\tcolor: ${theme.isDark ? lighten(0.1, theme.colors.primaryLight) : darken(0.1, theme.colors.primaryDark)};\n\t\t\tborder-color: ${theme.colors.primary};\n\t\t\tfont-weight: 600;\n\t`};\n`;\n\nexport const StyleMobileBar = styled.button<Props>`\n ${resetButton};\n position: fixed;\n z-index: 999;\n bottom: 0;\n right: 20px;\n font-size: ${({ theme }) => theme.fontSizes.strong.lg};\n line-height: ${({ theme }) => theme.fontSizes.strong.lg};\n box-shadow: ${({ theme }) => theme.shadows.sm};\n background: ${({ theme }) =>\n theme.isDark\n ? rgba(theme.colors.grayLight, 0.7)\n : rgba(theme.colors.light, 0.7)};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.primary};\n backdrop-filter: blur(10px);\n -webkit-backdrop-filter: blur(10px);\n padding: 20px;\n border-radius: 100px;\n margin: 0 0 20px 0;\n font-weight: 600;\n display: flex;\n justify-content: flex-start;\n width: auto;\n\n ${mq(\"lg\")} {\n display: none;\n }\n\n ${({ $isActive }) => $isActive && `position: fixed;`};\n`;\n\nexport const StyledMobileBurger = styled.span<Props>`\n display: block;\n margin: auto 0;\n width: 18px;\n height: 18px;\n position: relative;\n overflow: hidden;\n background: transparent;\n position: relative;\n\n &::before,\n &::after {\n content: \"\";\n display: block;\n position: absolute;\n width: 18px;\n height: 3px;\n border-radius: 3px;\n background: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.primary};\n transition: all 0.3s ease;\n }\n\n &::before {\n top: 3px;\n }\n\n &::after {\n bottom: 3px;\n }\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n &::before {\n transform: translateY(5px) rotate(45deg);\n }\n\n &::after {\n transform: translateY(-4px) rotate(-45deg);\n }\n `};\n`;\n\ninterface DocsWrapperProps {\n children: React.ReactNode;\n}\n\nfunction DocsWrapper({ children }: DocsWrapperProps) {\n return <StyledDocsWrapper>{children}</StyledDocsWrapper>;\n}\n\nfunction DocsSidebar({ children }: DocsProps) {\n return <StyledDocsSidebar>{children}</StyledDocsSidebar>;\n}\n\nfunction DocsContainer({ children }: DocsProps) {\n const { isOpen } = useContext(ChatContext);\n\n return (\n <StyledDocsContainer $isChatOpen={isOpen}>{children}</StyledDocsContainer>\n );\n}\n\nexport {\n DocsWrapper,\n DocsSidebar,\n DocsContainer,\n SectionBarContext,\n SectionBarProvider,\n};\n";
@@ -121,11 +121,11 @@ export const StyledSidebar = styled.nav<Props>\`
121
121
  position: fixed;
122
122
  overflow-y: auto;
123
123
  max-height: calc(
124
- 100svh - \${({ $hasSectionBar }) => ($hasSectionBar ? 105 : 63)}px
124
+ 100dvh - \${({ $hasSectionBar }) => ($hasSectionBar ? 104 : 62)}px
125
125
  );
126
126
  width: 100%;
127
127
  z-index: 99;
128
- top: \${({ $hasSectionBar }) => ($hasSectionBar ? 105 : 63)}px;
128
+ top: \${({ $hasSectionBar }) => ($hasSectionBar ? 104 : 62)}px;
129
129
  height: 100%;
130
130
  padding: 20px 20px 80px 20px;
131
131
  opacity: 0;
@@ -134,7 +134,6 @@ export const StyledSidebar = styled.nav<Props>\`
134
134
  transform: translateY(30px);
135
135
  left: 0;
136
136
  background: \${({ theme }) => theme.colors.light};
137
- border-right: solid 1px \${({ theme }) => theme.colors.grayLight};
138
137
  -webkit-overflow-scrolling: touch;
139
138
 
140
139
  &::-webkit-scrollbar {
@@ -142,8 +141,9 @@ export const StyledSidebar = styled.nav<Props>\`
142
141
  }
143
142
 
144
143
  \${mq("lg")} {
144
+ border-right: solid 1px \${({ theme }) => theme.colors.grayLight};
145
145
  transition: none;
146
- max-height: 100svh;
146
+ max-height: 100dvh;
147
147
  width: 220px;
148
148
  background: transparent;
149
149
  padding: 82px 20px 20px 20px;
@@ -173,7 +173,7 @@ export const StyledIndexSidebar = styled.ul<{ theme: Theme }>\`
173
173
  top: 0;
174
174
  right: 0;
175
175
  width: 280px;
176
- height: 100svh;
176
+ height: 100dvh;
177
177
  overflow-y: auto;
178
178
  z-index: 1;
179
179
  padding: 82px 20px 20px 20px;
@@ -1 +1 @@
1
- export declare const platformAiAssistantMdxTemplate = "---\ntitle: \"AI Assistant\"\ndescription: \"Configure the built-in AI assistant that ships with every Doccupine documentation site.\"\ndate: \"2026-02-19\"\ncategory: \"Configuration\"\ncategoryOrder: 2\norder: 6\nsection: \"Platform\"\n---\n# AI Assistant\nEvery Doccupine site ships with a built-in AI assistant that helps visitors find answers across your documentation. The AI settings page lets you choose how it's powered.\n\n## Modes\n\n### Platform (default)\nUses Doccupine's built-in integration. Zero configuration needed - the AI assistant works out of the box with no API keys or setup.\n\n### Custom\nBring your own API key for full control over the AI model. Supported providers:\n\n- **OpenAI**\n- **Anthropic**\n- **Google**\n\nIn Custom mode, you can also configure:\n- **Embedding model** - the model used to index your documentation content\n- **Temperature** - controls response creativity (0.0 for focused answers, up to 1.0 for more varied responses)\n\nFor a complete list of available models, refer to the official documentation of your chosen provider.\n\n### Off\nCompletely disables the AI assistant on your site.\n\n<Callout type=\"warning\">\n AI settings are stored as environment variables on your deployment, not in a JSON file. After saving, a redeploy is triggered automatically to apply the changes.\n</Callout>\n\n## MCP server authentication\nEvery Doccupine site exposes an MCP (Model Context Protocol) endpoint at `/api/mcp`. This lets external AI tools query your documentation programmatically.\n\nYou can set an optional **API key** to restrict access to the MCP endpoint. When set, requests must include the key in their authorization header.\n\n<Callout type=\"note\">\n For more details on how the MCP endpoint works and how to connect it to AI tools, see the [Model Context Protocol documentation](/model-context-protocol).\n</Callout>";
1
+ export declare const platformAiAssistantMdxTemplate = "---\ntitle: \"AI Assistant\"\ndescription: \"Configure the built-in AI assistant that ships with every Doccupine documentation site.\"\ndate: \"2026-02-19\"\ncategory: \"Configuration\"\ncategoryOrder: 2\norder: 6\nsection: \"Platform\"\n---\n# AI Assistant\nEvery Doccupine site ships with a built-in AI assistant that helps visitors find answers across your documentation. The AI settings page lets you choose how it's powered.\n\n## Modes\n\n### Platform (default)\nUses Doccupine's built-in integration. Zero configuration needed - the AI assistant works out of the box with no API keys or setup.\n\nEach plan includes a monthly AI usage budget:\n\n| Plan | Monthly Budget |\n| ---------- | -------------- |\n| Trial | $2 |\n| Pro | $20 |\n| Enterprise | $50 |\n\nThe AI settings page shows a usage dashboard with your current spending and remaining budget. Usage resets automatically with your billing cycle.\n\n#### AI credit top-ups\n\nIf you run out of AI credits before your billing cycle resets, you can purchase a one-time top-up to increase your monthly limit. Available tiers:\n\n- **$5**\n- **$10**\n- **$20**\n\nTop-ups are added to your current cycle's budget immediately after purchase and reset when your billing cycle renews. You can purchase multiple top-ups in the same cycle.\n\n### Custom\nBring your own API key for full control over the AI model. Supported providers:\n\n- **OpenAI**\n- **Anthropic**\n- **Google**\n\nIn Custom mode, you can also configure:\n- **Embedding model** - the model used to index your documentation content\n- **Temperature** - controls response creativity (0.0 for focused answers, up to 1.0 for more varied responses)\n\nFor a complete list of available models, refer to the official documentation of your chosen provider.\n\n### Off\nCompletely disables the AI assistant on your site.\n\n<Callout type=\"warning\">\n AI settings are stored as environment variables on your deployment, not in a JSON file. After saving, a redeploy is triggered automatically to apply the changes.\n</Callout>\n\n## MCP server authentication\nEvery Doccupine site exposes an MCP (Model Context Protocol) endpoint at `/api/mcp`. This lets external AI tools query your documentation programmatically.\n\nYou can set an optional **API key** to restrict access to the MCP endpoint. When set, requests must include the key in their authorization header.\n\n<Callout type=\"note\">\n For more details on how the MCP endpoint works and how to connect it to AI tools, see the [Model Context Protocol documentation](/model-context-protocol).\n</Callout>";
@@ -15,6 +15,26 @@ Every Doccupine site ships with a built-in AI assistant that helps visitors find
15
15
  ### Platform (default)
16
16
  Uses Doccupine's built-in integration. Zero configuration needed - the AI assistant works out of the box with no API keys or setup.
17
17
 
18
+ Each plan includes a monthly AI usage budget:
19
+
20
+ | Plan | Monthly Budget |
21
+ | ---------- | -------------- |
22
+ | Trial | $2 |
23
+ | Pro | $20 |
24
+ | Enterprise | $50 |
25
+
26
+ The AI settings page shows a usage dashboard with your current spending and remaining budget. Usage resets automatically with your billing cycle.
27
+
28
+ #### AI credit top-ups
29
+
30
+ If you run out of AI credits before your billing cycle resets, you can purchase a one-time top-up to increase your monthly limit. Available tiers:
31
+
32
+ - **$5**
33
+ - **$10**
34
+ - **$20**
35
+
36
+ Top-ups are added to your current cycle's budget immediately after purchase and reset when your billing cycle renews. You can purchase multiple top-ups in the same cycle.
37
+
18
38
  ### Custom
19
39
  Bring your own API key for full control over the AI model. Supported providers:
20
40
 
@@ -16,7 +16,7 @@ export const packageJsonTemplate = JSON.stringify({
16
16
  "@langchain/openai": "^1.2.9",
17
17
  "@mdx-js/react": "^3.1.1",
18
18
  "@modelcontextprotocol/sdk": "^1.26.0",
19
- "cherry-styled-components": "^0.1.12",
19
+ "cherry-styled-components": "^0.1.13",
20
20
  langchain: "^1.2.25",
21
21
  "lucide-react": "^0.575.0",
22
22
  next: "16.1.6",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doccupine",
3
- "version": "0.0.64",
3
+ "version": "0.0.65",
4
4
  "description": "Free and open-source documentation platform. Write MDX, get a production-ready site with AI chat, built-in components, and an MCP server - in one command.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {