doccupine 0.0.52 → 0.0.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -62,6 +62,7 @@ import { llmTypesTemplate } from "./templates/services/llm/types.js";
62
62
  import { styledDTemplate } from "./templates/types/styled.js";
63
63
  import { orderNavItemsTemplate } from "./templates/utils/orderNavItems.js";
64
64
  import { rateLimitTemplate } from "./templates/utils/rateLimit.js";
65
+ import { configTemplate } from "./templates/utils/config.js";
65
66
  import { accordionMdxTemplate } from "./templates/mdx/accordion.mdx.js";
66
67
  import { aiAssistantMdxTemplate } from "./templates/mdx/ai-assistant.mdx.js";
67
68
  import { buttonsMdxTemplate } from "./templates/mdx/buttons.mdx.js";
@@ -255,6 +256,7 @@ class MDXToNextJSGenerator {
255
256
  "types/styled.d.ts": styledDTemplate,
256
257
  "utils/orderNavItems.ts": orderNavItemsTemplate,
257
258
  "utils/rateLimit.ts": rateLimitTemplate,
259
+ "utils/config.ts": configTemplate,
258
260
  "components/Chat.tsx": chatTemplate,
259
261
  "components/ClickOutside.ts": clickOutsideTemplate,
260
262
  "components/Docs.tsx": docsTemplate,
@@ -666,16 +668,7 @@ class MDXToNextJSGenerator {
666
668
  async generatePageFromMDX(mdxFile) {
667
669
  const pageContent = `import { Metadata } from "next";
668
670
  import { Docs } from "@/components/Docs";
669
- import configData from "@/config.json";
670
-
671
- interface Config {
672
- name?: string;
673
- description?: string;
674
- icon?: string;
675
- preview?: string;
676
- }
677
-
678
- const config = configData as Config;
671
+ import { config } from "@/utils/config";
679
672
 
680
673
  const content = \`${escapeTemplateContent(mdxFile.content)}\`;
681
674
 
@@ -718,16 +711,7 @@ export default function Page() {
718
711
  }
719
712
  const indexContent = `import { Metadata } from "next";
720
713
  import { Docs } from "@/components/Docs";
721
- import configData from "@/config.json";
722
-
723
- interface Config {
724
- name?: string;
725
- description?: string;
726
- icon?: string;
727
- preview?: string;
728
- }
729
-
730
- const config = configData as Config;
714
+ import { config } from "@/utils/config";
731
715
 
732
716
  ${indexMDX ? `const content = \`${escapeTemplateContent(indexMDX.content)}\`;` : `const content = null;`}
733
717
 
@@ -1 +1 @@
1
- export declare const mcpRoutesTemplate = "import { NextResponse } from \"next/server\";\nimport { z } from \"zod\";\nimport { WebStandardStreamableHTTPServerTransport } from \"@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js\";\nimport { createMCPServer } from \"@/services/mcp/server\";\nimport {\n searchDocs,\n ensureDocsIndex,\n getIndexStatus,\n DOCS_TOOLS,\n listDocs,\n getDoc,\n} from \"@/services/mcp\";\nimport type { MCPToolName } from \"@/services/mcp\";\n\nconst searchDocsSchema = z.object({\n query: z.string().min(1).max(2000),\n limit: z.number().int().min(1).max(50).optional(),\n});\n\nconst getDocSchema = z.object({\n path: z.string().min(1).max(500),\n});\n\nconst listDocsSchema = z.object({\n directory: z.string().max(500).optional(),\n});\n\n// Create a stateless transport for serverless environment\nfunction createTransport() {\n return new WebStandardStreamableHTTPServerTransport({\n sessionIdGenerator: undefined, // Stateless mode for serverless\n enableJsonResponse: true,\n });\n}\n\n// Handle MCP protocol requests\nasync function handleMCPRequest(req: Request) {\n const transport = createTransport();\n const server = createMCPServer();\n\n try {\n await server.connect(transport);\n const response = await transport.handleRequest(req);\n\n // Clean up after response is done\n response\n .clone()\n .body?.pipeTo(\n new WritableStream({\n close() {\n transport.close();\n server.close();\n },\n }),\n )\n .catch(() => {});\n\n return response;\n } catch (error) {\n console.error(\"MCP request error:\", error);\n return new Response(\n JSON.stringify({\n jsonrpc: \"2.0\",\n error: {\n code: -32603,\n message: \"Internal server error\",\n },\n id: null,\n }),\n {\n status: 500,\n headers: { \"Content-Type\": \"application/json\" },\n },\n );\n }\n}\n\n// Handle REST API requests (original JSON format)\ninterface ToolCallRequest {\n tool: MCPToolName;\n params: Record<string, unknown>;\n}\n\nasync function handleRESTRequest(body: ToolCallRequest) {\n try {\n const { tool, params } = body;\n\n if (!tool) {\n return NextResponse.json(\n { error: \"Missing 'tool' parameter\" },\n { status: 400 },\n );\n }\n\n switch (tool) {\n case \"search_docs\": {\n const parsed = searchDocsSchema.safeParse(params);\n if (!parsed.success) {\n return NextResponse.json(\n { error: \"Invalid params\", details: parsed.error.issues },\n { status: 400 },\n );\n }\n await ensureDocsIndex();\n const results = await searchDocs(\n parsed.data.query,\n parsed.data.limit ?? 6,\n );\n return NextResponse.json({\n content: results.map(({ chunk, score }) => ({\n path: chunk.path,\n uri: chunk.uri,\n score: score.toFixed(3),\n text: chunk.text,\n })),\n });\n }\n\n case \"get_doc\": {\n const parsed = getDocSchema.safeParse(params);\n if (!parsed.success) {\n return NextResponse.json(\n { error: \"Invalid params\", details: parsed.error.issues },\n { status: 400 },\n );\n }\n const doc = await getDoc({ path: parsed.data.path });\n if (!doc) {\n return NextResponse.json(\n { error: \"Document not found\" },\n { status: 404 },\n );\n }\n return NextResponse.json({ content: doc });\n }\n\n case \"list_docs\": {\n const parsed = listDocsSchema.safeParse(params);\n if (!parsed.success) {\n return NextResponse.json(\n { error: \"Invalid params\", details: parsed.error.issues },\n { status: 400 },\n );\n }\n const docs = await listDocs({\n directory: parsed.data.directory,\n });\n return NextResponse.json({\n content: docs.map((d) => ({\n name: d.name,\n path: d.path,\n uri: d.uri,\n })),\n });\n }\n\n default:\n return NextResponse.json(\n { error: `Unknown tool: ${tool}` },\n { status: 400 },\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 POST(req: Request) {\n // Clone the request to read body twice if needed\n const clonedReq = req.clone();\n\n try {\n const body = await clonedReq.json();\n\n // Check if this is an MCP protocol request (has jsonrpc field)\n // or a REST API request (has tool field)\n if (\"jsonrpc\" in body) {\n // MCP protocol request - use the original request\n return handleMCPRequest(req);\n } else if (\"tool\" in body) {\n // REST API request\n return handleRESTRequest(body as ToolCallRequest);\n } else {\n return NextResponse.json(\n {\n error:\n \"Invalid request format. Expected 'jsonrpc' (MCP) or 'tool' (REST) field.\",\n },\n { status: 400 },\n );\n }\n } catch {\n return NextResponse.json({ error: \"Invalid JSON body\" }, { status: 400 });\n }\n}\n\nexport async function GET() {\n // GET always returns REST API format (tools list and index status)\n const status = getIndexStatus();\n return NextResponse.json({\n tools: DOCS_TOOLS,\n index: {\n ready: status.ready,\n chunkCount: status.chunkCount,\n },\n });\n}\n\nexport async function DELETE(req: Request) {\n // DELETE is only used by MCP protocol\n return handleMCPRequest(req);\n}\n";
1
+ export declare const mcpRoutesTemplate = "import { NextResponse } from \"next/server\";\nimport { z } from \"zod\";\nimport { WebStandardStreamableHTTPServerTransport } from \"@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js\";\nimport { createMCPServer } from \"@/services/mcp/server\";\nimport {\n searchDocs,\n ensureDocsIndex,\n getIndexStatus,\n DOCS_TOOLS,\n listDocs,\n getDoc,\n} from \"@/services/mcp\";\nimport type { MCPToolName } from \"@/services/mcp\";\nimport { rateLimit } from \"@/utils/rateLimit\";\n\nconst searchDocsSchema = z.object({\n query: z.string().min(1).max(2000),\n limit: z.number().int().min(1).max(50).optional(),\n});\n\nconst getDocSchema = z.object({\n path: z.string().min(1).max(500),\n});\n\nconst listDocsSchema = z.object({\n directory: z.string().max(500).optional(),\n});\n\n// Create a stateless transport for serverless environment\nfunction createTransport() {\n return new WebStandardStreamableHTTPServerTransport({\n sessionIdGenerator: undefined, // Stateless mode for serverless\n enableJsonResponse: true,\n });\n}\n\n// Handle MCP protocol requests\nasync function handleMCPRequest(req: Request) {\n const transport = createTransport();\n const server = createMCPServer();\n\n try {\n await server.connect(transport);\n const response = await transport.handleRequest(req);\n\n // Clean up after response is done\n response\n .clone()\n .body?.pipeTo(\n new WritableStream({\n close() {\n transport.close();\n server.close();\n },\n }),\n )\n .catch(() => {});\n\n return response;\n } catch (error) {\n console.error(\"MCP request error:\", error);\n return new Response(\n JSON.stringify({\n jsonrpc: \"2.0\",\n error: {\n code: -32603,\n message: \"Internal server error\",\n },\n id: null,\n }),\n {\n status: 500,\n headers: { \"Content-Type\": \"application/json\" },\n },\n );\n }\n}\n\n// Handle REST API requests (original JSON format)\ninterface ToolCallRequest {\n tool: MCPToolName;\n params: Record<string, unknown>;\n}\n\nasync function handleRESTRequest(body: ToolCallRequest) {\n try {\n const { tool, params } = body;\n\n if (!tool) {\n return NextResponse.json(\n { error: \"Missing 'tool' parameter\" },\n { status: 400 },\n );\n }\n\n switch (tool) {\n case \"search_docs\": {\n const parsed = searchDocsSchema.safeParse(params);\n if (!parsed.success) {\n return NextResponse.json(\n { error: \"Invalid params\", details: parsed.error.issues },\n { status: 400 },\n );\n }\n await ensureDocsIndex();\n const results = await searchDocs(\n parsed.data.query,\n parsed.data.limit ?? 6,\n );\n return NextResponse.json({\n content: results.map(({ chunk, score }) => ({\n path: chunk.path,\n uri: chunk.uri,\n score: score.toFixed(3),\n text: chunk.text,\n })),\n });\n }\n\n case \"get_doc\": {\n const parsed = getDocSchema.safeParse(params);\n if (!parsed.success) {\n return NextResponse.json(\n { error: \"Invalid params\", details: parsed.error.issues },\n { status: 400 },\n );\n }\n const doc = await getDoc({ path: parsed.data.path });\n if (!doc) {\n return NextResponse.json(\n { error: \"Document not found\" },\n { status: 404 },\n );\n }\n return NextResponse.json({ content: doc });\n }\n\n case \"list_docs\": {\n const parsed = listDocsSchema.safeParse(params);\n if (!parsed.success) {\n return NextResponse.json(\n { error: \"Invalid params\", details: parsed.error.issues },\n { status: 400 },\n );\n }\n const docs = await listDocs({\n directory: parsed.data.directory,\n });\n return NextResponse.json({\n content: docs.map((d) => ({\n name: d.name,\n path: d.path,\n uri: d.uri,\n })),\n });\n }\n\n default:\n return NextResponse.json(\n { error: `Unknown tool: ${tool}` },\n { status: 400 },\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 POST(req: Request) {\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 // Clone the request to read body twice if needed\n const clonedReq = req.clone();\n\n try {\n const body = await clonedReq.json();\n\n // Check if this is an MCP protocol request (has jsonrpc field)\n // or a REST API request (has tool field)\n if (\"jsonrpc\" in body) {\n // MCP protocol request - use the original request\n return handleMCPRequest(req);\n } else if (\"tool\" in body) {\n // REST API request\n return handleRESTRequest(body as ToolCallRequest);\n } else {\n return NextResponse.json(\n {\n error:\n \"Invalid request format. Expected 'jsonrpc' (MCP) or 'tool' (REST) field.\",\n },\n { status: 400 },\n );\n }\n } catch {\n return NextResponse.json({ error: \"Invalid JSON body\" }, { status: 400 });\n }\n}\n\nexport async function GET() {\n // GET always returns REST API format (tools list and index status)\n const status = getIndexStatus();\n return NextResponse.json({\n tools: DOCS_TOOLS,\n index: {\n ready: status.ready,\n chunkCount: status.chunkCount,\n },\n });\n}\n\nexport async function DELETE(req: Request) {\n // DELETE is only used by MCP protocol\n return handleMCPRequest(req);\n}\n";
@@ -11,6 +11,7 @@ import {
11
11
  getDoc,
12
12
  } from "@/services/mcp";
13
13
  import type { MCPToolName } from "@/services/mcp";
14
+ import { rateLimit } from "@/utils/rateLimit";
14
15
 
15
16
  const searchDocsSchema = z.object({
16
17
  query: z.string().min(1).max(2000),
@@ -167,6 +168,16 @@ async function handleRESTRequest(body: ToolCallRequest) {
167
168
  }
168
169
 
169
170
  export async function POST(req: Request) {
171
+ const ip =
172
+ req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
173
+ const { allowed, retryAfter } = rateLimit(ip);
174
+ if (!allowed) {
175
+ return NextResponse.json(
176
+ { error: "Too many requests" },
177
+ { status: 429, headers: { "Retry-After": String(retryAfter) } },
178
+ );
179
+ }
180
+
170
181
  // Clone the request to read body twice if needed
171
182
  const clonedReq = req.clone();
172
183
 
@@ -1 +1 @@
1
- export declare const ragRoutesTemplate = "import { NextResponse } from \"next/server\";\nimport path from \"node:path\";\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 configData from \"@/config.json\";\n\nconst config = configData as Config;\n\ninterface Config {\n name?: string;\n description?: string;\n icon?: string;\n preview?: string;\n}\n\nconst PROJECT_ROOT = process.cwd();\n\nconst ragSchema = z.object({\n question: z.string().min(1).max(2000),\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, refresh } = parsed.data;\n\n let config;\n try {\n config = 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(String(question || \"\"), 6);\n\n // Build context from search results\n const context = searchResults\n .map(\n ({ chunk, score }) =>\n `File: ${path.relative(PROJECT_ROOT, 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(config);\n const prompt = [\n {\n role: \"system\" as const,\n content: systemContext as string,\n },\n {\n role: \"user\" as const,\n content: `Question: ${question}\\n\\nContext:\\n${context}`,\n },\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: path.relative(PROJECT_ROOT, 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 ragSchema = z.object({\n question: z.string().min(1).max(2000),\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, 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 = [\n {\n role: \"system\" as const,\n content: systemContext,\n },\n {\n role: \"user\" as const,\n content: `Question: ${question}\\n\\nContext:\\n${context}`,\n },\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,5 +1,4 @@
1
1
  export const ragRoutesTemplate = `import { NextResponse } from "next/server";
2
- import path from "node:path";
3
2
  import { z } from "zod";
4
3
  import { getLLMConfig, createChatModel } from "@/services/llm";
5
4
  import {
@@ -8,18 +7,7 @@ import {
8
7
  getIndexStatus,
9
8
  } from "@/services/mcp/server";
10
9
  import { rateLimit } from "@/utils/rateLimit";
11
- import configData from "@/config.json";
12
-
13
- const config = configData as Config;
14
-
15
- interface Config {
16
- name?: string;
17
- description?: string;
18
- icon?: string;
19
- preview?: string;
20
- }
21
-
22
- const PROJECT_ROOT = process.cwd();
10
+ import { config } from "@/utils/config";
23
11
 
24
12
  const ragSchema = z.object({
25
13
  question: z.string().min(1).max(2000),
@@ -72,9 +60,9 @@ export async function POST(req: Request) {
72
60
  }
73
61
  const { question, refresh } = parsed.data;
74
62
 
75
- let config;
63
+ let llmConfig;
76
64
  try {
77
- config = getLLMConfig();
65
+ llmConfig = getLLMConfig();
78
66
  } catch (error: unknown) {
79
67
  const message =
80
68
  error instanceof Error ? error.message : "LLM configuration error";
@@ -85,22 +73,22 @@ export async function POST(req: Request) {
85
73
  await ensureDocsIndex(Boolean(refresh));
86
74
 
87
75
  // Use MCP search_docs tool to find relevant documentation
88
- const searchResults = await searchDocs(String(question || ""), 6);
76
+ const searchResults = await searchDocs(question, 6);
89
77
 
90
78
  // Build context from search results
91
79
  const context = searchResults
92
80
  .map(
93
81
  ({ chunk, score }) =>
94
- \`File: \${path.relative(PROJECT_ROOT, chunk.path)}\\nScore: \${score.toFixed(3)}\\n----\\n\${chunk.text}\`,
82
+ \`File: \${chunk.path}\\nScore: \${score.toFixed(3)}\\n----\\n\${chunk.text}\`,
95
83
  )
96
84
  .join("\\n\\n================\\n\\n");
97
85
 
98
86
  // Create chat model and stream response
99
- const llm = createChatModel(config);
87
+ const llm = createChatModel(llmConfig);
100
88
  const prompt = [
101
89
  {
102
90
  role: "system" as const,
103
- content: systemContext as string,
91
+ content: systemContext,
104
92
  },
105
93
  {
106
94
  role: "user" as const,
@@ -115,7 +103,7 @@ export async function POST(req: Request) {
115
103
  const metadata = {
116
104
  sources: searchResults.map(({ chunk, score }) => ({
117
105
  id: chunk.id,
118
- path: path.relative(PROJECT_ROOT, chunk.path),
106
+ path: chunk.path,
119
107
  uri: chunk.uri,
120
108
  score,
121
109
  })),
@@ -1,9 +1,10 @@
1
1
  export const layoutTemplate = (pages, fontConfig) => `import type { Metadata } from "next";
2
2
  ${fontConfig?.googleFont?.fontName?.length ? `import { ${fontConfig.googleFont.fontName} } from "next/font/google";` : fontConfig?.localFonts?.length || fontConfig?.localFonts?.src?.length ? 'import localFont from "next/font/local";' : 'import { Inter } from "next/font/google";'}
3
+ import dynamic from "next/dynamic";
3
4
  import { StyledComponentsRegistry } from "cherry-styled-components";
4
5
  import { theme, themeDark } from "@/app/theme";
5
6
  import { CherryThemeProvider } from "@/components/layout/CherryThemeProvider";
6
- import { Chat, ChtProvider } from "@/components/Chat";
7
+ import { ChtProvider } from "@/components/Chat";
7
8
  import { Footer } from "@/components/layout/Footer";
8
9
  import { Header } from "@/components/layout/Header";
9
10
  import { DocsWrapper } from "@/components/layout/DocsComponents";
@@ -14,7 +15,9 @@ import {
14
15
  type PagesProps,
15
16
  } from "@/utils/orderNavItems";
16
17
  import { StaticLinks } from "@/components/layout/StaticLinks";
18
+ import { config } from "@/utils/config";
17
19
  import navigation from "@/navigation.json";
20
+ const Chat = dynamic(() => import("@/components/Chat").then((mod) => mod.Chat));
18
21
 
19
22
  ${fontConfig?.googleFont?.fontName?.length
20
23
  ? `const font = ${fontConfig.googleFont.fontName}({ subsets: ${fontConfig?.googleFont?.subsets?.length ? JSON.stringify(fontConfig?.googleFont?.subsets, null, 2) : '["latin"]'}, ${fontConfig.googleFont?.weight.length ? `weight: "${fontConfig.googleFont.weight}"` : ""} });`
@@ -25,13 +28,13 @@ ${fontConfig?.googleFont?.fontName?.length
25
28
  : 'const font = Inter({ subsets: ["latin"] });'}
26
29
 
27
30
  export const metadata: Metadata = {
28
- title: "Doccupine",
29
- description:
30
- "Doccupine is a free and open-source document management system that allows you to store, organize, and share your documentation with ease. AI-ready.",
31
+ title: config.name || "Doccupine",
32
+ description: config.description || "Doccupine is a free and open-source document management system that allows you to store, organize, and share your documentation with ease. AI-ready.",
33
+ icons: config.icon || "https://doccupine.com/favicon.ico",
31
34
  openGraph: {
32
- title: "Doccupine",
33
- description:
34
- "Doccupine is a free and open-source document management system that allows you to store, organize, and share your documentation with ease. AI-ready.",
35
+ title: config.name || "Doccupine",
36
+ description: config.description || "Doccupine is a free and open-source document management system that allows you to store, organize, and share your documentation with ease. AI-ready.",
37
+ images: config.preview || "https://doccupine.com/preview.png",
35
38
  },
36
39
  };
37
40
 
@@ -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 } from \"@/components/MDXComponents\";\nimport { styledTable, stylesLists } from \"@/components/layout/SharedStyled\";\nimport links from \"@/links.json\";\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 width: 100%;\n padding: 0 20px;\n transition: all 0.3s ease;\n transform: translateX(0);\n background: ${({ theme }) => theme.colors.light};\n\n ${({ $isVisible }) =>\n !$isVisible &&\n css`\n transform: translateX(100%);\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\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n\n ${({ $isVisible }) =>\n $isVisible &&\n css`\n transform: translateX(0);\n `}\n\n & .loading {\n animation: ${loadingAnimation} 1s linear infinite;\n }\n`;\n\nconst StyledChatFixedForm = styled.form<{\n theme: Theme;\n $hide: boolean;\n $hasLinks: boolean;\n}>`\n transition: all 0.3s ease;\n position: fixed;\n bottom: 20px;\n left: 20px;\n width: calc(100% - 115px);\n z-index: 998;\n\n ${mq(\"lg\")} {\n left: 50%;\n transform: translateX(-50%) translateY(0);\n bottom: initial;\n position: absolute;\n top: 90px;\n width: calc(100% - 320px * 2 - 40px);\n opacity: 1;\n\n ${({ $hasLinks }) =>\n $hasLinks &&\n css`\n top: 164px;\n `}\n }\n\n ${({ $hide }) =>\n $hide &&\n css`\n transform: translateX(-100px);\n\n ${mq(\"lg\")} {\n opacity: 0;\n transform: translateX(-50%) translateY(-20px);\n }\n `}\n\n & .loading {\n animation: ${loadingAnimation} 1s linear infinite;\n }\n`;\n\nconst StyledChatFixedInner = styled.div`\n margin: auto;\n display: flex;\n gap: 10px;\n justify-content: center;\n align-items: center;\n\n ${mq(\"lg\")} {\n max-width: 640px;\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};A\n white-space: pre;\n }\n\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: 25px 20px;\n height: 73px;\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}\n\nfunction RainbowInput({\n id,\n value,\n onChange,\n placeholder,\n autoComplete,\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 id={id}\n value={value}\n onChange={onChange}\n placeholder={placeholder}\n autoComplete={autoComplete}\n onFocus={() => setIsFocused(true)}\n onBlur={() => setIsFocused(false)}\n />\n </StyledRainbowInputWrapper>\n );\n}\n\nfunction Chat() {\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 endRef = useRef<HTMLDivElement | null>(null);\n const mdxComponents = useMDXComponents({});\n const { isOpen, setIsOpen } = useContext(ChatContext);\n\n useEffect(() => {\n endRef.current?.scrollIntoView({ behavior: \"smooth\", block: \"end\" });\n }, [answer]);\n\n useEffect(() => {\n if (answer?.length > 0) {\n const el = document.getElementById(\n \"chat-bottom-input\",\n ) as HTMLInputElement | null;\n el?.focus();\n }\n }, [answer]);\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 try {\n const res = await fetch(\"/api/rag\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ question: currentQuestion }),\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 let streamedContent = \"\";\n if (!reader) {\n throw new Error(\"Failed to get response reader\");\n }\n\n // Add a placeholder for the streaming answer\n const streamingAnswerIndex = mergedQuestions.length;\n setAnswer([...mergedQuestions, { text: \"\", answer: true }]);\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n\n const chunk = decoder.decode(value);\n const lines = chunk.split(\"\\n\");\n\n for (const line of lines) {\n if (line.startsWith(\"data: \")) {\n try {\n const data = JSON.parse(line.slice(6));\n\n if (data.type === \"content\") {\n streamedContent += data.data;\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 // Finalize with MDX serialization\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 console.error(\"Failed to parse SSE data:\", parseError);\n }\n }\n }\n }\n } catch (err: unknown) {\n setError(err instanceof Error ? err.message : \"Unknown error\");\n } finally {\n setLoading(false);\n }\n }\n\n return (\n <>\n <StyledChatFixedForm\n onSubmit={ask}\n $hide={answer?.length > 0}\n $hasLinks={links.length > 0}\n >\n <StyledChatFixedInner>\n <RainbowInput\n value={question}\n onChange={(e) => setQuestion(e.target.value)}\n placeholder=\"Ask AI Assistant...\"\n autoComplete=\"off\"\n />\n <StyledRainbowButton\n type=\"submit\"\n disabled={loading}\n $hasContent={question.trim().length > 0}\n >\n {loading ? <LoaderPinwheel className=\"loading\" /> : <ArrowUp />}\n </StyledRainbowButton>\n </StyledChatFixedInner>\n </StyledChatFixedForm>\n\n <StyledChat $isVisible={isOpen}>\n <StyledChatTitle>\n <StyledChatTitleIconWrapper>\n <Sparkles />\n <h3>AI Assistant</h3>\n </StyledChatTitleIconWrapper>\n <StyledChatCloseButton\n onClick={() => {\n setAnswer([]);\n setIsOpen(false);\n }}\n >\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 value={question}\n onChange={(e) => setQuestion(e.target.value)}\n placeholder=\"Ask AI Assistant...\"\n autoComplete=\"off\"\n />\n <StyledRainbowButton\n type=\"submit\"\n disabled={loading || question.trim() === \"\"}\n $hasContent={question.trim().length > 0}\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}>({\n isOpen: false,\n setIsOpen: () => {},\n isChatActive: false,\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\n return (\n <ChatContext.Provider\n value={{\n isOpen,\n setIsOpen,\n isChatActive,\n }}\n >\n {children}\n </ChatContext.Provider>\n );\n};\n\nexport { Chat, ChtProvider, ChatContext };\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, 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 { styledTable, stylesLists } from \"@/components/layout/SharedStyled\";\nimport links from \"@/links.json\";\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\n ${({ $isVisible }) =>\n !$isVisible &&\n css`\n transform: translateX(100%);\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\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n\n ${({ $isVisible }) =>\n $isVisible &&\n css`\n transform: translateX(0);\n `}\n\n & .loading {\n animation: ${loadingAnimation} 1s linear infinite;\n }\n`;\n\nconst StyledChatFixedForm = styled.form<{\n theme: Theme;\n $hide: boolean;\n $hasLinks: boolean;\n}>`\n transition: all 0.3s ease;\n position: fixed;\n bottom: 20px;\n left: 20px;\n width: calc(100% - 115px);\n z-index: 998;\n\n ${mq(\"lg\")} {\n left: 50%;\n transform: translateX(-50%) translateY(0);\n bottom: initial;\n position: absolute;\n top: 90px;\n width: calc(100% - 320px * 2 - 40px);\n opacity: 1;\n\n ${({ $hasLinks }) =>\n $hasLinks &&\n css`\n top: 164px;\n `}\n }\n\n ${({ $hide }) =>\n $hide &&\n css`\n transform: translateX(-100px);\n\n ${mq(\"lg\")} {\n opacity: 0;\n transform: translateX(-50%) translateY(-20px);\n }\n `}\n\n & .loading {\n animation: ${loadingAnimation} 1s linear infinite;\n }\n`;\n\nconst StyledChatFixedInner = styled.div`\n margin: auto;\n display: flex;\n gap: 10px;\n justify-content: center;\n align-items: center;\n\n ${mq(\"lg\")} {\n max-width: 640px;\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 ${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: 25px 20px;\n height: 73px;\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}\n\nfunction RainbowInput({\n id,\n value,\n onChange,\n placeholder,\n autoComplete,\n \"aria-label\": ariaLabel,\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 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 Chat() {\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 endRef = useRef<HTMLDivElement | null>(null);\n const abortRef = useRef<AbortController | null>(null);\n const { isOpen, setIsOpen } = useContext(ChatContext);\n\n useEffect(() => {\n endRef.current?.scrollIntoView({ behavior: \"smooth\", block: \"end\" });\n }, [answer]);\n\n useEffect(() => {\n if (answer?.length > 0) {\n const el = document.getElementById(\n \"chat-bottom-input\",\n ) as HTMLInputElement | null;\n el?.focus();\n }\n }, [answer]);\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 res = await fetch(\"/api/rag\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ question: currentQuestion }),\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 // Add a placeholder for the streaming answer\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 // Keep the last (potentially incomplete) part in the buffer\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 // Finalize with MDX serialization\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 (parseError instanceof Error && parseError.message !== \"Unknown error\") {\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 return (\n <>\n <StyledChatFixedForm\n onSubmit={ask}\n $hide={answer?.length > 0}\n $hasLinks={links.length > 0}\n >\n <StyledChatFixedInner>\n <RainbowInput\n value={question}\n onChange={(e) => setQuestion(e.target.value)}\n placeholder=\"Ask AI Assistant...\"\n autoComplete=\"off\"\n aria-label=\"Ask a question about the documentation\"\n />\n <StyledRainbowButton\n type=\"submit\"\n disabled={loading}\n $hasContent={question.trim().length > 0}\n aria-label={loading ? \"Loading response\" : \"Submit question\"}\n >\n {loading ? <LoaderPinwheel className=\"loading\" /> : <ArrowUp />}\n </StyledRainbowButton>\n </StyledChatFixedInner>\n </StyledChatFixedForm>\n\n <StyledChat $isVisible={isOpen}>\n <StyledChatTitle>\n <StyledChatTitleIconWrapper>\n <Sparkles />\n <h3>AI Assistant</h3>\n </StyledChatTitleIconWrapper>\n <StyledChatCloseButton\n onClick={() => {\n abortRef.current?.abort();\n setAnswer([]);\n setIsOpen(false);\n }}\n aria-label=\"Close chat\"\n >\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 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}>({\n isOpen: false,\n setIsOpen: () => {},\n isChatActive: false,\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\n return (\n <ChatContext.Provider\n value={{\n isOpen,\n setIsOpen,\n isChatActive,\n }}\n >\n {children}\n </ChatContext.Provider>\n );\n};\n\nexport { Chat, ChtProvider, ChatContext };\n";
@@ -15,10 +15,12 @@ import rehypeHighlight from "rehype-highlight";
15
15
  import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote";
16
16
  import { serialize } from "next-mdx-remote/serialize";
17
17
  import { mq, Theme } from "@/app/theme";
18
- import { useMDXComponents } from "@/components/MDXComponents";
18
+ import { useMDXComponents as getMDXComponents } from "@/components/MDXComponents";
19
19
  import { styledTable, stylesLists } from "@/components/layout/SharedStyled";
20
20
  import links from "@/links.json";
21
21
 
22
+ const mdxComponents = getMDXComponents({});
23
+
22
24
  const styledText = css<{ theme: Theme }>\`
23
25
  font-size: \${({ theme }) => theme.fontSizes.text.xs};
24
26
  line-height: \${({ theme }) => theme.lineHeights.text.xs};
@@ -39,7 +41,6 @@ const StyledChat = styled.div<{ theme: Theme; $isVisible: boolean }>\`
39
41
  overflow-y: scroll;
40
42
  overflow-x: hidden;
41
43
  z-index: 1000;
42
- width: 100%;
43
44
  padding: 0 20px;
44
45
  transition: all 0.3s ease;
45
46
  transform: translateX(0);
@@ -490,7 +491,7 @@ const StyledAnswer = styled.div<{ theme: Theme; $isAnswer: boolean }>\`
490
491
  background: \${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};
491
492
  color: \${({ theme }) => theme.colors.dark};
492
493
  padding: 2px 4px;
493
- border-radius: \${({ theme }) => theme.spacing.radius.xs};A
494
+ border-radius: \${({ theme }) => theme.spacing.radius.xs};
494
495
  white-space: pre;
495
496
  }
496
497
 
@@ -632,6 +633,7 @@ interface RainbowInputProps {
632
633
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
633
634
  placeholder?: string;
634
635
  autoComplete?: string;
636
+ "aria-label"?: string;
635
637
  }
636
638
 
637
639
  function RainbowInput({
@@ -640,6 +642,7 @@ function RainbowInput({
640
642
  onChange,
641
643
  placeholder,
642
644
  autoComplete,
645
+ "aria-label": ariaLabel,
643
646
  }: RainbowInputProps) {
644
647
  const [isFocused, setIsFocused] = useState(false);
645
648
  const [isHovered, setIsHovered] = useState(false);
@@ -675,6 +678,7 @@ function RainbowInput({
675
678
  onChange={onChange}
676
679
  placeholder={placeholder}
677
680
  autoComplete={autoComplete}
681
+ aria-label={ariaLabel}
678
682
  onFocus={() => setIsFocused(true)}
679
683
  onBlur={() => setIsFocused(false)}
680
684
  />
@@ -688,7 +692,7 @@ function Chat() {
688
692
  const [error, setError] = useState<string | null>(null);
689
693
  const [answer, setAnswer] = useState<Answer[]>([]);
690
694
  const endRef = useRef<HTMLDivElement | null>(null);
691
- const mdxComponents = useMDXComponents({});
695
+ const abortRef = useRef<AbortController | null>(null);
692
696
  const { isOpen, setIsOpen } = useContext(ChatContext);
693
697
 
694
698
  useEffect(() => {
@@ -720,11 +724,15 @@ function Chat() {
720
724
 
721
725
  setAnswer(mergedQuestions);
722
726
 
727
+ const controller = new AbortController();
728
+ abortRef.current = controller;
729
+
723
730
  try {
724
731
  const res = await fetch("/api/rag", {
725
732
  method: "POST",
726
733
  headers: { "Content-Type": "application/json" },
727
734
  body: JSON.stringify({ question: currentQuestion }),
735
+ signal: controller.signal,
728
736
  });
729
737
 
730
738
  if (!res.ok) {
@@ -734,7 +742,7 @@ function Chat() {
734
742
 
735
743
  const reader = res.body?.getReader();
736
744
  const decoder = new TextDecoder();
737
- let streamedContent = "";
745
+ const contentParts: string[] = [];
738
746
  if (!reader) {
739
747
  throw new Error("Failed to get response reader");
740
748
  }
@@ -743,20 +751,24 @@ function Chat() {
743
751
  const streamingAnswerIndex = mergedQuestions.length;
744
752
  setAnswer([...mergedQuestions, { text: "", answer: true }]);
745
753
 
754
+ let buffer = "";
746
755
  while (true) {
747
756
  const { done, value } = await reader.read();
748
757
  if (done) break;
749
758
 
750
- const chunk = decoder.decode(value);
751
- const lines = chunk.split("\\n");
759
+ buffer += decoder.decode(value, { stream: true });
760
+ const parts = buffer.split("\\n");
761
+ // Keep the last (potentially incomplete) part in the buffer
762
+ buffer = parts.pop() ?? "";
752
763
 
753
- for (const line of lines) {
764
+ for (const line of parts) {
754
765
  if (line.startsWith("data: ")) {
755
766
  try {
756
767
  const data = JSON.parse(line.slice(6));
757
768
 
758
769
  if (data.type === "content") {
759
- streamedContent += data.data;
770
+ contentParts.push(data.data);
771
+ const streamedContent = contentParts.join("");
760
772
 
761
773
  setAnswer((prev) => {
762
774
  const newAnswers = [...prev];
@@ -769,6 +781,7 @@ function Chat() {
769
781
  } else if (data.type === "error") {
770
782
  throw new Error(data.data);
771
783
  } else if (data.type === "done") {
784
+ const streamedContent = contentParts.join("");
772
785
  // Finalize with MDX serialization
773
786
  let mdxSource: MDXRemoteSerializeResult | null = null;
774
787
  try {
@@ -796,14 +809,18 @@ function Chat() {
796
809
  });
797
810
  }
798
811
  } catch (parseError) {
799
- console.error("Failed to parse SSE data:", parseError);
812
+ if (parseError instanceof Error && parseError.message !== "Unknown error") {
813
+ console.error("Failed to parse SSE data:", parseError);
814
+ }
800
815
  }
801
816
  }
802
817
  }
803
818
  }
804
819
  } catch (err: unknown) {
820
+ if (err instanceof DOMException && err.name === "AbortError") return;
805
821
  setError(err instanceof Error ? err.message : "Unknown error");
806
822
  } finally {
823
+ abortRef.current = null;
807
824
  setLoading(false);
808
825
  }
809
826
  }
@@ -821,11 +838,13 @@ function Chat() {
821
838
  onChange={(e) => setQuestion(e.target.value)}
822
839
  placeholder="Ask AI Assistant..."
823
840
  autoComplete="off"
841
+ aria-label="Ask a question about the documentation"
824
842
  />
825
843
  <StyledRainbowButton
826
844
  type="submit"
827
845
  disabled={loading}
828
846
  $hasContent={question.trim().length > 0}
847
+ aria-label={loading ? "Loading response" : "Submit question"}
829
848
  >
830
849
  {loading ? <LoaderPinwheel className="loading" /> : <ArrowUp />}
831
850
  </StyledRainbowButton>
@@ -840,9 +859,11 @@ function Chat() {
840
859
  </StyledChatTitleIconWrapper>
841
860
  <StyledChatCloseButton
842
861
  onClick={() => {
862
+ abortRef.current?.abort();
843
863
  setAnswer([]);
844
864
  setIsOpen(false);
845
865
  }}
866
+ aria-label="Close chat"
846
867
  >
847
868
  <X />
848
869
  </StyledChatCloseButton>
@@ -879,11 +900,13 @@ function Chat() {
879
900
  onChange={(e) => setQuestion(e.target.value)}
880
901
  placeholder="Ask AI Assistant..."
881
902
  autoComplete="off"
903
+ aria-label="Ask a follow-up question"
882
904
  />
883
905
  <StyledRainbowButton
884
906
  type="submit"
885
907
  disabled={loading || question.trim() === ""}
886
908
  $hasContent={question.trim().length > 0}
909
+ aria-label={loading ? "Loading response" : "Submit question"}
887
910
  >
888
911
  {loading ? <LoaderPinwheel className="loading" /> : <ArrowUp />}
889
912
  </StyledRainbowButton>
@@ -1 +1 @@
1
- export declare const codeTemplate = "\"use client\";\nimport { useState, useCallback } 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 { editableContent } from \"@/components/layout/SharedStyled\";\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 &[contenteditable=\"true\"] {\n ${editableContent};\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n }\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 highlightCode = (code: string, language: string): string => {\n const escapedCode = escapeHtml(code);\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-${language}\">${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 = highlightCode(code, language);\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 + `${className && \" \"}code-wrapper`}\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 { editableContent } from \"@/components/layout/SharedStyled\";\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 &[contenteditable=\"true\"] {\n ${editableContent};\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n }\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,5 +1,5 @@
1
1
  export const codeTemplate = `"use client";
2
- import { useState, useCallback } from "react";
2
+ import { useState, useCallback, useMemo } from "react";
3
3
  import styled from "styled-components";
4
4
  import { Theme, styledCode } from "cherry-styled-components";
5
5
  import { rgba } from "polished";
@@ -354,8 +354,12 @@ const escapeHtml = (unsafe: string): string => {
354
354
  .replace(/'/g, "&#039;");
355
355
  };
356
356
 
357
+ const sanitizeLanguage = (lang: string): string =>
358
+ lang.replace(/[^a-zA-Z0-9_-]/g, "");
359
+
357
360
  const highlightCode = (code: string, language: string): string => {
358
361
  const escapedCode = escapeHtml(code);
362
+ const safeLang = sanitizeLanguage(language);
359
363
  const result = unified()
360
364
  .use(rehypeParse, { fragment: true })
361
365
  .use(rehypeHighlight, {
@@ -364,7 +368,7 @@ const highlightCode = (code: string, language: string): string => {
364
368
  })
365
369
  .use(rehypeStringify)
366
370
  .processSync(
367
- \`<pre><code class="language-\${language}">\${escapedCode}</code></pre>\`,
371
+ \`<pre><code class="language-\${safeLang}">\${escapedCode}</code></pre>\`,
368
372
  );
369
373
 
370
374
  return String(result);
@@ -372,7 +376,10 @@ const highlightCode = (code: string, language: string): string => {
372
376
 
373
377
  function Code({ code, language = "javascript", theme, className }: CodeProps) {
374
378
  const [copied, setCopied] = useState(false);
375
- const highlightedCode = highlightCode(code, language);
379
+ const highlightedCode = useMemo(
380
+ () => highlightCode(code, language),
381
+ [code, language],
382
+ );
376
383
 
377
384
  const handleCopy = useCallback(async () => {
378
385
  try {
@@ -386,7 +393,7 @@ function Code({ code, language = "javascript", theme, className }: CodeProps) {
386
393
 
387
394
  return (
388
395
  <CodeWrapper
389
- className={className + \`\${className && " "}code-wrapper\`}
396
+ className={\`\${className ?? ""} code-wrapper\`.trim()}
390
397
  theme={theme}
391
398
  >
392
399
  <TopBar theme={theme}>
@@ -1 +1 @@
1
- export declare const sharedStyledTemplate = "\"use client\";\nimport {\n mq,\n resetButton,\n styledH3,\n styledSmall,\n styledText,\n Theme,\n} from \"cherry-styled-components\";\nimport { rgba } from \"polished\";\nimport Link from \"next/link\";\nimport styled, { css } from \"styled-components\";\nimport { StyledH1 } from \"@/components/layout/Typography\";\n\nexport const interactiveStyles = css<{ theme: Theme }>`\n transition: all 0.3s ease;\n border: solid 1px transparent;\n box-shadow: 0 0 0 0px ${({ theme }) => theme.colors.primary};\n\n &:hover {\n border-color: ${({ theme }) => theme.colors.primary};\n }\n\n &:focus {\n border-color: ${({ theme }) => theme.colors.primary};\n box-shadow: 0 0 0 4px ${({ theme }) => theme.colors.primaryLight};\n }\n\n &:active {\n box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n`;\n\nexport const StyledPriceHeader = styled.div<{ theme: Theme }>`\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding: 20px;\n display: flex;\n gap: 20px;\n\n & strong {\n margin: auto 0;\n color: ${({ theme }) => theme.colors.dark};\n }\n`;\n\nexport const StyledPrice = styled.span<{\n theme: Theme;\n $textAlign?: \"left\" | \"center\" | \"right\";\n}>`\n color: ${({ theme }) => theme.colors.dark};\n display: block;\n font-weight: 800;\n ${({ theme }) => styledH3(theme)}\n ${({ $textAlign }) =>\n $textAlign &&\n css`\n text-align: ${$textAlign};\n `}\n`;\n\nexport const StyledSmall = styled.small<{\n theme: Theme;\n $textAlign?: \"left\" | \"center\" | \"right\";\n}>`\n ${({ theme }) => styledSmall(theme)};\n margin: auto 0;\n display: block;\n color: ${({ theme }) => theme.colors.grayDark};\n ${({ $textAlign }) =>\n $textAlign &&\n css`\n text-align: ${$textAlign};\n `}\n`;\n\nexport const StyledMinHeight = styled.div`\n min-height: calc(100svh - 100px);\n\n ${mq(\"lg\")} {\n min-height: calc(100svh - 120px);\n }\n`;\n\nexport const StyledDataUserAvatar = styled.span<{ theme: Theme }>`\n display: inline-flex;\n justify-content: center;\n min-width: 50px;\n min-height: 50px;\n border-radius: 50%;\n border: solid 2px ${({ theme }) => theme.colors.grayLight};\n background: ${({ theme }) => theme.colors.light};\n position: relative;\n overflow: hidden;\n color: ${({ theme }) => theme.colors.primary};\n\n & img {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: 100%;\n height: 100%;\n object-fit: cover;\n pointer-events: none;\n }\n\n & svg {\n pointer-events: none;\n margin: auto;\n transition: none;\n }\n`;\n\nexport const StyledDataArrowButton = styled.button<{\n theme: Theme;\n $isActive: boolean;\n}>`\n ${resetButton};\n display: flex;\n gap: 5px;\n min-width: fit-content;\n\n & .avatar,\n & .clickable {\n box-shadow: 0 0 0 0px ${({ theme }) => theme.colors.primary};\n transition: all 0.3s ease;\n }\n\n & svg {\n color: ${({ theme }) => theme.colors.primary};\n }\n\n &:hover {\n & .lucide-chevron-down {\n & path {\n stroke: ${({ theme }) => theme.colors.primaryDark};\n }\n }\n\n & .avatar,\n & .clickable {\n border-color: ${({ theme }) => theme.colors.primary};\n }\n }\n\n &:focus {\n & .avatar,\n & .clickable {\n border-color: ${({ theme }) => theme.colors.primary};\n box-shadow: 0 0 0 4px ${({ theme }) => theme.colors.primaryLight};\n }\n }\n\n &:active {\n & .avatar,\n & .clickable {\n box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n }\n\n & svg {\n margin: auto 0;\n }\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n & .lucide-chevron-down {\n transform: rotate(180deg);\n }\n `}\n`;\n\nexport const StyledDataSpan = styled.span<{ theme: Theme }>`\n display: block;\n font-weight: 500;\n ${({ theme }) => styledText(theme)};\n color: ${({ theme }) => theme.colors.primary};\n font-family: ${({ theme }) => theme.fonts.text};\n`;\n\nexport const StyledDataDropdownHoverWrapper = styled.span<{\n theme: Theme;\n $align?: \"center\";\n}>`\n position: relative;\n display: block;\n padding: 10px 0;\n margin: -10px 0;\n\n &:hover {\n & ul {\n pointer-events: all;\n opacity: 1;\n transform: ${({ $align }) =>\n $align === \"center\"\n ? \"translateX(-50%) translateY(0)\"\n : \"translateY(0)\"};\n }\n\n & .lucide-chevron-down {\n transform: rotate(180deg);\n }\n }\n\n &:active {\n & ul {\n opacity: 0;\n }\n }\n`;\n\nexport const StyledDataDropdownWrapper = styled.span<{\n theme: Theme;\n $isAbsolute?: boolean;\n $marginAuto?: boolean;\n}>`\n position: relative;\n display: flex;\n padding: 10px 0;\n margin: -10px 0;\n\n & span {\n display: flex;\n margin: auto 0;\n }\n\n ${({ $isAbsolute }) =>\n $isAbsolute &&\n css`\n margin: 0;\n padding: 0;\n `}\n\n ${({ $marginAuto }) => $marginAuto && `margin: auto 0;`}\n`;\n\nexport const StyledDataDropdown = styled.ul<{\n theme: Theme;\n $isActive?: boolean;\n $align?: \"left\" | \"center\";\n $isTop?: boolean;\n $isAbsolute?: boolean;\n $minWidth?: string;\n}>`\n position: absolute;\n display: block;\n top: 100%;\n right: 0;\n list-style: none;\n transition: all 0.3s ease;\n min-width: ${({ $minWidth }) => ($minWidth ? $minWidth : \"200px\")};\n text-align: left;\n box-shadow: ${({ theme }) => theme.shadows.sm};\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n overflow: hidden;\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n transition: all 0.3s ease;\n opacity: 0;\n pointer-events: none;\n transform: translateY(-10px);\n z-index: 999;\n\n ${({ $isAbsolute }) =>\n $isAbsolute &&\n css`\n top: calc(100% + 56px);\n right: 20px;\n `}\n\n ${({ $isTop }) => $isTop && `top: initial; bottom: 100%;`}\n\n ${({ $align }) => $align === \"left\" && `right: initial; left: 0;`}\n ${({ $align }) =>\n $align === \"center\" &&\n `right: initial; left: 50%; transform: translateX(-50%) translateY(-10px);`}\n\n ${({ $isActive, $align }) =>\n $isActive &&\n css`\n pointer-events: all;\n opacity: 1;\n transform: translateY(0);\n\n ${$align === \"center\" &&\n `right: initial; left: 50%; transform: translateX(-50%) translateY(0);`}\n `}\n`;\n\nexport const StyledDataDropdownItem = styled.li<{ theme: Theme }>`\n display: flex;\n width: 100%;\n text-align: left;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n &:last-of-type,\n &:only-of-type {\n border-bottom: none;\n }\n\n & form {\n width: 100%;\n margin: 0;\n padding: 0;\n }\n\n & a,\n & button,\n & .logout-button {\n ${resetButton};\n width: 100%;\n text-align: left;\n padding: 12px 20px;\n font-weight: 600;\n font-family: inherit;\n color: ${({ theme }) => theme.colors.primary};\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n text-decoration: none;\n transition: all 0.3s ease;\n border-radius: 0;\n background: transparent;\n display: flex;\n gap: 10px;\n height: auto;\n min-height: 45px;\n\n &:hover {\n background-color: ${({ theme }) =>\n rgba(\n theme.isDark ? theme.colors.primaryDark : theme.colors.primaryLight,\n 0.1,\n )};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n }\n\n &:active,\n &:focus {\n box-shadow: none;\n }\n\n * {\n margin: auto 0;\n }\n\n &:focus {\n background-color: ${({ theme }) =>\n rgba(\n theme.isDark ? theme.colors.primaryDark : theme.colors.primaryLight,\n 0.15,\n )};\n }\n\n &:active {\n background-color: ${({ theme }) =>\n rgba(\n theme.isDark ? theme.colors.primaryDark : theme.colors.primaryLight,\n 0.2,\n )};\n }\n }\n\n & button {\n border: none;\n\n & svg {\n margin: -2px -5px;\n }\n }\n`;\n\nexport const StyledDataDropdownButton = styled.button<{ theme: Theme }>`\n ${resetButton};\n`;\n\nexport const StyledDataDropdownSelectWrapper = styled.span<{ theme: Theme }>`\n padding: 5px;\n display: block;\n width: 100%;\n\n & select {\n width: 100%;\n }\n`;\n\nexport const StyledDataTagButton = styled.button<{ theme: Theme }>`\n ${resetButton};\n position: absolute;\n top: 0;\n width: 24px;\n height: 100%;\n right: 0;\n border-left: solid 1px\n ${({ theme }) =>\n rgba(theme.isDark ? theme.colors.dark : theme.colors.light, 0.2)};\n transition: all 0.3s ease;\n\n &:hover {\n background: ${({ theme }) =>\n rgba(theme.isDark ? theme.colors.dark : theme.colors.light, 0.2)};\n }\n\n & svg {\n width: 16px;\n height: 100%;\n vertical-align: middle;\n\n & path {\n stroke: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n }\n }\n`;\n\nexport const StyledDataTag = styled.span<{\n theme: Theme;\n $color?: \"default\" | \"warning\";\n $capitalize?: boolean;\n}>`\n display: inline-block;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n background: ${({ theme, $color }) =>\n $color === \"warning\" ? theme.colors.warning : theme.colors.primary};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n font-size: ${({ theme }) => theme.fontSizes.small.xs};\n position: relative;\n font-weight: 500;\n padding: 5px 10px;\n margin: auto 0;\n overflow: hidden;\n ${({ $capitalize }) =>\n $capitalize &&\n css`\n text-transform: capitalize;\n `}\n\n &:has(button) {\n padding: 5px 35px 5px 10px;\n }\n`;\n\nexport const StyledIllustration = styled.div<{ theme: Theme }>`\n text-align: center;\n\n & svg {\n height: auto;\n width: 250px;\n margin: auto;\n }\n`;\n\nexport const StyledIllustrationText = styled.p<{ theme: Theme }>`\n color: ${({ theme }) => theme.colors.dark};\n ${({ theme }) => styledSmall(theme)};\n`;\n\nexport const StyledDataText = styled.div<{ theme: Theme; $gray?: boolean }>`\n ${({ theme }) => styledText(theme)};\n color: ${({ theme, $gray }) =>\n $gray ? theme.colors.grayDark : theme.colors.dark};\n`;\n\nexport const StyledDataHeader = styled.h1<{ theme: Theme }>`\n ${({ theme }) => styledH3(theme)};\n font-weight: 900;\n color: ${({ theme }) => theme.colors.dark};\n`;\n\nexport const StyledMobileOnly = styled.em<{ theme: Theme }>`\n font-style: normal;\n display: inline;\n\n ${mq(\"lg\")} {\n display: none;\n }\n`;\n\nexport const StyledDesktopOnly = styled.em<{ theme: Theme }>`\n font-style: normal;\n display: none;\n\n ${mq(\"lg\")} {\n display: inline;\n }\n`;\n\nexport const StyledAlignMiddle = styled.div<{ theme: Theme }>`\n margin: auto 0;\n`;\n\nexport const StyledLoadingOverlay = styled.div<{\n theme: Theme;\n $isActive?: boolean;\n}>`\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background-color: ${({ theme }) => theme.colors.light};\n z-index: 1000;\n display: flex;\n transition: all 0.3s ease;\n opacity: 0;\n pointer-events: none;\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n opacity: 1;\n pointer-events: all;\n `}\n\n & > * {\n margin: auto;\n }\n`;\n\nexport const StyledFlex1 = styled.div`\n flex: 1;\n`;\n\nexport const StyledIconCircle = styled.span<{ theme: Theme }>`\n border-radius: 50%;\n width: 40px;\n height: 40px;\n min-width: 40px;\n min-height: 40px;\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n display: inline-flex;\n color: ${({ theme }) => theme.colors.primary};\n vertical-align: middle;\n\n & svg {\n margin: auto;\n }\n`;\n\nexport const StyledStrong = styled.span<{ theme: Theme }>`\n display: block;\n margin: auto 0;\n color: ${({ theme }) => theme.colors.primary};\n font-weight: 700;\n`;\n\nexport const StyledTextMiddle = styled.span<{ theme: Theme }>`\n display: block;\n margin: auto 0;\n color: ${({ theme }) => theme.colors.dark};\n text-align: left;\n`;\n\nexport const StyledPanelWrapper = styled.div<{ theme: Theme }>`\n position: relative;\n z-index: 100;\n\n & input {\n position: relative;\n z-index: 10;\n }\n`;\n\nexport const StyledPanel = styled.div<{ theme: Theme; $absolute?: boolean }>`\n background: ${({ theme }) => theme.colors.light};\n border: solid 2px ${({ theme }) => theme.colors.grayLight};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n margin: ${({ $absolute }) => ($absolute ? \"-10px 0 0 0\" : \"0\")};\n ${({ $absolute }) =>\n $absolute &&\n css`\n position: absolute;\n width: 100%;\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n `}\n max-height: calc(63px * 3);\n overflow-y: auto;\n`;\n\nexport const StyledPanelLabel = styled.div<{ theme: Theme; $moveUp?: boolean }>`\n font-size: ${({ theme }) => theme.fontSizes.small.xs};\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n color: ${({ theme }) => theme.colors.gray};\n padding: ${({ $moveUp }) => ($moveUp ? \"20px 15px 10px\" : \"20px 15px\")};\n`;\n\nexport const StyledPanelContent = styled.div<{ theme: Theme }>`\n display: flex;\n padding: 15px;\n gap: 10px;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n width: 100%;\n text-align: left;\n font-family: ${({ theme }) => theme.fonts.text};\n font-size: ${({ theme }) => theme.fontSizes.text.xs};\n line-height: ${({ theme }) => theme.lineHeights.text.xs};\n font-weight: 500;\n color: ${({ theme }) => theme.colors.primary};\n transition: all 0.3s ease;\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n line-height: ${({ theme }) => theme.lineHeights.text.lg};\n }\n\n &:last-of-type {\n border-bottom: none;\n }\n`;\n\nexport const StyledPanelButton = styled(Link)<{ theme: Theme }>`\n ${resetButton};\n display: flex;\n padding: 15px;\n gap: 10px;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n width: 100%;\n text-align: left;\n font-family: ${({ theme }) => theme.fonts.text};\n font-size: ${({ theme }) => theme.fontSizes.text.xs};\n line-height: ${({ theme }) => theme.lineHeights.text.xs};\n font-weight: 500;\n color: ${({ theme }) => theme.colors.primary};\n text-decoration: none;\n transition: all 0.3s ease;\n\n &:hover {\n background-color: ${({ theme }) => rgba(theme.colors.primaryLight, 0.1)};\n\n & .link {\n border-color: ${({ theme }) => theme.colors.primaryDark};\n }\n }\n\n &:focus {\n background-color: ${({ theme }) => rgba(theme.colors.primaryLight, 0.15)};\n\n & .link {\n box-shadow: 0 0 0 4px ${({ theme }) => theme.colors.primaryLight};\n border-color: ${({ theme }) => theme.colors.primary};\n }\n }\n\n &:active {\n background-color: ${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};\n\n & .link {\n box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n }\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n line-height: ${({ theme }) => theme.lineHeights.text.lg};\n }\n\n &:last-of-type {\n border-bottom: none;\n }\n\n & .link {\n transition: all 0.3s ease;\n pointer-events: none;\n }\n`;\n\nexport const StyledPanelSpan = styled.span<{ theme: Theme }>`\n margin: auto 0;\n display: flex;\n flex: 1;\n line-height: 1.5;\n`;\n\nexport const editableContent = css<{ theme: Theme }>`\n border: dotted 1px transparent;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n transition: all 0.3s ease;\n outline: none;\n cursor: text;\n\n &:hover {\n border: dotted 1px ${({ theme }) => theme.colors.gray};\n }\n\n &:focus {\n border: dotted 1px ${({ theme }) => theme.colors.info};\n }\n`;\n\nexport const StyledDataEditableText = styled.div<{\n theme: Theme;\n $gray?: boolean;\n}>`\n ${({ theme }) => styledText(theme)};\n color: ${({ theme, $gray }) =>\n $gray ? theme.colors.grayDark : theme.colors.dark};\n\n &[contenteditable=\"true\"] {\n ${editableContent};\n }\n`;\n\nexport const StyledSmallButtonWrapper = styled.div<{ theme: Theme }>`\n position: relative;\n\n & .hidden-button {\n opacity: 0;\n pointer-events: none;\n transform: translateY(10px);\n }\n\n &:hover {\n & .hidden-button {\n opacity: 1;\n pointer-events: all;\n transform: translateY(0);\n }\n }\n`;\n\nexport const StyledSmallButton = styled.button<{\n theme: Theme;\n}>`\n ${resetButton};\n display: flex;\n gap: 5px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n color: ${({ theme }) => theme.colors.primary};\n background: ${({ theme }) => theme.colors.light};\n margin: auto 0;\n min-width: fit-content;\n ${interactiveStyles}\n\n &.hidden-button {\n position: absolute;\n bottom: 1px;\n right: 1px;\n z-index: 98;\n }\n`;\n\nexport const StyledTitle = styled(StyledH1)<{ theme: Theme }>`\n display: block;\n\n &[contenteditable=\"true\"] {\n ${editableContent};\n }\n`;\n\nexport const StyledImage = styled.img<{ theme: Theme; $maxWidth?: string }>`\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n max-width: ${({ $maxWidth }) => ($maxWidth ? $maxWidth : \"100%\")};\n width: 100%;\n height: auto;\n border: 1px solid ${({ theme }) => theme.colors.grayLight};\n`;\n\nexport const stylesLists = css<{ theme: Theme }>`\n & ul,\n & ol {\n & li {\n & > .code-wrapper {\n margin: 10px 0;\n }\n }\n }\n\n & ul {\n list-style: none;\n padding: 0;\n margin: 0;\n\n & li {\n text-indent: 0;\n display: block;\n position: relative;\n padding: 0 0 0 15px;\n margin: 0;\n ${({ theme }) => styledText(theme)};\n min-height: 23px;\n\n $mq: \"lg\" {\n min-height: 27px;\n }\n\n &::before {\n content: \"\";\n display: block;\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: ${({ theme }) => theme.colors.primary};\n position: absolute;\n top: 8px;\n left: 2px;\n\n ${mq(\"lg\")} {\n top: 10px;\n }\n }\n }\n }\n\n & ol {\n padding: 0;\n margin: 0;\n\n & ul {\n padding-left: 15px;\n }\n\n & > li {\n position: relative;\n padding: 0;\n counter-increment: item;\n margin: 0;\n ${({ theme }) => styledText(theme)};\n\n &::before {\n content: counter(item) \".\";\n display: inline-block;\n margin: 0 4px 0 0;\n font-weight: 700;\n color: ${({ theme }) => theme.colors.primary};\n min-width: max-content;\n }\n }\n }\n`;\n\nexport const styledTable = css<{ theme: Theme }>`\n & table {\n margin: 0;\n padding: 0;\n border-collapse: collapse;\n width: 100%;\n text-align: left;\n\n & tr {\n margin: 0;\n padding: 0;\n }\n\n & th {\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding: 10px 0;\n ${({ theme }) => styledSmall(theme)};\n font-weight: 600;\n color: ${({ theme }) => theme.colors.dark};\n }\n\n & td {\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding: 10px 10px 10px 0;\n color: ${({ theme }) => theme.colors.grayDark};\n ${({ theme }) => styledSmall(theme)};\n }\n }\n`;\n";
1
+ export declare const sharedStyledTemplate = "\"use client\";\nimport {\n mq,\n resetButton,\n styledH3,\n styledSmall,\n styledText,\n Theme,\n} from \"cherry-styled-components\";\nimport { rgba } from \"polished\";\nimport Link from \"next/link\";\nimport styled, { css } from \"styled-components\";\nimport { StyledH1 } from \"@/components/layout/Typography\";\n\nexport const interactiveStyles = css<{ theme: Theme }>`\n transition: all 0.3s ease;\n border: solid 1px transparent;\n box-shadow: 0 0 0 0px ${({ theme }) => theme.colors.primary};\n\n &:hover {\n border-color: ${({ theme }) => theme.colors.primary};\n }\n\n &:focus {\n border-color: ${({ theme }) => theme.colors.primary};\n box-shadow: 0 0 0 4px ${({ theme }) => theme.colors.primaryLight};\n }\n\n &:active {\n box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n`;\n\nexport const StyledPriceHeader = styled.div<{ theme: Theme }>`\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding: 20px;\n display: flex;\n gap: 20px;\n\n & strong {\n margin: auto 0;\n color: ${({ theme }) => theme.colors.dark};\n }\n`;\n\nexport const StyledPrice = styled.span<{\n theme: Theme;\n $textAlign?: \"left\" | \"center\" | \"right\";\n}>`\n color: ${({ theme }) => theme.colors.dark};\n display: block;\n font-weight: 800;\n ${({ theme }) => styledH3(theme)}\n ${({ $textAlign }) =>\n $textAlign &&\n css`\n text-align: ${$textAlign};\n `}\n`;\n\nexport const StyledSmall = styled.small<{\n theme: Theme;\n $textAlign?: \"left\" | \"center\" | \"right\";\n}>`\n ${({ theme }) => styledSmall(theme)};\n margin: auto 0;\n display: block;\n color: ${({ theme }) => theme.colors.grayDark};\n ${({ $textAlign }) =>\n $textAlign &&\n css`\n text-align: ${$textAlign};\n `}\n`;\n\nexport const StyledMinHeight = styled.div`\n min-height: calc(100svh - 100px);\n\n ${mq(\"lg\")} {\n min-height: calc(100svh - 120px);\n }\n`;\n\nexport const StyledDataUserAvatar = styled.span<{ theme: Theme }>`\n display: inline-flex;\n justify-content: center;\n min-width: 50px;\n min-height: 50px;\n border-radius: 50%;\n border: solid 2px ${({ theme }) => theme.colors.grayLight};\n background: ${({ theme }) => theme.colors.light};\n position: relative;\n overflow: hidden;\n color: ${({ theme }) => theme.colors.primary};\n\n & img {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: 100%;\n height: 100%;\n object-fit: cover;\n pointer-events: none;\n }\n\n & svg {\n pointer-events: none;\n margin: auto;\n transition: none;\n }\n`;\n\nexport const StyledDataArrowButton = styled.button<{\n theme: Theme;\n $isActive: boolean;\n}>`\n ${resetButton};\n display: flex;\n gap: 5px;\n min-width: fit-content;\n\n & .avatar,\n & .clickable {\n box-shadow: 0 0 0 0px ${({ theme }) => theme.colors.primary};\n transition: all 0.3s ease;\n }\n\n & svg {\n color: ${({ theme }) => theme.colors.primary};\n }\n\n &:hover {\n & .lucide-chevron-down {\n & path {\n stroke: ${({ theme }) => theme.colors.primaryDark};\n }\n }\n\n & .avatar,\n & .clickable {\n border-color: ${({ theme }) => theme.colors.primary};\n }\n }\n\n &:focus {\n & .avatar,\n & .clickable {\n border-color: ${({ theme }) => theme.colors.primary};\n box-shadow: 0 0 0 4px ${({ theme }) => theme.colors.primaryLight};\n }\n }\n\n &:active {\n & .avatar,\n & .clickable {\n box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n }\n\n & svg {\n margin: auto 0;\n }\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n & .lucide-chevron-down {\n transform: rotate(180deg);\n }\n `}\n`;\n\nexport const StyledDataSpan = styled.span<{ theme: Theme }>`\n display: block;\n font-weight: 500;\n ${({ theme }) => styledText(theme)};\n color: ${({ theme }) => theme.colors.primary};\n font-family: ${({ theme }) => theme.fonts.text};\n`;\n\nexport const StyledDataDropdownHoverWrapper = styled.span<{\n theme: Theme;\n $align?: \"center\";\n}>`\n position: relative;\n display: block;\n padding: 10px 0;\n margin: -10px 0;\n\n &:hover {\n & ul {\n pointer-events: all;\n opacity: 1;\n transform: ${({ $align }) =>\n $align === \"center\"\n ? \"translateX(-50%) translateY(0)\"\n : \"translateY(0)\"};\n }\n\n & .lucide-chevron-down {\n transform: rotate(180deg);\n }\n }\n\n &:active {\n & ul {\n opacity: 0;\n }\n }\n`;\n\nexport const StyledDataDropdownWrapper = styled.span<{\n theme: Theme;\n $isAbsolute?: boolean;\n $marginAuto?: boolean;\n}>`\n position: relative;\n display: flex;\n padding: 10px 0;\n margin: -10px 0;\n\n & span {\n display: flex;\n margin: auto 0;\n }\n\n ${({ $isAbsolute }) =>\n $isAbsolute &&\n css`\n margin: 0;\n padding: 0;\n `}\n\n ${({ $marginAuto }) => $marginAuto && `margin: auto 0;`}\n`;\n\nexport const StyledDataDropdown = styled.ul<{\n theme: Theme;\n $isActive?: boolean;\n $align?: \"left\" | \"center\";\n $isTop?: boolean;\n $isAbsolute?: boolean;\n $minWidth?: string;\n}>`\n position: absolute;\n display: block;\n top: 100%;\n right: 0;\n list-style: none;\n transition: all 0.3s ease;\n min-width: ${({ $minWidth }) => ($minWidth ? $minWidth : \"200px\")};\n text-align: left;\n box-shadow: ${({ theme }) => theme.shadows.sm};\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n overflow: hidden;\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n transition: all 0.3s ease;\n opacity: 0;\n pointer-events: none;\n transform: translateY(-10px);\n z-index: 999;\n\n ${({ $isAbsolute }) =>\n $isAbsolute &&\n css`\n top: calc(100% + 56px);\n right: 20px;\n `}\n\n ${({ $isTop }) => $isTop && `top: initial; bottom: 100%;`}\n\n ${({ $align }) => $align === \"left\" && `right: initial; left: 0;`}\n ${({ $align }) =>\n $align === \"center\" &&\n `right: initial; left: 50%; transform: translateX(-50%) translateY(-10px);`}\n\n ${({ $isActive, $align }) =>\n $isActive &&\n css`\n pointer-events: all;\n opacity: 1;\n transform: translateY(0);\n\n ${$align === \"center\" &&\n `right: initial; left: 50%; transform: translateX(-50%) translateY(0);`}\n `}\n`;\n\nexport const StyledDataDropdownItem = styled.li<{ theme: Theme }>`\n display: flex;\n width: 100%;\n text-align: left;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n &:last-of-type,\n &:only-of-type {\n border-bottom: none;\n }\n\n & form {\n width: 100%;\n margin: 0;\n padding: 0;\n }\n\n & a,\n & button,\n & .logout-button {\n ${resetButton};\n width: 100%;\n text-align: left;\n padding: 12px 20px;\n font-weight: 600;\n font-family: inherit;\n color: ${({ theme }) => theme.colors.primary};\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n text-decoration: none;\n transition: all 0.3s ease;\n border-radius: 0;\n background: transparent;\n display: flex;\n gap: 10px;\n height: auto;\n min-height: 45px;\n\n &:hover {\n background-color: ${({ theme }) =>\n rgba(\n theme.isDark ? theme.colors.primaryDark : theme.colors.primaryLight,\n 0.1,\n )};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n }\n\n &:active,\n &:focus {\n box-shadow: none;\n }\n\n * {\n margin: auto 0;\n }\n\n &:focus {\n background-color: ${({ theme }) =>\n rgba(\n theme.isDark ? theme.colors.primaryDark : theme.colors.primaryLight,\n 0.15,\n )};\n }\n\n &:active {\n background-color: ${({ theme }) =>\n rgba(\n theme.isDark ? theme.colors.primaryDark : theme.colors.primaryLight,\n 0.2,\n )};\n }\n }\n\n & button {\n border: none;\n\n & svg {\n margin: -2px -5px;\n }\n }\n`;\n\nexport const StyledDataDropdownButton = styled.button<{ theme: Theme }>`\n ${resetButton};\n`;\n\nexport const StyledDataDropdownSelectWrapper = styled.span<{ theme: Theme }>`\n padding: 5px;\n display: block;\n width: 100%;\n\n & select {\n width: 100%;\n }\n`;\n\nexport const StyledDataTagButton = styled.button<{ theme: Theme }>`\n ${resetButton};\n position: absolute;\n top: 0;\n width: 24px;\n height: 100%;\n right: 0;\n border-left: solid 1px\n ${({ theme }) =>\n rgba(theme.isDark ? theme.colors.dark : theme.colors.light, 0.2)};\n transition: all 0.3s ease;\n\n &:hover {\n background: ${({ theme }) =>\n rgba(theme.isDark ? theme.colors.dark : theme.colors.light, 0.2)};\n }\n\n & svg {\n width: 16px;\n height: 100%;\n vertical-align: middle;\n\n & path {\n stroke: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n }\n }\n`;\n\nexport const StyledDataTag = styled.span<{\n theme: Theme;\n $color?: \"default\" | \"warning\";\n $capitalize?: boolean;\n}>`\n display: inline-block;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n background: ${({ theme, $color }) =>\n $color === \"warning\" ? theme.colors.warning : theme.colors.primary};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n font-size: ${({ theme }) => theme.fontSizes.small.xs};\n position: relative;\n font-weight: 500;\n padding: 5px 10px;\n margin: auto 0;\n overflow: hidden;\n ${({ $capitalize }) =>\n $capitalize &&\n css`\n text-transform: capitalize;\n `}\n\n &:has(button) {\n padding: 5px 35px 5px 10px;\n }\n`;\n\nexport const StyledIllustration = styled.div<{ theme: Theme }>`\n text-align: center;\n\n & svg {\n height: auto;\n width: 250px;\n margin: auto;\n }\n`;\n\nexport const StyledIllustrationText = styled.p<{ theme: Theme }>`\n color: ${({ theme }) => theme.colors.dark};\n ${({ theme }) => styledSmall(theme)};\n`;\n\nexport const StyledDataText = styled.div<{ theme: Theme; $gray?: boolean }>`\n ${({ theme }) => styledText(theme)};\n color: ${({ theme, $gray }) =>\n $gray ? theme.colors.grayDark : theme.colors.dark};\n`;\n\nexport const StyledDataHeader = styled.h1<{ theme: Theme }>`\n ${({ theme }) => styledH3(theme)};\n font-weight: 900;\n color: ${({ theme }) => theme.colors.dark};\n`;\n\nexport const StyledMobileOnly = styled.em<{ theme: Theme }>`\n font-style: normal;\n display: inline;\n\n ${mq(\"lg\")} {\n display: none;\n }\n`;\n\nexport const StyledDesktopOnly = styled.em<{ theme: Theme }>`\n font-style: normal;\n display: none;\n\n ${mq(\"lg\")} {\n display: inline;\n }\n`;\n\nexport const StyledAlignMiddle = styled.div<{ theme: Theme }>`\n margin: auto 0;\n`;\n\nexport const StyledLoadingOverlay = styled.div<{\n theme: Theme;\n $isActive?: boolean;\n}>`\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background-color: ${({ theme }) => theme.colors.light};\n z-index: 1000;\n display: flex;\n transition: all 0.3s ease;\n opacity: 0;\n pointer-events: none;\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n opacity: 1;\n pointer-events: all;\n `}\n\n & > * {\n margin: auto;\n }\n`;\n\nexport const StyledFlex1 = styled.div`\n flex: 1;\n`;\n\nexport const StyledIconCircle = styled.span<{ theme: Theme }>`\n border-radius: 50%;\n width: 40px;\n height: 40px;\n min-width: 40px;\n min-height: 40px;\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n display: inline-flex;\n color: ${({ theme }) => theme.colors.primary};\n vertical-align: middle;\n\n & svg {\n margin: auto;\n }\n`;\n\nexport const StyledStrong = styled.span<{ theme: Theme }>`\n display: block;\n margin: auto 0;\n color: ${({ theme }) => theme.colors.primary};\n font-weight: 700;\n`;\n\nexport const StyledTextMiddle = styled.span<{ theme: Theme }>`\n display: block;\n margin: auto 0;\n color: ${({ theme }) => theme.colors.dark};\n text-align: left;\n`;\n\nexport const StyledPanelWrapper = styled.div<{ theme: Theme }>`\n position: relative;\n z-index: 100;\n\n & input {\n position: relative;\n z-index: 10;\n }\n`;\n\nexport const StyledPanel = styled.div<{ theme: Theme; $absolute?: boolean }>`\n background: ${({ theme }) => theme.colors.light};\n border: solid 2px ${({ theme }) => theme.colors.grayLight};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n margin: ${({ $absolute }) => ($absolute ? \"-10px 0 0 0\" : \"0\")};\n ${({ $absolute }) =>\n $absolute &&\n css`\n position: absolute;\n width: 100%;\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n `}\n max-height: calc(63px * 3);\n overflow-y: auto;\n`;\n\nexport const StyledPanelLabel = styled.div<{ theme: Theme; $moveUp?: boolean }>`\n font-size: ${({ theme }) => theme.fontSizes.small.xs};\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n color: ${({ theme }) => theme.colors.gray};\n padding: ${({ $moveUp }) => ($moveUp ? \"20px 15px 10px\" : \"20px 15px\")};\n`;\n\nexport const StyledPanelContent = styled.div<{ theme: Theme }>`\n display: flex;\n padding: 15px;\n gap: 10px;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n width: 100%;\n text-align: left;\n font-family: ${({ theme }) => theme.fonts.text};\n font-size: ${({ theme }) => theme.fontSizes.text.xs};\n line-height: ${({ theme }) => theme.lineHeights.text.xs};\n font-weight: 500;\n color: ${({ theme }) => theme.colors.primary};\n transition: all 0.3s ease;\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n line-height: ${({ theme }) => theme.lineHeights.text.lg};\n }\n\n &:last-of-type {\n border-bottom: none;\n }\n`;\n\nexport const StyledPanelButton = styled(Link)<{ theme: Theme }>`\n ${resetButton};\n display: flex;\n padding: 15px;\n gap: 10px;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n width: 100%;\n text-align: left;\n font-family: ${({ theme }) => theme.fonts.text};\n font-size: ${({ theme }) => theme.fontSizes.text.xs};\n line-height: ${({ theme }) => theme.lineHeights.text.xs};\n font-weight: 500;\n color: ${({ theme }) => theme.colors.primary};\n text-decoration: none;\n transition: all 0.3s ease;\n\n &:hover {\n background-color: ${({ theme }) => rgba(theme.colors.primaryLight, 0.1)};\n\n & .link {\n border-color: ${({ theme }) => theme.colors.primaryDark};\n }\n }\n\n &:focus {\n background-color: ${({ theme }) => rgba(theme.colors.primaryLight, 0.15)};\n\n & .link {\n box-shadow: 0 0 0 4px ${({ theme }) => theme.colors.primaryLight};\n border-color: ${({ theme }) => theme.colors.primary};\n }\n }\n\n &:active {\n background-color: ${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};\n\n & .link {\n box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n }\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n line-height: ${({ theme }) => theme.lineHeights.text.lg};\n }\n\n &:last-of-type {\n border-bottom: none;\n }\n\n & .link {\n transition: all 0.3s ease;\n pointer-events: none;\n }\n`;\n\nexport const StyledPanelSpan = styled.span<{ theme: Theme }>`\n margin: auto 0;\n display: flex;\n flex: 1;\n line-height: 1.5;\n`;\n\nexport const editableContent = css<{ theme: Theme }>`\n border: dotted 1px transparent;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n transition: all 0.3s ease;\n outline: none;\n cursor: text;\n\n &:hover {\n border: dotted 1px ${({ theme }) => theme.colors.gray};\n }\n\n &:focus {\n border: dotted 1px ${({ theme }) => theme.colors.info};\n }\n`;\n\nexport const StyledDataEditableText = styled.div<{\n theme: Theme;\n $gray?: boolean;\n}>`\n ${({ theme }) => styledText(theme)};\n color: ${({ theme, $gray }) =>\n $gray ? theme.colors.grayDark : theme.colors.dark};\n\n &[contenteditable=\"true\"] {\n ${editableContent};\n }\n`;\n\nexport const StyledSmallButtonWrapper = styled.div<{ theme: Theme }>`\n position: relative;\n\n & .hidden-button {\n opacity: 0;\n pointer-events: none;\n transform: translateY(10px);\n }\n\n &:hover {\n & .hidden-button {\n opacity: 1;\n pointer-events: all;\n transform: translateY(0);\n }\n }\n`;\n\nexport const StyledSmallButton = styled.button<{\n theme: Theme;\n}>`\n ${resetButton};\n display: flex;\n gap: 5px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n color: ${({ theme }) => theme.colors.primary};\n background: ${({ theme }) => theme.colors.light};\n margin: auto 0;\n min-width: fit-content;\n ${interactiveStyles}\n\n &.hidden-button {\n position: absolute;\n bottom: 1px;\n right: 1px;\n z-index: 98;\n }\n`;\n\nexport const StyledTitle = styled(StyledH1)<{ theme: Theme }>`\n display: block;\n\n &[contenteditable=\"true\"] {\n ${editableContent};\n }\n`;\n\nexport const StyledImage = styled.img<{ theme: Theme; $maxWidth?: string }>`\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n max-width: ${({ $maxWidth }) => ($maxWidth ? $maxWidth : \"100%\")};\n width: 100%;\n height: auto;\n border: 1px solid ${({ theme }) => theme.colors.grayLight};\n`;\n\nexport const stylesLists = css<{ theme: Theme }>`\n & ul,\n & ol {\n & li {\n & > .code-wrapper {\n margin: 10px 0;\n }\n }\n }\n\n & ul {\n list-style: none;\n padding: 0;\n margin: 0;\n\n & li {\n text-indent: 0;\n display: block;\n position: relative;\n padding: 0 0 0 15px;\n margin: 0;\n ${({ theme }) => styledText(theme)};\n min-height: 23px;\n\n ${mq(\"lg\")} {\n min-height: 27px;\n }\n\n &::before {\n content: \"\";\n display: block;\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: ${({ theme }) => theme.colors.primary};\n position: absolute;\n top: 8px;\n left: 2px;\n\n ${mq(\"lg\")} {\n top: 10px;\n }\n }\n }\n }\n\n & ol {\n padding: 0;\n margin: 0;\n\n & ul {\n padding-left: 15px;\n }\n\n & > li {\n position: relative;\n padding: 0;\n counter-increment: item;\n margin: 0;\n ${({ theme }) => styledText(theme)};\n\n &::before {\n content: counter(item) \".\";\n display: inline-block;\n margin: 0 4px 0 0;\n font-weight: 700;\n color: ${({ theme }) => theme.colors.primary};\n min-width: max-content;\n }\n }\n }\n`;\n\nexport const styledTable = css<{ theme: Theme }>`\n & table {\n margin: 0;\n padding: 0;\n border-collapse: collapse;\n width: 100%;\n text-align: left;\n\n & tr {\n margin: 0;\n padding: 0;\n }\n\n & th {\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding: 10px 0;\n ${({ theme }) => styledSmall(theme)};\n font-weight: 600;\n color: ${({ theme }) => theme.colors.dark};\n }\n\n & td {\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding: 10px 10px 10px 0;\n color: ${({ theme }) => theme.colors.grayDark};\n ${({ theme }) => styledSmall(theme)};\n }\n }\n`;\n";
@@ -783,7 +783,7 @@ export const stylesLists = css<{ theme: Theme }>\`
783
783
  \${({ theme }) => styledText(theme)};
784
784
  min-height: 23px;
785
785
 
786
- $mq: "lg" {
786
+ \${mq("lg")} {
787
787
  min-height: 27px;
788
788
  }
789
789
 
@@ -1 +1 @@
1
- export declare const nextConfigTemplate = "import type { NextConfig } from \"next\";\nconst path = require(\"path\");\n\nconst nextConfig: NextConfig = {\n outputFileTracingRoot: path.join(__dirname, \"./\"),\n compiler: {\n styledComponents: true,\n },\n transpilePackages: [\"lucide-react\", \"cherry-styled-components\"],\n};\n\nexport default nextConfig;\n";
1
+ export declare const nextConfigTemplate = "import type { NextConfig } from \"next\";\nimport path from \"node:path\";\n\nconst nextConfig: NextConfig = {\n outputFileTracingRoot: path.join(__dirname, \"./\"),\n compiler: {\n styledComponents: true,\n },\n transpilePackages: [\"lucide-react\", \"cherry-styled-components\"],\n};\n\nexport default nextConfig;\n";
@@ -1,5 +1,5 @@
1
1
  export const nextConfigTemplate = `import type { NextConfig } from "next";
2
- const path = require("path");
2
+ import path from "node:path";
3
3
 
4
4
  const nextConfig: NextConfig = {
5
5
  outputFileTracingRoot: path.join(__dirname, "./"),
@@ -9,35 +9,36 @@ export const packageJsonTemplate = JSON.stringify({
9
9
  lint: "eslint .",
10
10
  },
11
11
  dependencies: {
12
- "@langchain/anthropic": "^1.3.17",
13
- "@langchain/google-genai": "^2.1.18",
14
- "@langchain/openai": "^1.2.7",
12
+ "@langchain/anthropic": "^1.3.18",
13
+ "@langchain/core": "^1.1.25",
14
+ "@langchain/google-genai": "^2.1.19",
15
+ "@langchain/openai": "^1.2.8",
16
+ "@mdx-js/react": "^3.1.1",
15
17
  "@modelcontextprotocol/sdk": "^1.26.0",
18
+ "cherry-styled-components": "^0.1.12",
16
19
  langchain: "^1.2.24",
20
+ "lucide-react": "^0.574.0",
17
21
  next: "16.1.6",
22
+ "next-mdx-remote": "^6.0.0",
23
+ polished: "^4.3.1",
18
24
  react: "19.2.4",
19
25
  "react-dom": "19.2.4",
26
+ "rehype-highlight": "^7.0.2",
27
+ "rehype-parse": "^9.0.1",
28
+ "rehype-stringify": "^10.0.1",
29
+ "remark-gfm": "^4.0.1",
30
+ "styled-components": "^6.3.9",
31
+ unified: "^11.0.5",
32
+ zod: "^4.3.6",
20
33
  },
21
34
  devDependencies: {
22
- "@mdx-js/react": "^3.1.1",
23
35
  "@types/node": "^25",
24
36
  "@types/react": "^19",
25
37
  "@types/react-dom": "^19",
26
38
  "baseline-browser-mapping": "^2.9.19",
27
- "cherry-styled-components": "^0.1.12",
28
39
  eslint: "^9",
29
40
  "eslint-config-next": "16.1.6",
30
- "lucide-react": "^0.574.0",
31
- "next-mdx-remote": "^6.0.0",
32
- polished: "^4.3.1",
33
41
  prettier: "^3.8.1",
34
- "rehype-highlight": "^7.0.2",
35
- "rehype-parse": "^9.0.1",
36
- "rehype-stringify": "^10.0.1",
37
- "remark-gfm": "^4.0.1",
38
- "styled-components": "^6.3.9",
39
42
  typescript: "^5",
40
- unified: "^11.0.5",
41
- zod: "^4.3.6",
42
43
  },
43
44
  }, null, 2);
@@ -1 +1 @@
1
- export declare const llmConfigTemplate = "import type {\n LLMConfig,\n LLMProvider,\n ProviderDefaults,\n} from \"@/services/llm/types\";\nconst PROVIDER_DEFAULTS: ProviderDefaults = {\n openai: {\n chat: \"gpt-4.1-nano\",\n embedding: \"text-embedding-3-small\",\n },\n anthropic: {\n chat: \"claude-sonnet-4-5-20250929\",\n embedding: \"text-embedding-3-small\", // Fallback to OpenAI\n },\n google: {\n chat: \"gemini-2.5-flash-lite\",\n embedding: \"text-embedding-004\",\n },\n};\nfunction validateAPIKeys(provider: LLMProvider): void {\n const requiredKeys: Record<LLMProvider, string> = {\n openai: \"OPENAI_API_KEY\",\n anthropic: \"ANTHROPIC_API_KEY\",\n google: \"GOOGLE_API_KEY\",\n };\n const keyName = requiredKeys[provider];\n const keyValue = process.env[keyName];\n if (!keyValue) {\n throw new Error(\n `Missing API key for ${provider}. Please set ${keyName} in your environment variables.`,\n );\n }\n if (provider === \"anthropic\" && !process.env.OPENAI_API_KEY) {\n throw new Error(\n \"Anthropic provider requires OPENAI_API_KEY for embeddings. Please set OPENAI_API_KEY in your environment variables.\",\n );\n }\n}\nexport function getLLMConfig(): LLMConfig {\n const provider = (process.env.LLM_PROVIDER || \"openai\") as LLMProvider;\n if (![\"openai\", \"anthropic\", \"google\"].includes(provider)) {\n throw new Error(\n `Invalid LLM_PROVIDER: ${provider}. Must be one of: openai, anthropic, google`,\n );\n }\n validateAPIKeys(provider);\n const defaults = PROVIDER_DEFAULTS[provider];\n return {\n provider,\n chatModel: process.env.LLM_CHAT_MODEL || defaults.chat,\n embeddingModel: process.env.LLM_EMBEDDING_MODEL || defaults.embedding,\n temperature: parseFloat(process.env.LLM_TEMPERATURE || \"0\"),\n };\n}\nexport function getLLMDisplayName(config?: LLMConfig): string {\n const c = config || getLLMConfig();\n const providerNames: Record<LLMProvider, string> = {\n openai: \"OpenAI\",\n anthropic: \"Anthropic\",\n google: \"Google\",\n };\n return `${providerNames[c.provider]} (${c.chatModel})`;\n}\nexport function isLLMConfigured(): boolean {\n try {\n getLLMConfig();\n return true;\n } catch {\n return false;\n }\n}\n";
1
+ export declare const llmConfigTemplate = "import type {\n LLMConfig,\n LLMProvider,\n ProviderDefaults,\n} from \"@/services/llm/types\";\nconst PROVIDER_DEFAULTS: ProviderDefaults = {\n openai: {\n chat: \"gpt-4.1-nano\",\n embedding: \"text-embedding-3-small\",\n },\n anthropic: {\n chat: \"claude-sonnet-4-5-20250929\",\n embedding: \"text-embedding-3-small\", // Fallback to OpenAI\n },\n google: {\n chat: \"gemini-2.5-flash-lite\",\n embedding: \"text-embedding-004\",\n },\n};\nfunction validateAPIKeys(provider: LLMProvider): void {\n const requiredKeys: Record<LLMProvider, string> = {\n openai: \"OPENAI_API_KEY\",\n anthropic: \"ANTHROPIC_API_KEY\",\n google: \"GOOGLE_API_KEY\",\n };\n const keyName = requiredKeys[provider];\n const keyValue = process.env[keyName];\n if (!keyValue) {\n throw new Error(\n `Missing API key for ${provider}. Please set ${keyName} in your environment variables.`,\n );\n }\n if (provider === \"anthropic\" && !process.env.OPENAI_API_KEY) {\n throw new Error(\n \"Anthropic provider requires OPENAI_API_KEY for embeddings. Please set OPENAI_API_KEY in your environment variables.\",\n );\n }\n}\nexport function getLLMConfig(): LLMConfig {\n const provider = (process.env.LLM_PROVIDER || \"openai\") as LLMProvider;\n if (![\"openai\", \"anthropic\", \"google\"].includes(provider)) {\n throw new Error(\n `Invalid LLM_PROVIDER: ${provider}. Must be one of: openai, anthropic, google`,\n );\n }\n validateAPIKeys(provider);\n const defaults = PROVIDER_DEFAULTS[provider];\n return {\n provider,\n chatModel: process.env.LLM_CHAT_MODEL || defaults.chat,\n embeddingModel: process.env.LLM_EMBEDDING_MODEL || defaults.embedding,\n temperature: parseFloat(process.env.LLM_TEMPERATURE || \"0\"),\n };\n}\n";
@@ -52,21 +52,4 @@ export function getLLMConfig(): LLMConfig {
52
52
  temperature: parseFloat(process.env.LLM_TEMPERATURE || "0"),
53
53
  };
54
54
  }
55
- export function getLLMDisplayName(config?: LLMConfig): string {
56
- const c = config || getLLMConfig();
57
- const providerNames: Record<LLMProvider, string> = {
58
- openai: "OpenAI",
59
- anthropic: "Anthropic",
60
- google: "Google",
61
- };
62
- return \`\${providerNames[c.provider]} (\${c.chatModel})\`;
63
- }
64
- export function isLLMConfigured(): boolean {
65
- try {
66
- getLLMConfig();
67
- return true;
68
- } catch {
69
- return false;
70
- }
71
- }
72
55
  `;
@@ -1 +1 @@
1
- export declare const llmTypesTemplate = "export type LLMProvider = \"openai\" | \"anthropic\" | \"google\";\n\nexport interface LLMConfig {\n provider: LLMProvider;\n chatModel: string;\n embeddingModel: string;\n temperature: number;\n}\n\nexport interface Message {\n role: \"system\" | \"user\" | \"assistant\";\n content: string;\n}\n\nexport interface LLMResponse {\n content: string;\n metadata?: Record<string, unknown>;\n}\n\nexport interface ProviderModels {\n chat: string;\n embedding: string;\n}\n\nexport type ProviderDefaults = Record<LLMProvider, ProviderModels>;\n";
1
+ export declare const llmTypesTemplate = "export type LLMProvider = \"openai\" | \"anthropic\" | \"google\";\n\nexport interface LLMConfig {\n provider: LLMProvider;\n chatModel: string;\n embeddingModel: string;\n temperature: number;\n}\n\nexport interface ProviderModels {\n chat: string;\n embedding: string;\n}\n\nexport type ProviderDefaults = Record<LLMProvider, ProviderModels>;\n";
@@ -7,16 +7,6 @@ export interface LLMConfig {
7
7
  temperature: number;
8
8
  }
9
9
 
10
- export interface Message {
11
- role: "system" | "user" | "assistant";
12
- content: string;
13
- }
14
-
15
- export interface LLMResponse {
16
- content: string;
17
- metadata?: Record<string, unknown>;
18
- }
19
-
20
10
  export interface ProviderModels {
21
11
  chat: string;
22
12
  embedding: string;
@@ -1 +1 @@
1
- export declare const mcpServerTemplate = "import { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\nimport {\n listDocs,\n getDoc,\n getAllDocsChunks,\n DOCS_TOOLS,\n} from \"@/services/mcp/tools\";\nimport { getLLMConfig, createEmbeddings } from \"@/services/llm\";\nimport type { DocsChunk } from \"@/services/mcp/types\";\n\n/**\n * In-memory cache for document embeddings\n */\nlet docsIndex: {\n ready: boolean;\n building: boolean;\n chunks: (DocsChunk & { embedding: number[] })[];\n} = {\n ready: false,\n building: false,\n chunks: [],\n};\n\n/**\n * Cosine similarity between two vectors\n */\nfunction cosineSim(a: number[], b: number[]): number {\n let dot = 0,\n na = 0,\n nb = 0;\n for (let i = 0; i < a.length; i++) {\n const x = a[i];\n const y = b[i];\n dot += x * y;\n na += x * x;\n nb += y * y;\n }\n if (na === 0 || nb === 0) return 0;\n return dot / (Math.sqrt(na) * Math.sqrt(nb));\n}\n\n/**\n * Build or rebuild the documentation index\n */\nexport async function buildDocsIndex(force = false): Promise<void> {\n if (docsIndex.building) return;\n if (docsIndex.ready && !force) return;\n\n docsIndex.building = true;\n try {\n const chunks = await getAllDocsChunks();\n\n if (chunks.length === 0) {\n docsIndex.chunks = [];\n docsIndex.ready = true;\n return;\n }\n\n const config = getLLMConfig();\n const embeddings = createEmbeddings(config);\n\n // Process embeddings in small batches to avoid exceeding token limits\n const BATCH_SIZE = 10;\n const texts = chunks.map((c) => c.text);\n const vectors: number[][] = [];\n\n for (let i = 0; i < texts.length; i += BATCH_SIZE) {\n const batch = texts.slice(i, i + BATCH_SIZE);\n const batchVectors = await embeddings.embedDocuments(batch);\n vectors.push(...batchVectors);\n }\n\n docsIndex.chunks = chunks.map((c, i) => ({\n ...c,\n embedding: vectors[i],\n }));\n docsIndex.ready = true;\n } finally {\n docsIndex.building = false;\n }\n}\n\n/**\n * Ensure the docs index is ready\n */\nexport async function ensureDocsIndex(force = false): Promise<void> {\n if (force) {\n docsIndex.ready = false;\n docsIndex.chunks = [];\n }\n if (!docsIndex.ready) {\n await buildDocsIndex();\n }\n}\n\n/**\n * Search documents using semantic similarity\n */\nexport async function searchDocs(\n query: string,\n limit = 6,\n): Promise<{ chunk: DocsChunk; score: number }[]> {\n await ensureDocsIndex();\n\n const config = getLLMConfig();\n const embeddings = createEmbeddings(config);\n const queryVector = await embeddings.embedQuery(query);\n\n const scored = docsIndex.chunks\n .map((c) => ({\n chunk: { id: c.id, text: c.text, path: c.path, uri: c.uri },\n score: cosineSim(queryVector, c.embedding),\n }))\n .sort((a, b) => b.score - a.score)\n .slice(0, limit);\n\n return scored;\n}\n\n/**\n * Get the current index status\n */\nexport function getIndexStatus(): { ready: boolean; chunkCount: number } {\n return {\n ready: docsIndex.ready,\n chunkCount: docsIndex.chunks.length,\n };\n}\n\n/**\n * Create and configure the MCP server with documentation tools\n */\nexport function createMCPServer(): McpServer {\n const server = new McpServer({\n name: \"docs-server\",\n version: \"1.0.0\",\n });\n\n // Register the search_docs tool\n server.tool(\n \"search_docs\",\n DOCS_TOOLS[0].description,\n {\n query: z\n .string()\n .describe(\"The search query to find relevant documentation\"),\n limit: z\n .number()\n .optional()\n .describe(\"Maximum number of results to return (default: 6)\"),\n },\n async ({ query, limit }) => {\n const results = await searchDocs(query, limit ?? 6);\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(\n results.map(({ chunk, score }) => ({\n path: chunk.path,\n uri: chunk.uri,\n score: score.toFixed(3),\n text: chunk.text,\n })),\n null,\n 2,\n ),\n },\n ],\n };\n },\n );\n\n // Register the get_doc tool\n server.tool(\n \"get_doc\",\n DOCS_TOOLS[1].description,\n {\n path: z.string().describe(\"The file path to the documentation page\"),\n },\n async ({ path }) => {\n const doc = await getDoc({ path });\n if (!doc) {\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify({ error: \"Document not found\" }),\n },\n ],\n isError: true,\n };\n }\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(doc, null, 2),\n },\n ],\n };\n },\n );\n\n // Register the list_docs tool\n server.tool(\n \"list_docs\",\n DOCS_TOOLS[2].description,\n {\n directory: z\n .string()\n .optional()\n .describe(\"Optional directory to filter results\"),\n },\n async ({ directory }) => {\n const docs = await listDocs({ directory });\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(\n docs.map((d) => ({\n name: d.name,\n path: d.path,\n uri: d.uri,\n })),\n null,\n 2,\n ),\n },\n ],\n };\n },\n );\n\n // Register documentation as resources\n server.resource(\"docs://list\", \"docs://list\", async () => {\n const docs = await listDocs();\n return {\n contents: [\n {\n uri: \"docs://list\",\n text: JSON.stringify(\n docs.map((d) => ({ name: d.name, path: d.path, uri: d.uri })),\n null,\n 2,\n ),\n },\n ],\n };\n });\n\n return server;\n}\n";
1
+ export declare const mcpServerTemplate = "import { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\nimport {\n listDocs,\n getDoc,\n getAllDocsChunks,\n DOCS_TOOLS,\n} from \"@/services/mcp/tools\";\nimport { getLLMConfig, createEmbeddings } from \"@/services/llm\";\nimport type { DocsChunk } from \"@/services/mcp/types\";\n\n/**\n * In-memory cache for document embeddings.\n * Built once at server startup since docs are static.\n */\nlet docsIndex: {\n ready: boolean;\n building: boolean;\n chunks: (DocsChunk & { embedding: number[] })[];\n} = {\n ready: false,\n building: false,\n chunks: [],\n};\n\n/** Resolves when the initial index build completes */\nlet indexReady: Promise<void> | null = null;\n\n/**\n * Cosine similarity between two vectors\n */\nfunction cosineSim(a: number[], b: number[]): number {\n let dot = 0,\n na = 0,\n nb = 0;\n for (let i = 0; i < a.length; i++) {\n const x = a[i];\n const y = b[i];\n dot += x * y;\n na += x * x;\n nb += y * y;\n }\n if (na === 0 || nb === 0) return 0;\n return dot / (Math.sqrt(na) * Math.sqrt(nb));\n}\n\n/**\n * Build or rebuild the documentation index\n */\nexport async function buildDocsIndex(force = false): Promise<void> {\n if (docsIndex.building) return;\n if (docsIndex.ready && !force) return;\n\n docsIndex.building = true;\n try {\n const chunks = await getAllDocsChunks();\n\n if (chunks.length === 0) {\n docsIndex.chunks = [];\n docsIndex.ready = true;\n return;\n }\n\n const config = getLLMConfig();\n const embeddings = createEmbeddings(config);\n\n // Process embeddings in small batches to avoid exceeding token limits\n const BATCH_SIZE = 10;\n const texts = chunks.map((c) => c.text);\n const vectors: number[][] = [];\n\n for (let i = 0; i < texts.length; i += BATCH_SIZE) {\n const batch = texts.slice(i, i + BATCH_SIZE);\n const batchVectors = await embeddings.embedDocuments(batch);\n vectors.push(...batchVectors);\n }\n\n docsIndex.chunks = chunks.map((c, i) => ({\n ...c,\n embedding: vectors[i],\n }));\n docsIndex.ready = true;\n } catch (error) {\n // Reset so the next call to ensureDocsIndex retries\n indexReady = null;\n throw error;\n } finally {\n docsIndex.building = false;\n }\n}\n\n/**\n * Ensure the docs index is ready.\n * On first call, triggers the build; subsequent calls wait for the same promise.\n */\nexport async function ensureDocsIndex(force = false): Promise<void> {\n if (force) {\n docsIndex.ready = false;\n docsIndex.chunks = [];\n indexReady = buildDocsIndex();\n return indexReady;\n }\n if (!indexReady) {\n indexReady = buildDocsIndex();\n }\n return indexReady;\n}\n\n// Eagerly start building the index on server startup (docs are static)\nindexReady = buildDocsIndex();\n\n/** Cached embeddings instance for search queries */\nlet cachedEmbeddings: ReturnType<typeof createEmbeddings> | null = null;\n\nfunction getEmbeddings() {\n if (!cachedEmbeddings) {\n cachedEmbeddings = createEmbeddings(getLLMConfig());\n }\n return cachedEmbeddings;\n}\n\n/**\n * Search documents using semantic similarity\n */\nexport async function searchDocs(\n query: string,\n limit = 6,\n): Promise<{ chunk: DocsChunk; score: number }[]> {\n await ensureDocsIndex();\n\n const queryVector = await getEmbeddings().embedQuery(query);\n\n const scored = docsIndex.chunks\n .map((c) => ({\n chunk: { id: c.id, text: c.text, path: c.path, uri: c.uri },\n score: cosineSim(queryVector, c.embedding),\n }))\n .sort((a, b) => b.score - a.score)\n .slice(0, limit);\n\n return scored;\n}\n\n/**\n * Get the current index status\n */\nexport function getIndexStatus(): { ready: boolean; chunkCount: number } {\n return {\n ready: docsIndex.ready,\n chunkCount: docsIndex.chunks.length,\n };\n}\n\n/**\n * Create and configure the MCP server with documentation tools\n */\nexport function createMCPServer(): McpServer {\n const server = new McpServer({\n name: \"docs-server\",\n version: \"1.0.0\",\n });\n\n // Register the search_docs tool\n server.tool(\n \"search_docs\",\n DOCS_TOOLS[0].description,\n {\n query: z\n .string()\n .describe(\"The search query to find relevant documentation\"),\n limit: z\n .number()\n .optional()\n .describe(\"Maximum number of results to return (default: 6)\"),\n },\n async ({ query, limit }) => {\n const results = await searchDocs(query, limit ?? 6);\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(\n results.map(({ chunk, score }) => ({\n path: chunk.path,\n uri: chunk.uri,\n score: score.toFixed(3),\n text: chunk.text,\n })),\n null,\n 2,\n ),\n },\n ],\n };\n },\n );\n\n // Register the get_doc tool\n server.tool(\n \"get_doc\",\n DOCS_TOOLS[1].description,\n {\n path: z.string().describe(\"The file path to the documentation page\"),\n },\n async ({ path }) => {\n const doc = await getDoc({ path });\n if (!doc) {\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify({ error: \"Document not found\" }),\n },\n ],\n isError: true,\n };\n }\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(doc, null, 2),\n },\n ],\n };\n },\n );\n\n // Register the list_docs tool\n server.tool(\n \"list_docs\",\n DOCS_TOOLS[2].description,\n {\n directory: z\n .string()\n .optional()\n .describe(\"Optional directory to filter results\"),\n },\n async ({ directory }) => {\n const docs = await listDocs({ directory });\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(\n docs.map((d) => ({\n name: d.name,\n path: d.path,\n uri: d.uri,\n })),\n null,\n 2,\n ),\n },\n ],\n };\n },\n );\n\n // Register documentation as resources\n server.resource(\"docs://list\", \"docs://list\", async () => {\n const docs = await listDocs();\n return {\n contents: [\n {\n uri: \"docs://list\",\n text: JSON.stringify(\n docs.map((d) => ({ name: d.name, path: d.path, uri: d.uri })),\n null,\n 2,\n ),\n },\n ],\n };\n });\n\n return server;\n}\n";
@@ -10,7 +10,8 @@ import { getLLMConfig, createEmbeddings } from "@/services/llm";
10
10
  import type { DocsChunk } from "@/services/mcp/types";
11
11
 
12
12
  /**
13
- * In-memory cache for document embeddings
13
+ * In-memory cache for document embeddings.
14
+ * Built once at server startup since docs are static.
14
15
  */
15
16
  let docsIndex: {
16
17
  ready: boolean;
@@ -22,6 +23,9 @@ let docsIndex: {
22
23
  chunks: [],
23
24
  };
24
25
 
26
+ /** Resolves when the initial index build completes */
27
+ let indexReady: Promise<void> | null = null;
28
+
25
29
  /**
26
30
  * Cosine similarity between two vectors
27
31
  */
@@ -76,22 +80,43 @@ export async function buildDocsIndex(force = false): Promise<void> {
76
80
  embedding: vectors[i],
77
81
  }));
78
82
  docsIndex.ready = true;
83
+ } catch (error) {
84
+ // Reset so the next call to ensureDocsIndex retries
85
+ indexReady = null;
86
+ throw error;
79
87
  } finally {
80
88
  docsIndex.building = false;
81
89
  }
82
90
  }
83
91
 
84
92
  /**
85
- * Ensure the docs index is ready
93
+ * Ensure the docs index is ready.
94
+ * On first call, triggers the build; subsequent calls wait for the same promise.
86
95
  */
87
96
  export async function ensureDocsIndex(force = false): Promise<void> {
88
97
  if (force) {
89
98
  docsIndex.ready = false;
90
99
  docsIndex.chunks = [];
100
+ indexReady = buildDocsIndex();
101
+ return indexReady;
102
+ }
103
+ if (!indexReady) {
104
+ indexReady = buildDocsIndex();
91
105
  }
92
- if (!docsIndex.ready) {
93
- await buildDocsIndex();
106
+ return indexReady;
107
+ }
108
+
109
+ // Eagerly start building the index on server startup (docs are static)
110
+ indexReady = buildDocsIndex();
111
+
112
+ /** Cached embeddings instance for search queries */
113
+ let cachedEmbeddings: ReturnType<typeof createEmbeddings> | null = null;
114
+
115
+ function getEmbeddings() {
116
+ if (!cachedEmbeddings) {
117
+ cachedEmbeddings = createEmbeddings(getLLMConfig());
94
118
  }
119
+ return cachedEmbeddings;
95
120
  }
96
121
 
97
122
  /**
@@ -103,9 +128,7 @@ export async function searchDocs(
103
128
  ): Promise<{ chunk: DocsChunk; score: number }[]> {
104
129
  await ensureDocsIndex();
105
130
 
106
- const config = getLLMConfig();
107
- const embeddings = createEmbeddings(config);
108
- const queryVector = await embeddings.embedQuery(query);
131
+ const queryVector = await getEmbeddings().embedQuery(query);
109
132
 
110
133
  const scored = docsIndex.chunks
111
134
  .map((c) => ({
@@ -1 +1 @@
1
- export declare const mcpToolsTemplate = "import path from \"node:path\";\nimport fs from \"node:fs/promises\";\nimport type {\n MCPToolDefinition,\n DocsResource,\n DocsChunk,\n GetDocParams,\n ListDocsParams,\n} from \"@/services/mcp/types\";\n\nconst PROJECT_ROOT = process.cwd();\nconst APP_DIR = path.join(PROJECT_ROOT, \"app\");\nconst VALID_EXT = new Set([\".ts\", \".tsx\", \".js\", \".jsx\"]);\n\n/**\n * Tool definitions for MCP - these describe the available tools\n */\nexport const DOCS_TOOLS: MCPToolDefinition[] = [\n {\n name: \"search_docs\",\n description:\n \"Search through the documentation content using semantic search. Returns relevant chunks of documentation based on the query.\",\n inputSchema: {\n type: \"object\",\n properties: {\n query: {\n type: \"string\",\n description: \"The search query to find relevant documentation\",\n },\n limit: {\n type: \"number\",\n description: \"Maximum number of results to return (default: 6)\",\n },\n },\n required: [\"query\"],\n },\n },\n {\n name: \"get_doc\",\n description:\n \"Get the full content of a specific documentation page by its path.\",\n inputSchema: {\n type: \"object\",\n properties: {\n path: {\n type: \"string\",\n description:\n \"The file path to the documentation page (e.g., 'app/getting-started/page.tsx')\",\n },\n },\n required: [\"path\"],\n },\n },\n {\n name: \"list_docs\",\n description:\n \"List all available documentation pages, optionally filtered by directory.\",\n inputSchema: {\n type: \"object\",\n properties: {\n directory: {\n type: \"string\",\n description:\n \"Optional directory to filter results (e.g., 'components')\",\n },\n },\n },\n },\n];\n\n/**\n * Recursively walk directory to find documentation files\n */\nasync function* walkDocs(dir: string): AsyncGenerator<string> {\n const entries = await fs.readdir(dir, { withFileTypes: true });\n for (const entry of entries) {\n const fullPath = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n if ([\"node_modules\", \".next\", \".git\", \"api\"].includes(entry.name)) {\n continue;\n }\n yield* walkDocs(fullPath);\n } else {\n const ext = path.extname(entry.name).toLowerCase();\n if (VALID_EXT.has(ext) && entry.name.startsWith(\"page.\")) {\n yield fullPath;\n }\n }\n }\n}\n\n/**\n * Extract content blocks from a file\n */\nfunction extractContentBlocks(fileText: string): string[] {\n const results: string[] = [];\n\n const tplRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*`((?:\\\\`|[^`])*)`\\s*;/g;\n let m: RegExpExecArray | null;\n while ((m = tplRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n const sglRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*'([^']*)'\\s*;/g;\n while ((m = sglRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n const dblRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*\"([^\"]*)\"\\s*;/g;\n while ((m = dblRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n return results;\n}\n\n/**\n * Get the title from markdown content\n */\nfunction extractTitle(content: string): string {\n const match = content.match(/^#\\s+(.+)$/m);\n return match ? match[1].trim() : \"Untitled\";\n}\n\n/**\n * List all documentation resources\n */\nexport async function listDocs(\n params?: ListDocsParams,\n): Promise<DocsResource[]> {\n const resources: DocsResource[] = [];\n const filterDir = params?.directory;\n\n for await (const filePath of walkDocs(APP_DIR)) {\n const relativePath = path.relative(PROJECT_ROOT, filePath);\n\n if (filterDir && !relativePath.includes(filterDir)) {\n continue;\n }\n\n try {\n const fileContent = await fs.readFile(filePath, \"utf8\");\n const blocks = extractContentBlocks(fileContent);\n const content = blocks.join(\"\\n\\n\");\n const title = extractTitle(content);\n const docPath = path.dirname(relativePath).replace(/^app\\/?/, \"\") || \"/\";\n\n resources.push({\n uri: `docs://${docPath}`,\n name: title,\n path: relativePath,\n content,\n });\n } catch (error) {\n console.warn(`Failed to read doc file: ${filePath}`, error);\n }\n }\n\n return resources;\n}\n\n/**\n * Get a specific documentation page\n */\nexport async function getDoc(\n params: GetDocParams,\n): Promise<DocsResource | null> {\n let targetPath = params.path;\n\n // Normalize path\n if (!targetPath.startsWith(\"app/\")) {\n targetPath = `app/${targetPath}`;\n }\n if (!targetPath.includes(\"page.\")) {\n targetPath = path.join(targetPath, \"page.tsx\");\n }\n\n const fullPath = path.join(PROJECT_ROOT, targetPath);\n\n // Prevent path traversal\n const resolvedPath = path.resolve(fullPath);\n if (!resolvedPath.startsWith(path.resolve(APP_DIR))) {\n return null;\n }\n\n try {\n const fileContent = await fs.readFile(fullPath, \"utf8\");\n const blocks = extractContentBlocks(fileContent);\n const content = blocks.join(\"\\n\\n\");\n const title = extractTitle(content);\n const docPath = path.dirname(targetPath).replace(/^app\\/?/, \"\") || \"/\";\n\n return {\n uri: `docs://${docPath}`,\n name: title,\n path: targetPath,\n content,\n };\n } catch (error) {\n console.warn(`Failed to read doc: ${targetPath}`, error);\n return null;\n }\n}\n\n/**\n * Chunk text for embeddings\n */\nexport function chunkText(\n text: string,\n chunkSize = 800,\n overlap = 100,\n): string[] {\n const chunks: string[] = [];\n let i = 0;\n while (i < text.length) {\n const end = Math.min(i + chunkSize, text.length);\n chunks.push(text.slice(i, end));\n if (end === text.length) break;\n i = end - overlap;\n if (i < 0) i = 0;\n }\n return chunks;\n}\n\n/**\n * Get all documentation chunks for indexing\n */\nexport async function getAllDocsChunks(): Promise<DocsChunk[]> {\n const allChunks: DocsChunk[] = [];\n const docs = await listDocs();\n\n for (const doc of docs) {\n const cleanContent = doc.content\n .replace(/\\r\\n/g, \"\\n\")\n .replace(/\\n{3,}/g, \"\\n\\n\")\n .slice(0, 200_000);\n\n const textChunks = chunkText(cleanContent);\n for (let i = 0; i < textChunks.length; i++) {\n allChunks.push({\n id: `${doc.path}:${i}`,\n text: textChunks[i],\n path: doc.path,\n uri: doc.uri,\n });\n }\n }\n\n return allChunks;\n}\n";
1
+ export declare const mcpToolsTemplate = "import path from \"node:path\";\nimport fs from \"node:fs/promises\";\nimport type {\n MCPToolDefinition,\n DocsResource,\n DocsChunk,\n GetDocParams,\n ListDocsParams,\n} from \"@/services/mcp/types\";\n\nconst PROJECT_ROOT = process.cwd();\nconst APP_DIR = path.join(PROJECT_ROOT, \"app\");\nconst VALID_EXT = new Set([\".ts\", \".tsx\", \".js\", \".jsx\"]);\n\n/**\n * Tool definitions for MCP - these describe the available tools\n */\nexport const DOCS_TOOLS: MCPToolDefinition[] = [\n {\n name: \"search_docs\",\n description:\n \"Search through the documentation content using semantic search. Returns relevant chunks of documentation based on the query.\",\n inputSchema: {\n type: \"object\",\n properties: {\n query: {\n type: \"string\",\n description: \"The search query to find relevant documentation\",\n },\n limit: {\n type: \"number\",\n description: \"Maximum number of results to return (default: 6)\",\n },\n },\n required: [\"query\"],\n },\n },\n {\n name: \"get_doc\",\n description:\n \"Get the full content of a specific documentation page by its path.\",\n inputSchema: {\n type: \"object\",\n properties: {\n path: {\n type: \"string\",\n description:\n \"The file path to the documentation page (e.g., 'app/getting-started/page.tsx')\",\n },\n },\n required: [\"path\"],\n },\n },\n {\n name: \"list_docs\",\n description:\n \"List all available documentation pages, optionally filtered by directory.\",\n inputSchema: {\n type: \"object\",\n properties: {\n directory: {\n type: \"string\",\n description:\n \"Optional directory to filter results (e.g., 'components')\",\n },\n },\n },\n },\n];\n\n/**\n * Recursively walk directory to find documentation files\n */\nasync function* walkDocs(dir: string): AsyncGenerator<string> {\n const entries = await fs.readdir(dir, { withFileTypes: true });\n for (const entry of entries) {\n const fullPath = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n if ([\"node_modules\", \".next\", \".git\", \"api\"].includes(entry.name)) {\n continue;\n }\n yield* walkDocs(fullPath);\n } else {\n const ext = path.extname(entry.name).toLowerCase();\n if (VALID_EXT.has(ext) && entry.name.startsWith(\"page.\")) {\n yield fullPath;\n }\n }\n }\n}\n\n/**\n * Extract content blocks from a file\n */\nfunction extractContentBlocks(fileText: string): string[] {\n const results: string[] = [];\n\n const tplRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*`((?:\\\\`|[^`])*)`\\s*;/g;\n let m: RegExpExecArray | null;\n while ((m = tplRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n const sglRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*'([^']*)'\\s*;/g;\n while ((m = sglRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n const dblRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*\"([^\"]*)\"\\s*;/g;\n while ((m = dblRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n return results;\n}\n\n/**\n * Get the title from markdown content\n */\nfunction extractTitle(content: string): string {\n const match = content.match(/^#\\s+(.+)$/m);\n return match ? match[1].trim() : \"Untitled\";\n}\n\n/**\n * List all documentation resources\n */\nexport async function listDocs(\n params?: ListDocsParams,\n): Promise<DocsResource[]> {\n const resources: DocsResource[] = [];\n const filterDir = params?.directory;\n\n for await (const filePath of walkDocs(APP_DIR)) {\n const relativePath = path.relative(PROJECT_ROOT, filePath);\n\n if (filterDir && !relativePath.includes(filterDir)) {\n continue;\n }\n\n try {\n const fileContent = await fs.readFile(filePath, \"utf8\");\n const blocks = extractContentBlocks(fileContent);\n const content = blocks.join(\"\\n\\n\");\n const title = extractTitle(content);\n const docPath = path.dirname(relativePath).replace(/^app\\/?/, \"\") || \"/\";\n\n resources.push({\n uri: `docs://${docPath}`,\n name: title,\n path: relativePath,\n content,\n });\n } catch (error) {\n console.warn(`Failed to read doc file: ${filePath}`, error);\n }\n }\n\n return resources;\n}\n\n/**\n * Get a specific documentation page\n */\nexport async function getDoc(\n params: GetDocParams,\n): Promise<DocsResource | null> {\n let targetPath = params.path;\n\n // Normalize path\n if (!targetPath.startsWith(\"app/\")) {\n targetPath = `app/${targetPath}`;\n }\n if (!targetPath.includes(\"page.\")) {\n targetPath = path.join(targetPath, \"page.tsx\");\n }\n\n const fullPath = path.join(PROJECT_ROOT, targetPath);\n\n // Prevent path traversal\n const resolvedPath = path.resolve(fullPath);\n if (!resolvedPath.startsWith(path.resolve(APP_DIR))) {\n return null;\n }\n\n try {\n const fileContent = await fs.readFile(fullPath, \"utf8\");\n const blocks = extractContentBlocks(fileContent);\n const content = blocks.join(\"\\n\\n\");\n const title = extractTitle(content);\n const docPath = path.dirname(targetPath).replace(/^app\\/?/, \"\") || \"/\";\n\n return {\n uri: `docs://${docPath}`,\n name: title,\n path: targetPath,\n content,\n };\n } catch (error) {\n console.warn(`Failed to read doc: ${targetPath}`, error);\n return null;\n }\n}\n\n/**\n * Chunk text for embeddings.\n * - chunkSize=800 chars balances granularity with embedding context window limits\n * - overlap=100 chars ensures continuity so searches don't miss content at chunk boundaries\n */\nexport function chunkText(\n text: string,\n chunkSize = 800,\n overlap = 100,\n): string[] {\n const chunks: string[] = [];\n let i = 0;\n while (i < text.length) {\n const end = Math.min(i + chunkSize, text.length);\n chunks.push(text.slice(i, end));\n if (end === text.length) break;\n i = end - overlap;\n if (i < 0) i = 0;\n }\n return chunks;\n}\n\n/**\n * Get all documentation chunks for indexing\n */\nexport async function getAllDocsChunks(): Promise<DocsChunk[]> {\n const allChunks: DocsChunk[] = [];\n const docs = await listDocs();\n\n for (const doc of docs) {\n const cleanContent = doc.content\n .replace(/\\r\\n/g, \"\\n\")\n .replace(/\\n{3,}/g, \"\\n\\n\")\n .slice(0, 200_000);\n\n const textChunks = chunkText(cleanContent);\n for (let i = 0; i < textChunks.length; i++) {\n allChunks.push({\n id: `${doc.path}:${i}`,\n text: textChunks[i],\n path: doc.path,\n uri: doc.uri,\n });\n }\n }\n\n return allChunks;\n}\n";
@@ -203,7 +203,9 @@ export async function getDoc(
203
203
  }
204
204
 
205
205
  /**
206
- * Chunk text for embeddings
206
+ * Chunk text for embeddings.
207
+ * - chunkSize=800 chars balances granularity with embedding context window limits
208
+ * - overlap=100 chars ensures continuity so searches don't miss content at chunk boundaries
207
209
  */
208
210
  export function chunkText(
209
211
  text: string,
@@ -1 +1 @@
1
- export declare const mcpTypesTemplate = "export interface MCPToolResult {\n content: string;\n metadata?: Record<string, unknown>;\n}\n\nexport interface DocsResource {\n uri: string;\n name: string;\n path: string;\n content: string;\n}\n\nexport interface DocsChunk {\n id: string;\n text: string;\n path: string;\n uri: string;\n}\n\nexport interface GetDocParams {\n path: string;\n}\n\nexport interface ListDocsParams {\n directory?: string;\n}\n\nexport type MCPToolName = \"search_docs\" | \"get_doc\" | \"list_docs\";\n\nexport interface MCPToolDefinition {\n name: MCPToolName;\n description: string;\n inputSchema: {\n type: \"object\";\n properties: Record<string, unknown>;\n required?: string[];\n };\n}\n";
1
+ export declare const mcpTypesTemplate = "export interface DocsResource {\n uri: string;\n name: string;\n path: string;\n content: string;\n}\n\nexport interface DocsChunk {\n id: string;\n text: string;\n path: string;\n uri: string;\n}\n\nexport interface GetDocParams {\n path: string;\n}\n\nexport interface ListDocsParams {\n directory?: string;\n}\n\nexport type MCPToolName = \"search_docs\" | \"get_doc\" | \"list_docs\";\n\nexport interface MCPToolDefinition {\n name: MCPToolName;\n description: string;\n inputSchema: {\n type: \"object\";\n properties: Record<string, unknown>;\n required?: string[];\n };\n}\n";
@@ -1,9 +1,4 @@
1
- export const mcpTypesTemplate = `export interface MCPToolResult {
2
- content: string;
3
- metadata?: Record<string, unknown>;
4
- }
5
-
6
- export interface DocsResource {
1
+ export const mcpTypesTemplate = `export interface DocsResource {
7
2
  uri: string;
8
3
  name: string;
9
4
  path: string;
@@ -1,7 +1,7 @@
1
1
  export const tsconfigTemplate = JSON.stringify({
2
2
  compilerOptions: {
3
- target: "es5",
4
- lib: ["dom", "dom.iterable", "es6"],
3
+ target: "es2020",
4
+ lib: ["dom", "dom.iterable", "esnext"],
5
5
  allowJs: true,
6
6
  skipLibCheck: true,
7
7
  strict: true,
@@ -0,0 +1 @@
1
+ export declare const configTemplate = "import { z } from \"zod\";\nimport configData from \"@/config.json\";\n\nconst configSchema = z.object({\n name: z.string().optional(),\n description: z.string().optional(),\n icon: z.string().optional(),\n preview: z.string().optional(),\n});\n\nexport type Config = z.infer<typeof configSchema>;\n\nexport const config: Config = configSchema.parse(configData);\n";
@@ -0,0 +1,14 @@
1
+ export const configTemplate = `import { z } from "zod";
2
+ import configData from "@/config.json";
3
+
4
+ const configSchema = z.object({
5
+ name: z.string().optional(),
6
+ description: z.string().optional(),
7
+ icon: z.string().optional(),
8
+ preview: z.string().optional(),
9
+ });
10
+
11
+ export type Config = z.infer<typeof configSchema>;
12
+
13
+ export const config: Config = configSchema.parse(configData);
14
+ `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doccupine",
3
- "version": "0.0.52",
3
+ "version": "0.0.53",
4
4
  "description": "Document management system that allows you to store, organize, and share your documentation with ease. AI-ready.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {