doccupine 0.0.68 → 0.0.70

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
@@ -11,6 +11,8 @@ import { layoutTemplate } from "./lib/layout.js";
11
11
  import { ConfigManager } from "./lib/config-manager.js";
12
12
  import { findAvailablePort, generateSlug, getFullSlug, escapeTemplateContent, } from "./lib/utils.js";
13
13
  import { generateMetadataBlock, generateRuntimeOnlyMetadataBlock, } from "./lib/metadata.js";
14
+ import { nextConfigTemplate } from "./templates/next.config.js";
15
+ import { proxyTemplate } from "./templates/proxy.js";
14
16
  export { generateSlug, getFullSlug, escapeTemplateContent, } from "./lib/utils.js";
15
17
  const __filename = fileURLToPath(import.meta.url);
16
18
  const __dirname = path.dirname(__filename);
@@ -24,6 +26,7 @@ class MDXToNextJSGenerator {
24
26
  configWatcher = null;
25
27
  fontWatcher = null;
26
28
  publicWatcher = null;
29
+ analyticsWatcher = null;
27
30
  configFiles = [
28
31
  "theme.json",
29
32
  "navigation.json",
@@ -32,6 +35,8 @@ class MDXToNextJSGenerator {
32
35
  "sections.json",
33
36
  ];
34
37
  fontConfigFile = "fonts.json";
38
+ analyticsConfigFile = "analytics.json";
39
+ analyticsConfig = null;
35
40
  sectionsConfig = null;
36
41
  /** Guards against recursive reprocessing when maybeUpdateSections() triggers processAllMDXFiles() */
37
42
  isReprocessing = false;
@@ -45,13 +50,18 @@ class MDXToNextJSGenerator {
45
50
  await fs.ensureDir(this.watchDir);
46
51
  await fs.ensureDir(this.outputDir);
47
52
  this.sectionsConfig = await this.resolveSections();
53
+ this.analyticsConfig = await this.loadAnalyticsConfig();
48
54
  if (this.sectionsConfig) {
49
55
  console.log(chalk.blue(`📑 Found ${this.sectionsConfig.length} section(s): ${this.sectionsConfig.map((s) => s.label).join(", ")}`));
50
56
  }
57
+ if (this.analyticsConfig) {
58
+ console.log(chalk.blue(`📊 Analytics enabled: ${this.analyticsConfig.provider}`));
59
+ }
51
60
  await this.createNextJSStructure();
52
61
  await this.createStartingDocs();
53
62
  await this.copyCustomConfigFiles();
54
63
  await this.copyFontConfig();
64
+ await this.copyAnalyticsConfig();
55
65
  await this.copyPublicFiles();
56
66
  await this.processAllMDXFiles();
57
67
  await this.generateSectionIndexPages();
@@ -63,6 +73,9 @@ class MDXToNextJSGenerator {
63
73
  async createNextJSStructure() {
64
74
  const structure = {
65
75
  ...appStructure,
76
+ "next.config.ts": nextConfigTemplate(this.analyticsConfig),
77
+ "proxy.ts": proxyTemplate(this.analyticsConfig),
78
+ "analytics.json": `{}\n`,
66
79
  "config.json": `{}\n`,
67
80
  "links.json": `[]\n`,
68
81
  "navigation.json": `[]\n`,
@@ -127,6 +140,34 @@ class MDXToNextJSGenerator {
127
140
  }
128
141
  return null;
129
142
  }
143
+ async loadAnalyticsConfig() {
144
+ const analyticsPath = path.join(this.rootDir, this.analyticsConfigFile);
145
+ try {
146
+ if (await fs.pathExists(analyticsPath)) {
147
+ const content = await fs.readFile(analyticsPath, "utf8");
148
+ const parsed = JSON.parse(content);
149
+ if (parsed?.provider === "posthog" && parsed.posthog?.key) {
150
+ return parsed;
151
+ }
152
+ }
153
+ }
154
+ catch (error) {
155
+ console.warn(chalk.yellow(`⚠️ Error reading ${this.analyticsConfigFile}`), error);
156
+ }
157
+ return null;
158
+ }
159
+ async copyAnalyticsConfig() {
160
+ console.log(chalk.blue(`🔍 Checking for analytics configuration...`));
161
+ const sourcePath = path.join(this.rootDir, this.analyticsConfigFile);
162
+ const destPath = path.join(this.outputDir, this.analyticsConfigFile);
163
+ if (await fs.pathExists(sourcePath)) {
164
+ await fs.copy(sourcePath, destPath);
165
+ console.log(chalk.green(` ✓ Copied ${this.analyticsConfigFile} to Next.js app`));
166
+ }
167
+ else {
168
+ console.log(chalk.gray(` ✗ ${this.analyticsConfigFile} not found, skipping`));
169
+ }
170
+ }
130
171
  async loadSectionsConfig() {
131
172
  const sectionsPath = path.join(this.rootDir, "sections.json");
132
173
  try {
@@ -337,6 +378,44 @@ class MDXToNextJSGenerator {
337
378
  console.error(chalk.red(`❌ Error removing font configuration:`), error);
338
379
  }
339
380
  }
381
+ async handleAnalyticsConfigChange() {
382
+ console.log(chalk.cyan(`📊 Analytics configuration changed`));
383
+ const sourcePath = path.join(this.rootDir, this.analyticsConfigFile);
384
+ const destPath = path.join(this.outputDir, this.analyticsConfigFile);
385
+ try {
386
+ await fs.copy(sourcePath, destPath);
387
+ console.log(chalk.green(`📋 Updated ${this.analyticsConfigFile} in Next.js app`));
388
+ this.analyticsConfig = await this.loadAnalyticsConfig();
389
+ // Regenerate dynamic templates that depend on analytics config
390
+ await fs.writeFile(path.join(this.outputDir, "next.config.ts"), nextConfigTemplate(this.analyticsConfig), "utf8");
391
+ await fs.writeFile(path.join(this.outputDir, "proxy.ts"), proxyTemplate(this.analyticsConfig), "utf8");
392
+ await this.updateRootLayout();
393
+ console.log(chalk.green(`✅ Analytics configuration updated`));
394
+ if (this.analyticsConfig) {
395
+ console.log(chalk.yellow(`⚠️ Next.js dev server restart may be required for analytics proxy changes`));
396
+ }
397
+ }
398
+ catch (error) {
399
+ console.error(chalk.red(`❌ Error updating analytics configuration:`), error);
400
+ }
401
+ }
402
+ async handleAnalyticsConfigDelete() {
403
+ console.log(chalk.red(`🗑️ Analytics configuration deleted`));
404
+ const destPath = path.join(this.outputDir, this.analyticsConfigFile);
405
+ try {
406
+ // Write empty analytics.json so runtime imports don't break
407
+ await fs.writeFile(destPath, `{}\n`, "utf8");
408
+ this.analyticsConfig = null;
409
+ // Regenerate dynamic templates without analytics
410
+ await fs.writeFile(path.join(this.outputDir, "next.config.ts"), nextConfigTemplate(null), "utf8");
411
+ await fs.writeFile(path.join(this.outputDir, "proxy.ts"), proxyTemplate(null), "utf8");
412
+ await this.updateRootLayout();
413
+ console.log(chalk.green(`✅ Analytics removed from Next.js app`));
414
+ }
415
+ catch (error) {
416
+ console.error(chalk.red(`❌ Error removing analytics configuration:`), error);
417
+ }
418
+ }
340
419
  async copyPublicFiles() {
341
420
  const publicDir = path.join(this.rootDir, "public");
342
421
  const destDir = path.join(this.outputDir, "public");
@@ -452,6 +531,25 @@ class MDXToNextJSGenerator {
452
531
  .on("error", (error) => {
453
532
  console.error(chalk.red("❌ Font watcher error:"), error);
454
533
  });
534
+ const analyticsPath = path.join(this.rootDir, this.analyticsConfigFile);
535
+ this.analyticsWatcher = chokidar.watch(analyticsPath, {
536
+ persistent: true,
537
+ ignoreInitial: true,
538
+ });
539
+ this.analyticsWatcher
540
+ .on("add", () => {
541
+ console.log(chalk.cyan(`📊 Analytics configuration added`));
542
+ this.handleAnalyticsConfigChange();
543
+ })
544
+ .on("change", () => {
545
+ this.handleAnalyticsConfigChange();
546
+ })
547
+ .on("unlink", () => {
548
+ this.handleAnalyticsConfigDelete();
549
+ })
550
+ .on("error", (error) => {
551
+ console.error(chalk.red("❌ Analytics watcher error:"), error);
552
+ });
455
553
  const publicDir = path.join(this.rootDir, "public");
456
554
  if (await fs.pathExists(publicDir)) {
457
555
  this.publicWatcher = chokidar.watch(publicDir, {
@@ -582,7 +680,8 @@ class MDXToNextJSGenerator {
582
680
  async generateRootLayout() {
583
681
  const pages = await this.buildAllPagesMeta();
584
682
  const fontConfig = await this.loadFontConfig();
585
- return layoutTemplate(pages, fontConfig, this.sectionsConfig);
683
+ const analyticsEnabled = this.analyticsConfig !== null;
684
+ return layoutTemplate(pages, fontConfig, this.sectionsConfig, analyticsEnabled);
586
685
  }
587
686
  async generateSectionIndexPages() {
588
687
  if (!this.sectionsConfig || this.sectionsConfig.length === 0)
@@ -732,6 +831,10 @@ export default function Page() {
732
831
  await this.fontWatcher.close();
733
832
  console.log(chalk.yellow("👋 Stopped watching for font config changes"));
734
833
  }
834
+ if (this.analyticsWatcher) {
835
+ await this.analyticsWatcher.close();
836
+ console.log(chalk.yellow("👋 Stopped watching for analytics config changes"));
837
+ }
735
838
  if (this.publicWatcher) {
736
839
  await this.publicWatcher.close();
737
840
  console.log(chalk.yellow("👋 Stopped watching for public directory changes"));
@@ -26,5 +26,5 @@ interface FontConfig {
26
26
  src?: LocalFontSrc[];
27
27
  };
28
28
  }
29
- export declare const layoutTemplate: (pages: PageData[], fontConfig: FontConfig | null, sectionsConfig?: SectionConfig[] | null) => string;
29
+ export declare const layoutTemplate: (pages: PageData[], fontConfig: FontConfig | null, sectionsConfig?: SectionConfig[] | null, analyticsEnabled?: boolean) => string;
30
30
  export {};
@@ -35,8 +35,16 @@ function getLocalFontSrc(fc) {
35
35
  return `"${fc.localFonts}"`;
36
36
  return JSON.stringify(fc.localFonts?.src, null, 2).replace(/"([^"]+)":/g, "$1:");
37
37
  }
38
- export const layoutTemplate = (pages, fontConfig, sectionsConfig = null) => {
38
+ export const layoutTemplate = (pages, fontConfig, sectionsConfig = null, analyticsEnabled = false) => {
39
39
  const hasSections = sectionsConfig !== null && sectionsConfig.length > 0;
40
+ // Extra indent when PostHogProvider wraps the inner content
41
+ const a = analyticsEnabled ? " " : "";
42
+ // ChtProvider line wraps at wider indent
43
+ const chtOpen = analyticsEnabled
44
+ ? `<ChtProvider
45
+ ${a} isChatActive={process.env.LLM_PROVIDER ? true : false}
46
+ ${a} >`
47
+ : `<ChtProvider isChatActive={process.env.LLM_PROVIDER ? true : false}>`;
40
48
  return `import type { Metadata } from "next";
41
49
  ${isGoogleFont(fontConfig) ? `import { ${fontConfig.googleFont.fontName} } from "next/font/google";` : isLocalFont(fontConfig) ? 'import localFont from "next/font/local";' : 'import { Inter } from "next/font/google";'}
42
50
  import dynamic from "next/dynamic";
@@ -70,7 +78,7 @@ ${hasSections
70
78
  ? `import { SectionBar } from "@/components/layout/SectionBar";
71
79
  import { SectionNavProvider } from "@/components/SectionNavProvider";
72
80
  `
73
- : ""}const Chat = dynamic(() => import("@/components/Chat").then((mod) => mod.Chat));
81
+ : ""}${analyticsEnabled ? `import { PostHogProvider } from "@/components/PostHogProvider";\n` : ""}const Chat = dynamic(() => import("@/components/Chat").then((mod) => mod.Chat));
74
82
 
75
83
  ${isGoogleFont(fontConfig)
76
84
  ? `const font = ${fontConfig.googleFont.fontName}({ ${[fontConfig.googleFont.subsets?.length ? `subsets: ${JSON.stringify(fontConfig.googleFont.subsets)}` : "", fontConfig.googleFont.weight?.length ? `weight: ${Array.isArray(fontConfig.googleFont.weight) ? JSON.stringify(fontConfig.googleFont.weight) : `"${fontConfig.googleFont.weight}"`}` : ""].filter(Boolean).join(", ")} });`
@@ -123,24 +131,24 @@ ${hasSections
123
131
  </head>
124
132
  <body className={font.className}>
125
133
  <StyledComponentsRegistry>
126
- <CherryThemeProvider theme={theme} themeDark={themeDark}>
127
- <ChtProvider isChatActive={process.env.LLM_PROVIDER ? true : false}>
128
- <Header>
129
- <SectionBar sections={doccupineSections} />
130
- </Header>
131
- {process.env.LLM_PROVIDER && <Chat />}
132
- <DocsWrapper>
133
- <SectionNavProvider
134
- sections={doccupineSections}
135
- allPages={pages}
136
- hideBranding={hideBranding}
137
- >
138
- {children}
139
- </SectionNavProvider>
140
- </DocsWrapper>
141
- </ChtProvider>
142
- </CherryThemeProvider>
143
- </StyledComponentsRegistry>
134
+ ${analyticsEnabled ? " <PostHogProvider>\n" : ""}${a} <CherryThemeProvider theme={theme} themeDark={themeDark}>
135
+ ${a} ${chtOpen}
136
+ ${a} <Header>
137
+ ${a} <SectionBar sections={doccupineSections} />
138
+ ${a} </Header>
139
+ ${a} {process.env.LLM_PROVIDER && <Chat />}
140
+ ${a} <DocsWrapper>
141
+ ${a} <SectionNavProvider
142
+ ${a} sections={doccupineSections}
143
+ ${a} allPages={pages}
144
+ ${a} hideBranding={hideBranding}
145
+ ${a} >
146
+ ${a} {children}
147
+ ${a} </SectionNavProvider>
148
+ ${a} </DocsWrapper>
149
+ ${a} </ChtProvider>
150
+ ${a} </CherryThemeProvider>
151
+ ${analyticsEnabled ? " </PostHogProvider>\n" : ""} </StyledComponentsRegistry>
144
152
  </body>
145
153
  </html>
146
154
  );
@@ -182,24 +190,24 @@ ${hasSections
182
190
  </head>
183
191
  <body className={font.className}>
184
192
  <StyledComponentsRegistry>
185
- <CherryThemeProvider theme={theme} themeDark={themeDark}>
186
- <ChtProvider isChatActive={process.env.LLM_PROVIDER ? true : false}>
187
- <Header />
188
- {process.env.LLM_PROVIDER && <Chat />}
189
- <SectionBarProvider hasSectionBar={false}>
190
- <DocsWrapper>
191
- <SideBar result={result.length ? result : defaultResults} />
192
- {children}
193
- <DocsNavigation
194
- result={result.length ? result : defaultResults}
195
- />
196
- <StaticLinks />
197
- <Footer hideBranding={hideBranding} />
198
- </DocsWrapper>
199
- </SectionBarProvider>
200
- </ChtProvider>
201
- </CherryThemeProvider>
202
- </StyledComponentsRegistry>
193
+ ${analyticsEnabled ? " <PostHogProvider>\n" : ""}${a} <CherryThemeProvider theme={theme} themeDark={themeDark}>
194
+ ${a} ${chtOpen}
195
+ ${a} <Header />
196
+ ${a} {process.env.LLM_PROVIDER && <Chat />}
197
+ ${a} <SectionBarProvider hasSectionBar={false}>
198
+ ${a} <DocsWrapper>
199
+ ${a} <SideBar result={result.length ? result : defaultResults} />
200
+ ${a} {children}
201
+ ${a} <DocsNavigation
202
+ ${a} result={result.length ? result : defaultResults}
203
+ ${a} />
204
+ ${a} <StaticLinks />
205
+ ${a} <Footer hideBranding={hideBranding} />
206
+ ${a} </DocsWrapper>
207
+ ${a} </SectionBarProvider>
208
+ ${a} </ChtProvider>
209
+ ${a} </CherryThemeProvider>
210
+ ${analyticsEnabled ? " </PostHogProvider>\n" : ""} </StyledComponentsRegistry>
203
211
  </body>
204
212
  </html>
205
213
  );
@@ -1,11 +1,9 @@
1
1
  import { envExampleTemplate } from "../templates/env.example.js";
2
2
  import { gitignoreTemplate } from "../templates/gitignore.js";
3
3
  import { eslintConfigTemplate } from "../templates/eslint.config.js";
4
- import { nextConfigTemplate } from "../templates/next.config.js";
5
4
  import { packageJsonTemplate } from "../templates/package.js";
6
5
  import { prettierrcTemplate } from "../templates/prettierrc.js";
7
6
  import { prettierignoreTemplate } from "../templates/prettierignore.js";
8
- import { proxyTemplate } from "../templates/proxy.js";
9
7
  import { tsconfigTemplate } from "../templates/tsconfig.js";
10
8
  import { mcpRoutesTemplate } from "../templates/app/api/mcp/route.js";
11
9
  import { ragRoutesTemplate } from "../templates/app/api/rag/route.js";
@@ -19,6 +17,7 @@ import { docsTemplate } from "../templates/components/Docs.js";
19
17
  import { docsSideBarTemplate } from "../templates/components/DocsSideBar.js";
20
18
  import { mdxComponentsTemplate } from "../templates/components/MDXComponents.js";
21
19
  import { sectionNavProviderTemplate } from "../templates/components/SectionNavProvider.js";
20
+ import { postHogProviderTemplate } from "../templates/components/PostHogProvider.js";
22
21
  import { sideBarTemplate } from "../templates/components/SideBar.js";
23
22
  import { sectionBarTemplate } from "../templates/components/layout/SectionBar.js";
24
23
  import { accordionTemplate } from "../templates/components/layout/Accordion.js";
@@ -54,6 +53,7 @@ import { llmConfigTemplate } from "../templates/services/llm/config.js";
54
53
  import { llmFactoryTemplate } from "../templates/services/llm/factory.js";
55
54
  import { llmIndexTemplate } from "../templates/services/llm/index.js";
56
55
  import { llmTypesTemplate } from "../templates/services/llm/types.js";
56
+ import { posthogServerTemplate } from "../templates/lib/posthog.js";
57
57
  import { styledDTemplate } from "../templates/types/styled.js";
58
58
  import { orderNavItemsTemplate } from "../templates/utils/orderNavItems.js";
59
59
  import { rateLimitTemplate } from "../templates/utils/rateLimit.js";
@@ -61,6 +61,7 @@ import { brandingTemplate } from "../templates/utils/branding.js";
61
61
  import { configTemplate } from "../templates/utils/config.js";
62
62
  import { accordionMdxTemplate } from "../templates/mdx/accordion.mdx.js";
63
63
  import { aiAssistantMdxTemplate } from "../templates/mdx/ai-assistant.mdx.js";
64
+ import { analyticsMdxTemplate } from "../templates/mdx/analytics.mdx.js";
64
65
  import { buttonsMdxTemplate } from "../templates/mdx/buttons.mdx.js";
65
66
  import { calloutsMdxTemplate } from "../templates/mdx/callouts.mdx.js";
66
67
  import { cardsMdxTemplate } from "../templates/mdx/cards.mdx.js";
@@ -93,7 +94,6 @@ import { platformCreatingAProjectMdxTemplate } from "../templates/mdx/platform/c
93
94
  import { platformSiteSettingsMdxTemplate } from "../templates/mdx/platform/site-settings.mdx.js";
94
95
  import { platformThemeSettingsMdxTemplate } from "../templates/mdx/platform/theme-settings.mdx.js";
95
96
  import { platformNavigationSettingsMdxTemplate } from "../templates/mdx/platform/navigation-settings.mdx.js";
96
- import { platformSectionsSettingsMdxTemplate } from "../templates/mdx/platform/sections-settings.mdx.js";
97
97
  import { platformFontsSettingsMdxTemplate } from "../templates/mdx/platform/fonts-settings.mdx.js";
98
98
  import { platformExternalLinksMdxTemplate } from "../templates/mdx/platform/external-links.mdx.js";
99
99
  import { platformAiAssistantMdxTemplate } from "../templates/mdx/platform/ai-assistant.mdx.js";
@@ -108,9 +108,7 @@ export const appStructure = {
108
108
  ".prettierrc": prettierrcTemplate,
109
109
  ".prettierignore": prettierignoreTemplate,
110
110
  "eslint.config.mjs": eslintConfigTemplate,
111
- "next.config.ts": nextConfigTemplate,
112
111
  "package.json": packageJsonTemplate,
113
- "proxy.ts": proxyTemplate,
114
112
  "tsconfig.json": tsconfigTemplate,
115
113
  "app/not-found.tsx": notFoundTemplate,
116
114
  "app/theme.ts": themeTemplate,
@@ -126,6 +124,7 @@ export const appStructure = {
126
124
  "services/llm/index.ts": llmIndexTemplate,
127
125
  "services/llm/types.ts": llmTypesTemplate,
128
126
  "types/styled.d.ts": styledDTemplate,
127
+ "lib/posthog.ts": posthogServerTemplate,
129
128
  "utils/branding.ts": brandingTemplate,
130
129
  "utils/orderNavItems.ts": orderNavItemsTemplate,
131
130
  "utils/rateLimit.ts": rateLimitTemplate,
@@ -137,6 +136,7 @@ export const appStructure = {
137
136
  "components/DocsSideBar.tsx": docsSideBarTemplate,
138
137
  "components/MDXComponents.tsx": mdxComponentsTemplate,
139
138
  "components/SectionNavProvider.tsx": sectionNavProviderTemplate,
139
+ "components/PostHogProvider.tsx": postHogProviderTemplate,
140
140
  "components/SideBar.tsx": sideBarTemplate,
141
141
  "components/layout/Accordion.tsx": accordionTemplate,
142
142
  "components/layout/ActionBar.tsx": actionBarTemplate,
@@ -168,6 +168,7 @@ export const appStructure = {
168
168
  export const startingDocsStructure = {
169
169
  "accordion.mdx": accordionMdxTemplate,
170
170
  "ai-assistant.mdx": aiAssistantMdxTemplate,
171
+ "analytics.mdx": analyticsMdxTemplate,
171
172
  "buttons.mdx": buttonsMdxTemplate,
172
173
  "callouts.mdx": calloutsMdxTemplate,
173
174
  "cards.mdx": cardsMdxTemplate,
@@ -200,7 +201,6 @@ export const startingDocsStructure = {
200
201
  "platform/site-settings.mdx": platformSiteSettingsMdxTemplate,
201
202
  "platform/theme-settings.mdx": platformThemeSettingsMdxTemplate,
202
203
  "platform/navigation-settings.mdx": platformNavigationSettingsMdxTemplate,
203
- "platform/sections-settings.mdx": platformSectionsSettingsMdxTemplate,
204
204
  "platform/fonts-settings.mdx": platformFontsSettingsMdxTemplate,
205
205
  "platform/external-links.mdx": platformExternalLinksMdxTemplate,
206
206
  "platform/ai-assistant.mdx": platformAiAssistantMdxTemplate,
@@ -28,3 +28,11 @@ export interface DoccupineConfig {
28
28
  export interface FontConfig {
29
29
  [key: string]: any;
30
30
  }
31
+ export interface PostHogConfig {
32
+ key: string;
33
+ host?: string;
34
+ }
35
+ export interface AnalyticsConfig {
36
+ provider: "posthog";
37
+ posthog: PostHogConfig;
38
+ }
@@ -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, RotateCcw, Sparkles, X } from \"lucide-react\";\nimport remarkGfm from \"remark-gfm\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport { MDXRemote, MDXRemoteSerializeResult } from \"next-mdx-remote\";\nimport { serialize } from \"next-mdx-remote/serialize\";\nimport Link from \"next/link\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { useLockBodyScroll } from \"@/components/LockBodyScroll\";\nimport { useMDXComponents as getMDXComponents } from \"@/components/MDXComponents\";\nimport {\n styledAnchor,\n styledTable,\n stylesLists,\n StyledSmallButton,\n interactiveStyles,\n} from \"@/components/layout/SharedStyled\";\n\nconst mdxComponents = getMDXComponents({});\n\nconst styledText = css<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.text.xs};\n line-height: ${({ theme }) => theme.lineHeights.text.xs};\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: ${({ theme }) => theme.lineHeights.small.lg};\n }\n`;\n\nconst StyledChat = styled.div<{ theme: Theme; $isVisible: boolean }>`\n margin: 0;\n position: fixed;\n top: 0;\n right: 0;\n width: 100%;\n height: calc(100dvh - 90px);\n overflow-y: scroll;\n overflow-x: hidden;\n z-index: 1000;\n padding: 0 20px;\n transition: all 0.3s ease;\n transform: translateX(0);\n background: ${({ theme }) => theme.colors.light};\n -webkit-overflow-scrolling: touch;\n opacity: 1;\n\n &::-webkit-scrollbar {\n display: none;\n }\n\n ${({ $isVisible }) =>\n !$isVisible &&\n css`\n transform: translateX(100%);\n opacity: 0;\n `}\n\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n`;\n\nconst loadingAnimation = keyframes`\n 0% {\n transform: rotate(0deg);\n }\n 100% {\n transform: rotate(360deg);\n }\n`;\n\nconst rotateGradient = keyframes`\n 0% {\n --gradient-angle: 0deg;\n }\n 100% {\n --gradient-angle: 360deg;\n }\n`;\n\nconst pulseGlow = keyframes`\n 0%, 100% {\n opacity: 0.5;\n filter: blur(16px);\n }\n 50% {\n opacity: 1;\n filter: blur(22px);\n }\n`;\n\nconst sparkleFloat = keyframes`\n 0%, 100% {\n opacity: 0;\n transform: translateY(0) scale(0);\n }\n 50% {\n opacity: 0.9;\n transform: translateY(-20px) scale(1);\n }\n`;\n\nconst shimmer = keyframes`\n 0% {\n background-position: 0% center;\n }\n 50% {\n background-position: 100% center;\n }\n 100% {\n background-position: 0% center;\n }\n`;\n\nconst StyledRainbowInputWrapper = styled.div<{\n theme: Theme;\n $isActive: boolean;\n}>`\n @property --gradient-angle {\n syntax: \"<angle>\";\n initial-value: 0deg;\n inherits: false;\n }\n\n position: relative;\n flex: 1;\n\n &::before {\n content: \"\";\n position: absolute;\n inset: -2px;\n border-radius: 14px;\n background: conic-gradient(\n from var(--gradient-angle),\n #cc5555,\n #d9a745,\n #3ab0cc,\n #cc7fc2,\n #4380cc,\n #4c1fa3,\n #cc5555\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation: ${rotateGradient} 3s linear infinite;\n z-index: 0;\n }\n\n &::after {\n content: \"\";\n position: absolute;\n inset: -10px;\n border-radius: 20px;\n background: conic-gradient(\n from var(--gradient-angle),\n ${rgba(\"#ff6b6b\", 0.4)},\n ${rgba(\"#feca57\", 0.4)},\n ${rgba(\"#48dbfb\", 0.4)},\n ${rgba(\"#ff9ff3\", 0.4)},\n ${rgba(\"#54a0ff\", 0.4)},\n ${rgba(\"#5f27cd\", 0.4)},\n ${rgba(\"#ff6b6b\", 0.4)}\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation:\n ${rotateGradient} 3s linear infinite,\n ${pulseGlow} 2s ease-in-out infinite;\n z-index: -1;\n pointer-events: none;\n }\n\n &:hover::before,\n &:focus-within::before {\n opacity: 1;\n }\n\n &:hover::after,\n &:focus-within::after {\n opacity: 1;\n }\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n &::before {\n opacity: 1;\n }\n &::after {\n opacity: 1;\n }\n `}\n`;\n\nconst StyledSparkleContainer = styled.div<{ $isActive: boolean }>`\n position: absolute;\n inset: -30px;\n pointer-events: none;\n overflow: hidden;\n border-radius: 30px;\n z-index: -2;\n opacity: 0;\n transition: opacity 0.4s ease;\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n opacity: 1;\n `}\n`;\n\nconst StyledSparkle = styled.div<{\n $color: string;\n $left: number;\n $top: number;\n $delay: number;\n}>`\n position: absolute;\n width: 4px;\n height: 4px;\n border-radius: 50%;\n background: ${({ $color }) => $color};\n box-shadow: 0 0 6px ${({ $color }) => $color};\n left: ${({ $left }) => $left}%;\n top: ${({ $top }) => $top}%;\n animation: ${sparkleFloat} 2s ease-in-out infinite;\n animation-delay: ${({ $delay }) => $delay}s;\n`;\n\nconst StyledRainbowInput = styled.input<{ theme: Theme }>`\n position: relative;\n z-index: 1;\n width: 100%;\n background: ${({ theme }) => theme.colors.light};\n border: 1px solid ${({ theme }) => theme.colors.grayLight};\n border-radius: 12px;\n padding: 14px 18px;\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n font-family: inherit;\n color: ${({ theme }) => theme.colors.dark};\n outline: none;\n transition:\n border-color 0.3s ease,\n box-shadow 0.3s ease;\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n }\n\n &::placeholder {\n color: ${({ theme }) => rgba(theme.colors.dark, 0.4)};\n transition: color 0.3s ease;\n }\n\n &:focus::placeholder {\n color: ${({ theme }) => rgba(theme.colors.dark, 0.6)};\n }\n\n &:focus {\n border-color: transparent;\n }\n`;\n\nconst StyledRainbowButton = styled(Button)<{\n theme: Theme;\n $hasContent: boolean;\n}>`\n padding-top: 10px;\n padding-bottom: 10px;\n position: relative;\n overflow: hidden;\n transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n\n &::before {\n content: \"\";\n position: absolute;\n inset: 0;\n background: linear-gradient(\n 135deg,\n #ff6b6b,\n #feca57,\n #48dbfb,\n #ff9ff3,\n #54a0ff\n );\n background-size: 300% 300%;\n opacity: 0;\n transition: opacity 0.3s ease;\n z-index: 0;\n animation: ${shimmer} 3s linear infinite;\n width: 200%;\n }\n\n ${({ $hasContent }) =>\n $hasContent &&\n css`\n &::before {\n opacity: 1;\n }\n `}\n\n &:hover::before {\n opacity: 1;\n }\n\n &:hover {\n transform: scale(1.05);\n }\n\n &:active {\n transform: scale(0.95);\n }\n\n & svg {\n position: relative;\n z-index: 1;\n transition: transform 0.3s ease;\n }\n\n &:disabled,\n &:disabled:hover {\n background: ${({ theme }) => theme.colors.primaryDark};\n transform: none;\n box-shadow: none;\n\n &::before {\n opacity: 0;\n }\n }\n`;\n\nconst StyledChatForm = styled.form<{ theme: Theme; $isVisible: boolean }>`\n display: flex;\n gap: 10px;\n justify-content: center;\n align-items: center;\n background: ${({ theme }) => theme.colors.light};\n padding: 20px;\n position: fixed;\n bottom: 0;\n right: 0;\n z-index: 1000;\n width: 100%;\n border-top: solid 1px ${({ theme }) => theme.colors.grayLight};\n transition: all 0.3s ease;\n transform: translateX(100%);\n opacity: 0;\n\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n\n ${({ $isVisible }) =>\n $isVisible &&\n css`\n opacity: 1;\n transform: translateX(0);\n `}\n\n & .loading {\n animation: ${loadingAnimation} 1s linear infinite;\n }\n`;\n\nconst StyledGlowSmallButton = styled(StyledSmallButton)<{\n theme: Theme;\n $hasContent: boolean;\n}>`\n @property --gradient-angle {\n syntax: \"<angle>\";\n initial-value: 0deg;\n inherits: false;\n }\n\n position: relative;\n isolation: isolate;\n margin-right: 0;\n background: ${({ theme }) => theme.colors.light};\n padding: 0;\n\n &::before {\n content: \"\";\n inset: -2px;\n border-radius: 8px;\n background: conic-gradient(\n from var(--gradient-angle),\n #cc5555,\n #d9a745,\n #3ab0cc,\n #cc7fc2,\n #4380cc,\n #4c1fa3,\n #cc5555\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation: ${rotateGradient} 3s linear infinite;\n z-index: -1;\n position: absolute;\n top: -2px;\n left: -2px;\n width: calc(100% + 4px);\n height: calc(100% + 4px);\n }\n\n &::after {\n content: \"\";\n position: absolute;\n inset: -8px;\n border-radius: 14px;\n background: conic-gradient(\n from var(--gradient-angle),\n ${rgba(\"#ff6b6b\", 0.4)},\n ${rgba(\"#feca57\", 0.4)},\n ${rgba(\"#48dbfb\", 0.4)},\n ${rgba(\"#ff9ff3\", 0.4)},\n ${rgba(\"#54a0ff\", 0.4)},\n ${rgba(\"#5f27cd\", 0.4)},\n ${rgba(\"#ff6b6b\", 0.4)}\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation:\n ${rotateGradient} 3s linear infinite,\n ${pulseGlow} 2s ease-in-out infinite;\n z-index: -2;\n pointer-events: none;\n }\n\n &:hover::before,\n &:hover::after {\n opacity: 1;\n }\n\n & span {\n padding: 6px 8px;\n display: flex;\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n gap: 6px;\n }\n\n ${({ $hasContent }) =>\n $hasContent &&\n css`\n &::before {\n opacity: 1;\n }\n &::after {\n opacity: 1;\n }\n `}\n`;\n\nconst StyledError = styled.div<{ theme: Theme }>`\n overflow-x: auto;\n background: ${({ theme }) => theme.colors.error};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n padding: 10px;\n border-radius: 8px;\n margin: 20px 0;\n width: 100%;\n ${styledText};\n`;\n\nconst loadingDotAnimation = keyframes`\n 0% {\n opacity: 0;\n }\n 50% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n`;\n\nconst StyledLoading = styled.div<{ theme: Theme }>`\n overflow-x: auto;\n margin: 20px 0;\n width: 100%;\n font-weight: 600;\n ${styledText};\n color: ${({ theme }) => theme.colors.dark};\n\n & span {\n &:nth-child(1) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n }\n &:nth-child(2) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n animation-delay: 0.2s;\n }\n &:nth-child(3) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n animation-delay: 0.4s;\n }\n }\n`;\n\nconst StyledAnswer = styled.div<{ theme: Theme; $isAnswer: boolean }>`\n overflow-x: auto;\n background: ${({ theme }) => theme.colors.primary};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n padding: 10px;\n border-radius: 8px;\n margin: 20px 0;\n width: 100%;\n ${styledText};\n\n & p {\n ${styledText};\n }\n\n ${({ $isAnswer }) =>\n $isAnswer &&\n css`\n background: transparent;\n color: ${({ theme }) => theme.colors.dark};\n padding: 0;\n `}\n\n & code:not([class]) {\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};\n color: ${({ theme }) => theme.colors.dark};\n padding: 2px 4px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n white-space: pre;\n }\n\n ${styledAnchor};\n ${stylesLists};\n ${styledTable};\n\n & pre,\n & .hljs {\n margin: 10px 0;\n }\n\n & .code-wrapper pre {\n margin: 0;\n ${styledText};\n }\n\n & > *:first-child {\n margin-top: 0;\n }\n\n & > *:last-child {\n margin-bottom: 0;\n\n & > *:last-child {\n margin-bottom: 0;\n }\n }\n\n & ul,\n & ol {\n & li {\n ${styledText};\n }\n }\n\n & ol {\n & > li {\n padding-left: 20px;\n\n &::before {\n position: absolute;\n top: 0;\n left: 0;\n }\n }\n }\n\n & img,\n & video,\n & iframe {\n max-width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n margin: 10px 0;\n display: block;\n }\n\n & h1,\n & h2,\n & h3,\n & h4,\n & h5,\n & h6 {\n margin: 10px 0;\n padding: 0;\n }\n`;\n\nconst StyledSources = styled.div`\n display: flex;\n gap: 16px;\n flex-wrap: wrap;\n margin: -5px 0 20px;\n`;\n\nconst StyledSourceLink = styled(Link)<{ theme: Theme }>`\n position: relative;\n text-decoration: none;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: 1;\n color: ${({ theme }) => theme.colors.primary};\n display: flex;\n gap: 6px;\n transition: all 0.3s ease;\n font-weight: 600;\n white-space: nowrap;\n min-width: fit-content;\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.1)};\n padding: 6px 8px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n ${interactiveStyles};\n\n & * {\n margin: auto 0;\n }\n\n &:hover {\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n }\n`;\n\nconst StyledChatTitle = styled.div<{ theme: Theme }>`\n display: flex;\n flex-wrap: nowrap;\n justify-content: space-between;\n position: sticky;\n margin: 0 -20px;\n padding: 16px 20px;\n height: 62px;\n top: 0;\n background: ${({ theme }) => theme.colors.light};\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n z-index: 1000;\n`;\n\nconst StyledChatTitleIconWrapper = styled.span<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 12px;\n color: ${({ theme }) => theme.colors.dark};\n`;\n\nconst StyledChatCloseButton = styled.button<{ theme: Theme }>`\n background: transparent;\n border: none;\n cursor: pointer;\n padding: 0;\n margin: 0;\n color: ${({ theme }) => theme.colors.primary};\n\n &:hover {\n color: ${({ theme }) => theme.colors.primaryDark};\n transform: scale(1.05);\n }\n\n &:active {\n transform: scale(0.95);\n }\n`;\n\ntype Source = {\n id: string;\n path: string;\n uri: string;\n score: number;\n};\n\ntype Answer = {\n text: string;\n answer?: boolean;\n mdx?: MDXRemoteSerializeResult;\n sources?: Source[];\n};\n\nconst SPARKLE_COLORS = [\n \"#ff6b6b\",\n \"#feca57\",\n \"#48dbfb\",\n \"#ff9ff3\",\n \"#54a0ff\",\n \"#5f27cd\",\n];\n\n// Deterministic sparkle positions to avoid hydration mismatch\nconst SPARKLE_POSITIONS = [\n { left: 8, top: 35 },\n { left: 17, top: 55 },\n { left: 26, top: 28 },\n { left: 35, top: 68 },\n { left: 44, top: 42 },\n { left: 53, top: 75 },\n { left: 62, top: 32 },\n { left: 71, top: 58 },\n { left: 80, top: 45 },\n { left: 89, top: 65 },\n];\n\ninterface RainbowInputProps {\n id?: string;\n value: string;\n onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;\n placeholder?: string;\n autoComplete?: string;\n \"aria-label\"?: string;\n inputRef?: React.Ref<HTMLInputElement>;\n}\n\nfunction RainbowInput({\n id,\n value,\n onChange,\n placeholder,\n autoComplete,\n \"aria-label\": ariaLabel,\n inputRef,\n}: RainbowInputProps) {\n const [isFocused, setIsFocused] = useState(false);\n const [isHovered, setIsHovered] = useState(false);\n const isActive = isFocused || isHovered;\n\n const sparkles = SPARKLE_POSITIONS.map((pos, i) => ({\n color: SPARKLE_COLORS[i % SPARKLE_COLORS.length],\n left: pos.left,\n top: pos.top,\n delay: i * 0.12,\n }));\n\n return (\n <StyledRainbowInputWrapper\n $isActive={isActive}\n onMouseEnter={() => setIsHovered(true)}\n onMouseLeave={() => setIsHovered(false)}\n >\n <StyledSparkleContainer $isActive={isActive}>\n {sparkles.map((sparkle, i) => (\n <StyledSparkle\n key={i}\n $color={sparkle.color}\n $left={sparkle.left}\n $top={sparkle.top}\n $delay={sparkle.delay}\n />\n ))}\n </StyledSparkleContainer>\n <StyledRainbowInput\n ref={inputRef}\n id={id}\n value={value}\n onChange={onChange}\n placeholder={placeholder}\n autoComplete={autoComplete}\n aria-label={ariaLabel}\n onFocus={() => setIsFocused(true)}\n onBlur={() => setIsFocused(false)}\n />\n </StyledRainbowInputWrapper>\n );\n}\n\nfunction ChatButtonCTA() {\n const { setIsOpen, isOpen, answer, setAnswer, chatInputRef } =\n useContext(ChatContext);\n\n return (\n <StyledGlowSmallButton\n onClick={() => {\n const next = !isOpen;\n setIsOpen(next);\n if (next) {\n if (answer.length === 0) {\n setAnswer([\n { text: \"Hey there, how can I assist you?\", answer: true },\n ]);\n }\n setTimeout(() => {\n chatInputRef.current?.focus();\n }, 350);\n }\n }}\n aria-label=\"Ask AI Assistant\"\n $hasContent={isOpen}\n type=\"button\"\n >\n <span>\n <Sparkles size={16} />\n Ask AI\n </span>\n </StyledGlowSmallButton>\n );\n}\n\nfunction Chat() {\n const {\n isOpen,\n question,\n setQuestion,\n loading,\n error,\n answer,\n ask,\n closeChat,\n resetChat,\n chatInputRef,\n } = useContext(ChatContext);\n const endRef = useRef<HTMLDivElement | null>(null);\n\n useLockBodyScroll(isOpen);\n\n useEffect(() => {\n endRef.current?.scrollIntoView({ behavior: \"smooth\", block: \"end\" });\n }, [answer]);\n\n useEffect(() => {\n if (answer?.length > 0) {\n chatInputRef.current?.focus();\n }\n }, [answer, chatInputRef]);\n\n return (\n <>\n <StyledChat $isVisible={isOpen}>\n <StyledChatTitle>\n <StyledChatTitleIconWrapper>\n <Sparkles />\n <h3>AI Assistant</h3>\n </StyledChatTitleIconWrapper>\n <StyledChatTitleIconWrapper>\n <StyledChatCloseButton\n onClick={resetChat}\n aria-label=\"Reset chat history\"\n title=\"Reset chat history\"\n >\n <RotateCcw size={18} />\n </StyledChatCloseButton>\n <StyledChatCloseButton\n onClick={closeChat}\n aria-label=\"Close chat\"\n title=\"Close chat\"\n >\n <X />\n </StyledChatCloseButton>\n </StyledChatTitleIconWrapper>\n </StyledChatTitle>\n {answer &&\n answer.map((a, i) => (\n <React.Fragment key={i}>\n <StyledAnswer $isAnswer={a.answer ?? false}>\n {a.answer && a.mdx ? (\n <MDXRemote {...a.mdx} components={mdxComponents} />\n ) : (\n a.text\n )}\n </StyledAnswer>\n {a.answer && a.sources && a.sources.length > 0 && (\n <StyledSources>\n {a.sources.map((src) => {\n const slug = src.uri\n .replace(\"docs://\", \"\")\n .replace(/^\\/+/, \"\");\n const href = slug ? `/${slug}/` : \"/\";\n const label = slug\n ? slug\n .split(\"/\")\n .pop()!\n .replace(/-/g, \" \")\n .replace(/\\b\\w/g, (c: string) => c.toUpperCase())\n : \"Home\";\n return (\n <StyledSourceLink key={src.id} href={href}>\n {label}\n </StyledSourceLink>\n );\n })}\n </StyledSources>\n )}\n </React.Fragment>\n ))}\n {loading && (\n <StyledLoading>\n Answering<span>.</span>\n <span>.</span>\n <span>.</span>\n </StyledLoading>\n )}\n {error && (\n <StyledError>\n <strong>Error:</strong> {error}\n </StyledError>\n )}\n <div ref={endRef} />\n </StyledChat>\n\n <StyledChatForm onSubmit={ask} $isVisible={isOpen}>\n <RainbowInput\n id=\"chat-bottom-input\"\n inputRef={chatInputRef}\n value={question}\n onChange={(e) => setQuestion(e.target.value)}\n placeholder=\"Ask AI Assistant...\"\n autoComplete=\"off\"\n aria-label=\"Ask a follow-up question\"\n />\n <StyledRainbowButton\n type=\"submit\"\n disabled={loading || question.trim() === \"\"}\n $hasContent={question.trim().length > 0}\n aria-label={loading ? \"Loading response\" : \"Submit question\"}\n >\n {loading ? <LoaderPinwheel className=\"loading\" /> : <ArrowUp />}\n </StyledRainbowButton>\n </StyledChatForm>\n </>\n );\n}\n\nconst ChatContext = createContext<{\n isOpen: boolean;\n setIsOpen: (isOpen: boolean) => void;\n isChatActive: boolean;\n question: string;\n setQuestion: (q: string) => void;\n loading: boolean;\n error: string | null;\n answer: Answer[];\n setAnswer: (answers: Answer[]) => void;\n ask: (e: React.FormEvent) => void;\n closeChat: () => void;\n resetChat: () => void;\n chatInputRef: React.RefObject<HTMLInputElement | null>;\n}>({\n isOpen: false,\n setIsOpen: () => {},\n isChatActive: false,\n question: \"\",\n setQuestion: () => {},\n loading: false,\n error: null,\n answer: [],\n setAnswer: () => {},\n ask: () => {},\n closeChat: () => {},\n resetChat: () => {},\n chatInputRef: { current: null },\n});\n\ninterface ChatContextProviderProps {\n children: React.ReactNode;\n isChatActive: boolean;\n}\n\nconst ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {\n const [isOpen, setIsOpen] = useState(false);\n const [question, setQuestion] = useState(\"\");\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const [answer, setAnswer] = useState<Answer[]>([]);\n const abortRef = useRef<AbortController | null>(null);\n const chatInputRef = useRef<HTMLInputElement | null>(null);\n\n async function ask(e: React.FormEvent) {\n e.preventDefault();\n if (loading || question.trim() === \"\") return;\n const currentQuestion = question;\n setQuestion(\"\");\n setIsOpen(true);\n setLoading(true);\n setError(null);\n\n const mergedQuestions =\n answer.length > 0\n ? [...answer, { text: currentQuestion, answer: false }]\n : [{ text: currentQuestion, answer: false }];\n\n setAnswer(mergedQuestions);\n\n const controller = new AbortController();\n abortRef.current = controller;\n\n try {\n const history = answer\n .filter((a) => a.text.trim() !== \"\")\n .map((a) => ({\n role: a.answer ? (\"assistant\" as const) : (\"user\" as const),\n content: a.text,\n }));\n\n const res = await fetch(\"/api/rag\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ question: currentQuestion, history }),\n signal: controller.signal,\n });\n\n if (!res.ok) {\n const errorData = await res.json();\n throw new Error(errorData.error || \"Request failed\");\n }\n\n const reader = res.body?.getReader();\n const decoder = new TextDecoder();\n const contentParts: string[] = [];\n let sources: Source[] = [];\n if (!reader) {\n throw new Error(\"Failed to get response reader\");\n }\n\n const streamingAnswerIndex = mergedQuestions.length;\n setAnswer([...mergedQuestions, { text: \"\", answer: true }]);\n\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n\n buffer += decoder.decode(value, { stream: true });\n const parts = buffer.split(\"\\n\");\n buffer = parts.pop() ?? \"\";\n\n for (const line of parts) {\n if (line.startsWith(\"data: \")) {\n try {\n const data = JSON.parse(line.slice(6));\n\n if (data.type === \"metadata\") {\n const allSources: Source[] = data.data?.sources ?? [];\n const seen = new Set<string>();\n sources = allSources.filter((s: Source) => {\n if (s.score < 0.4 || seen.has(s.uri)) return false;\n seen.add(s.uri);\n return true;\n });\n } else if (data.type === \"content\") {\n contentParts.push(data.data);\n const streamedContent = contentParts.join(\"\");\n\n setAnswer((prev) => {\n const newAnswers = [...prev];\n newAnswers[streamingAnswerIndex] = {\n text: streamedContent,\n answer: true,\n sources,\n };\n return newAnswers;\n });\n } else if (data.type === \"error\") {\n throw new Error(data.data);\n } else if (data.type === \"done\") {\n const streamedContent = contentParts.join(\"\");\n let mdxSource: MDXRemoteSerializeResult | null = null;\n try {\n mdxSource = await serialize(streamedContent, {\n parseFrontmatter: false,\n mdxOptions: {\n remarkPlugins: [remarkGfm],\n rehypePlugins: [rehypeHighlight],\n format: \"md\",\n development: false,\n },\n });\n } catch (mdxError: unknown) {\n console.error(\"MDX serialization error:\", mdxError);\n }\n\n setAnswer((prev) => {\n const newAnswers = [...prev];\n newAnswers[streamingAnswerIndex] = {\n text: streamedContent,\n answer: true,\n mdx: mdxSource || undefined,\n sources,\n };\n return newAnswers;\n });\n }\n } catch (parseError) {\n if (\n parseError instanceof Error &&\n parseError.message !== \"Unknown error\"\n ) {\n console.error(\"Failed to parse SSE data:\", parseError);\n }\n }\n }\n }\n }\n } catch (err: unknown) {\n if (err instanceof DOMException && err.name === \"AbortError\") return;\n setError(err instanceof Error ? err.message : \"Unknown error\");\n } finally {\n abortRef.current = null;\n setLoading(false);\n }\n }\n\n function closeChat() {\n setIsOpen(false);\n }\n\n function resetChat() {\n abortRef.current?.abort();\n setLoading(false);\n setError(null);\n setAnswer([{ text: \"Hey there, how can I assist you?\", answer: true }]);\n }\n\n return (\n <ChatContext.Provider\n value={{\n isOpen,\n setIsOpen,\n isChatActive,\n question,\n setQuestion,\n loading,\n error,\n answer,\n setAnswer,\n ask,\n closeChat,\n resetChat,\n chatInputRef,\n }}\n >\n {children}\n </ChatContext.Provider>\n );\n};\n\nexport { Chat, ChtProvider, ChatContext, ChatButtonCTA };\n";
1
+ export declare const chatTemplate = "\"use client\";\nimport React, {\n createContext,\n useContext,\n useEffect,\n useRef,\n useState,\n} from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { rgba } from \"polished\";\nimport { Button } from \"cherry-styled-components\";\nimport { ArrowUp, LoaderPinwheel, RotateCcw, Sparkles, X } from \"lucide-react\";\nimport remarkGfm from \"remark-gfm\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport { MDXRemote, MDXRemoteSerializeResult } from \"next-mdx-remote\";\nimport { serialize } from \"next-mdx-remote/serialize\";\nimport Link from \"next/link\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { useLockBodyScroll } from \"@/components/LockBodyScroll\";\nimport { useMDXComponents as getMDXComponents } from \"@/components/MDXComponents\";\nimport {\n styledAnchor,\n styledTable,\n stylesLists,\n StyledSmallButton,\n interactiveStyles,\n} from \"@/components/layout/SharedStyled\";\n\nconst mdxComponents = getMDXComponents({});\n\nconst styledText = css<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.text.xs};\n line-height: ${({ theme }) => theme.lineHeights.text.xs};\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: ${({ theme }) => theme.lineHeights.small.lg};\n }\n`;\n\nconst StyledChat = styled.div<{ theme: Theme; $isVisible: boolean }>`\n margin: 0;\n position: fixed;\n top: 0;\n right: 0;\n width: 100%;\n height: calc(100dvh - 90px);\n overflow-y: scroll;\n overflow-x: hidden;\n z-index: 1000;\n padding: 0 20px;\n transition: all 0.3s ease;\n transform: translateX(0);\n background: ${({ theme }) => theme.colors.light};\n -webkit-overflow-scrolling: touch;\n opacity: 1;\n\n &::-webkit-scrollbar {\n display: none;\n }\n\n ${({ $isVisible }) =>\n !$isVisible &&\n css`\n transform: translateX(100%);\n opacity: 0;\n `}\n\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n`;\n\nconst loadingAnimation = keyframes`\n 0% {\n transform: rotate(0deg);\n }\n 100% {\n transform: rotate(360deg);\n }\n`;\n\nconst rotateGradient = keyframes`\n 0% {\n --gradient-angle: 0deg;\n }\n 100% {\n --gradient-angle: 360deg;\n }\n`;\n\nconst pulseGlow = keyframes`\n 0%, 100% {\n opacity: 0.5;\n filter: blur(16px);\n }\n 50% {\n opacity: 1;\n filter: blur(22px);\n }\n`;\n\nconst sparkleFloat = keyframes`\n 0%, 100% {\n opacity: 0;\n transform: translateY(0) scale(0);\n }\n 50% {\n opacity: 0.9;\n transform: translateY(-20px) scale(1);\n }\n`;\n\nconst shimmer = keyframes`\n 0% {\n background-position: 0% center;\n }\n 50% {\n background-position: 100% center;\n }\n 100% {\n background-position: 0% center;\n }\n`;\n\nconst StyledRainbowInputWrapper = styled.div<{\n theme: Theme;\n $isActive: boolean;\n}>`\n @property --gradient-angle {\n syntax: \"<angle>\";\n initial-value: 0deg;\n inherits: false;\n }\n\n position: relative;\n flex: 1;\n\n &::before {\n content: \"\";\n position: absolute;\n inset: -2px;\n border-radius: 14px;\n background: conic-gradient(\n from var(--gradient-angle),\n #cc5555,\n #d9a745,\n #3ab0cc,\n #cc7fc2,\n #4380cc,\n #4c1fa3,\n #cc5555\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation: ${rotateGradient} 3s linear infinite;\n z-index: 0;\n }\n\n &::after {\n content: \"\";\n position: absolute;\n inset: -10px;\n border-radius: 20px;\n background: conic-gradient(\n from var(--gradient-angle),\n ${rgba(\"#ff6b6b\", 0.4)},\n ${rgba(\"#feca57\", 0.4)},\n ${rgba(\"#48dbfb\", 0.4)},\n ${rgba(\"#ff9ff3\", 0.4)},\n ${rgba(\"#54a0ff\", 0.4)},\n ${rgba(\"#5f27cd\", 0.4)},\n ${rgba(\"#ff6b6b\", 0.4)}\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation:\n ${rotateGradient} 3s linear infinite,\n ${pulseGlow} 2s ease-in-out infinite;\n z-index: -1;\n pointer-events: none;\n }\n\n &:hover::before,\n &:focus-within::before {\n opacity: 1;\n }\n\n &:hover::after,\n &:focus-within::after {\n opacity: 1;\n }\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n &::before {\n opacity: 1;\n }\n &::after {\n opacity: 1;\n }\n `}\n`;\n\nconst StyledSparkleContainer = styled.div<{ $isActive: boolean }>`\n position: absolute;\n inset: -30px;\n pointer-events: none;\n overflow: hidden;\n border-radius: 30px;\n z-index: -2;\n opacity: 0;\n transition: opacity 0.4s ease;\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n opacity: 1;\n `}\n`;\n\nconst StyledSparkle = styled.div<{\n $color: string;\n $left: number;\n $top: number;\n $delay: number;\n}>`\n position: absolute;\n width: 4px;\n height: 4px;\n border-radius: 50%;\n background: ${({ $color }) => $color};\n box-shadow: 0 0 6px ${({ $color }) => $color};\n left: ${({ $left }) => $left}%;\n top: ${({ $top }) => $top}%;\n animation: ${sparkleFloat} 2s ease-in-out infinite;\n animation-delay: ${({ $delay }) => $delay}s;\n`;\n\nconst StyledRainbowInput = styled.input<{ theme: Theme }>`\n position: relative;\n z-index: 1;\n width: 100%;\n background: ${({ theme }) => theme.colors.light};\n border: 1px solid ${({ theme }) => theme.colors.grayLight};\n border-radius: 12px;\n padding: 14px 18px;\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n font-family: inherit;\n color: ${({ theme }) => theme.colors.dark};\n outline: none;\n transition:\n border-color 0.3s ease,\n box-shadow 0.3s ease;\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n }\n\n &::placeholder {\n color: ${({ theme }) => rgba(theme.colors.dark, 0.4)};\n transition: color 0.3s ease;\n }\n\n &:focus::placeholder {\n color: ${({ theme }) => rgba(theme.colors.dark, 0.6)};\n }\n\n &:focus {\n border-color: transparent;\n }\n`;\n\nconst StyledRainbowButton = styled(Button)<{\n theme: Theme;\n $hasContent: boolean;\n}>`\n padding-top: 10px;\n padding-bottom: 10px;\n position: relative;\n overflow: hidden;\n transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n\n &::before {\n content: \"\";\n position: absolute;\n inset: 0;\n background: linear-gradient(\n 135deg,\n #ff6b6b,\n #feca57,\n #48dbfb,\n #ff9ff3,\n #54a0ff\n );\n background-size: 300% 300%;\n opacity: 0;\n transition: opacity 0.3s ease;\n z-index: 0;\n animation: ${shimmer} 3s linear infinite;\n width: 200%;\n }\n\n ${({ $hasContent }) =>\n $hasContent &&\n css`\n &::before {\n opacity: 1;\n }\n `}\n\n &:hover::before {\n opacity: 1;\n }\n\n &:hover {\n transform: scale(1.05);\n }\n\n &:active {\n transform: scale(0.95);\n }\n\n & svg {\n position: relative;\n z-index: 1;\n transition: transform 0.3s ease;\n }\n\n &:disabled,\n &:disabled:hover {\n background: ${({ theme }) => theme.colors.primaryDark};\n transform: none;\n box-shadow: none;\n\n &::before {\n opacity: 0;\n }\n }\n`;\n\nconst StyledChatForm = styled.form<{ theme: Theme; $isVisible: boolean }>`\n display: flex;\n gap: 10px;\n justify-content: center;\n align-items: center;\n background: ${({ theme }) => theme.colors.light};\n padding: 20px;\n position: fixed;\n bottom: 0;\n right: 0;\n z-index: 1000;\n width: 100%;\n border-top: solid 1px ${({ theme }) => theme.colors.grayLight};\n transition: all 0.3s ease;\n transform: translateX(100%);\n opacity: 0;\n\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n\n ${({ $isVisible }) =>\n $isVisible &&\n css`\n opacity: 1;\n transform: translateX(0);\n `}\n\n & .loading {\n animation: ${loadingAnimation} 1s linear infinite;\n }\n`;\n\nconst StyledGlowSmallButton = styled(StyledSmallButton)<{\n theme: Theme;\n $hasContent: boolean;\n}>`\n @property --gradient-angle {\n syntax: \"<angle>\";\n initial-value: 0deg;\n inherits: false;\n }\n\n position: relative;\n isolation: isolate;\n margin-right: 0;\n background: ${({ theme }) => theme.colors.light};\n padding: 0;\n\n &::before {\n content: \"\";\n inset: -2px;\n border-radius: 8px;\n background: conic-gradient(\n from var(--gradient-angle),\n #cc5555,\n #d9a745,\n #3ab0cc,\n #cc7fc2,\n #4380cc,\n #4c1fa3,\n #cc5555\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation: ${rotateGradient} 3s linear infinite;\n z-index: -1;\n position: absolute;\n top: -2px;\n left: -2px;\n width: calc(100% + 4px);\n height: calc(100% + 4px);\n }\n\n &::after {\n content: \"\";\n position: absolute;\n inset: -8px;\n border-radius: 14px;\n background: conic-gradient(\n from var(--gradient-angle),\n ${rgba(\"#ff6b6b\", 0.4)},\n ${rgba(\"#feca57\", 0.4)},\n ${rgba(\"#48dbfb\", 0.4)},\n ${rgba(\"#ff9ff3\", 0.4)},\n ${rgba(\"#54a0ff\", 0.4)},\n ${rgba(\"#5f27cd\", 0.4)},\n ${rgba(\"#ff6b6b\", 0.4)}\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation:\n ${rotateGradient} 3s linear infinite,\n ${pulseGlow} 2s ease-in-out infinite;\n z-index: -2;\n pointer-events: none;\n }\n\n &:hover::before,\n &:hover::after {\n opacity: 1;\n }\n\n & span {\n padding: 6px 8px;\n display: flex;\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n gap: 6px;\n }\n\n ${({ $hasContent }) =>\n $hasContent &&\n css`\n &::before {\n opacity: 1;\n }\n &::after {\n opacity: 1;\n }\n `}\n`;\n\nconst StyledError = styled.div<{ theme: Theme }>`\n overflow-x: auto;\n background: ${({ theme }) => theme.colors.error};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n padding: 10px;\n border-radius: 8px;\n margin: 20px 0;\n width: 100%;\n ${styledText};\n`;\n\nconst loadingDotAnimation = keyframes`\n 0% {\n opacity: 0;\n }\n 50% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n`;\n\nconst StyledLoading = styled.div<{ theme: Theme }>`\n overflow-x: auto;\n margin: 20px 0;\n width: 100%;\n font-weight: 600;\n ${styledText};\n color: ${({ theme }) => theme.colors.dark};\n\n & span {\n &:nth-child(1) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n }\n &:nth-child(2) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n animation-delay: 0.2s;\n }\n &:nth-child(3) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n animation-delay: 0.4s;\n }\n }\n`;\n\nconst StyledAnswer = styled.div<{ theme: Theme; $isAnswer: boolean }>`\n overflow-x: auto;\n background: ${({ theme }) => theme.colors.primary};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n padding: 10px;\n border-radius: 8px;\n margin: 20px 0;\n width: 100%;\n ${styledText};\n\n & p {\n ${styledText};\n }\n\n ${({ $isAnswer }) =>\n $isAnswer &&\n css`\n background: transparent;\n color: ${({ theme }) => theme.colors.dark};\n padding: 0;\n `}\n\n & code:not([class]) {\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};\n color: ${({ theme }) => theme.colors.dark};\n padding: 2px 4px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n white-space: pre;\n }\n\n ${styledAnchor};\n ${stylesLists};\n ${styledTable};\n\n & pre,\n & .hljs {\n margin: 10px 0;\n }\n\n & .code-wrapper pre {\n margin: 0;\n ${styledText};\n }\n\n & > *:first-child {\n margin-top: 0;\n }\n\n & > *:last-child {\n margin-bottom: 0;\n\n & > *:last-child {\n margin-bottom: 0;\n }\n }\n\n & ul,\n & ol {\n & li {\n ${styledText};\n }\n }\n\n & ol {\n & > li {\n padding-left: 20px;\n\n &::before {\n position: absolute;\n top: 0;\n left: 0;\n }\n }\n }\n\n & img,\n & video,\n & iframe {\n max-width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n margin: 10px 0;\n display: block;\n }\n\n & h1,\n & h2,\n & h3,\n & h4,\n & h5,\n & h6 {\n margin: 10px 0;\n padding: 0;\n }\n`;\n\nconst StyledSources = styled.div`\n display: flex;\n gap: 16px;\n flex-wrap: wrap;\n margin: -5px 0 20px;\n`;\n\nconst StyledSourceLink = styled(Link)<{ theme: Theme }>`\n position: relative;\n text-decoration: none;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: 1;\n color: ${({ theme }) => theme.colors.primary};\n display: flex;\n gap: 6px;\n transition: all 0.3s ease;\n font-weight: 600;\n white-space: nowrap;\n min-width: fit-content;\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.1)};\n padding: 6px 8px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n ${interactiveStyles};\n\n & * {\n margin: auto 0;\n }\n\n &:hover {\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n }\n`;\n\nconst StyledChatTitle = styled.div<{ theme: Theme }>`\n display: flex;\n flex-wrap: nowrap;\n justify-content: space-between;\n position: sticky;\n margin: 0 -20px;\n padding: 16px 20px;\n height: 62px;\n top: 0;\n background: ${({ theme }) => theme.colors.light};\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n z-index: 1000;\n`;\n\nconst StyledChatTitleIconWrapper = styled.span<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 12px;\n color: ${({ theme }) => theme.colors.dark};\n`;\n\nconst StyledChatCloseButton = styled.button<{ theme: Theme }>`\n background: transparent;\n border: none;\n cursor: pointer;\n padding: 0;\n margin: 0;\n color: ${({ theme }) => theme.colors.primary};\n\n &:hover {\n color: ${({ theme }) => theme.colors.primaryDark};\n transform: scale(1.05);\n }\n\n &:active {\n transform: scale(0.95);\n }\n`;\n\ntype Source = {\n id: string;\n path: string;\n uri: string;\n score: number;\n};\n\ntype Answer = {\n text: string;\n answer?: boolean;\n mdx?: MDXRemoteSerializeResult;\n sources?: Source[];\n};\n\nconst SPARKLE_COLORS = [\n \"#ff6b6b\",\n \"#feca57\",\n \"#48dbfb\",\n \"#ff9ff3\",\n \"#54a0ff\",\n \"#5f27cd\",\n];\n\n// Deterministic sparkle positions to avoid hydration mismatch\nconst SPARKLE_POSITIONS = [\n { left: 8, top: 35 },\n { left: 17, top: 55 },\n { left: 26, top: 28 },\n { left: 35, top: 68 },\n { left: 44, top: 42 },\n { left: 53, top: 75 },\n { left: 62, top: 32 },\n { left: 71, top: 58 },\n { left: 80, top: 45 },\n { left: 89, top: 65 },\n];\n\ninterface RainbowInputProps {\n id?: string;\n value: string;\n onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;\n placeholder?: string;\n autoComplete?: string;\n \"aria-label\"?: string;\n inputRef?: React.Ref<HTMLInputElement>;\n}\n\nfunction RainbowInput({\n id,\n value,\n onChange,\n placeholder,\n autoComplete,\n \"aria-label\": ariaLabel,\n inputRef,\n}: RainbowInputProps) {\n const [isFocused, setIsFocused] = useState(false);\n const [isHovered, setIsHovered] = useState(false);\n const isActive = isFocused || isHovered;\n\n const sparkles = SPARKLE_POSITIONS.map((pos, i) => ({\n color: SPARKLE_COLORS[i % SPARKLE_COLORS.length],\n left: pos.left,\n top: pos.top,\n delay: i * 0.12,\n }));\n\n return (\n <StyledRainbowInputWrapper\n $isActive={isActive}\n onMouseEnter={() => setIsHovered(true)}\n onMouseLeave={() => setIsHovered(false)}\n >\n <StyledSparkleContainer $isActive={isActive}>\n {sparkles.map((sparkle, i) => (\n <StyledSparkle\n key={i}\n $color={sparkle.color}\n $left={sparkle.left}\n $top={sparkle.top}\n $delay={sparkle.delay}\n />\n ))}\n </StyledSparkleContainer>\n <StyledRainbowInput\n ref={inputRef}\n id={id}\n value={value}\n onChange={onChange}\n placeholder={placeholder}\n autoComplete={autoComplete}\n aria-label={ariaLabel}\n onFocus={() => setIsFocused(true)}\n onBlur={() => setIsFocused(false)}\n />\n </StyledRainbowInputWrapper>\n );\n}\n\nfunction ChatButtonCTA() {\n const { setIsOpen, isOpen, answer, setAnswer, chatInputRef } =\n useContext(ChatContext);\n\n return (\n <StyledGlowSmallButton\n onClick={() => {\n const next = !isOpen;\n setIsOpen(next);\n if (next) {\n if (answer.length === 0) {\n setAnswer([\n { text: \"Hey there, how can I assist you?\", answer: true },\n ]);\n }\n setTimeout(() => {\n chatInputRef.current?.focus();\n }, 350);\n }\n }}\n aria-label=\"Ask AI Assistant\"\n $hasContent={isOpen}\n type=\"button\"\n >\n <span>\n <Sparkles size={16} />\n Ask AI\n </span>\n </StyledGlowSmallButton>\n );\n}\n\nfunction Chat() {\n const {\n isOpen,\n question,\n setQuestion,\n loading,\n error,\n answer,\n ask,\n closeChat,\n resetChat,\n chatInputRef,\n } = useContext(ChatContext);\n const endRef = useRef<HTMLDivElement | null>(null);\n\n useLockBodyScroll(isOpen);\n\n useEffect(() => {\n endRef.current?.scrollIntoView({ behavior: \"smooth\", block: \"end\" });\n }, [answer]);\n\n useEffect(() => {\n if (answer?.length > 0) {\n chatInputRef.current?.focus();\n }\n }, [answer, chatInputRef]);\n\n return (\n <>\n <StyledChat $isVisible={isOpen}>\n <StyledChatTitle>\n <StyledChatTitleIconWrapper>\n <Sparkles />\n <h3>AI Assistant</h3>\n </StyledChatTitleIconWrapper>\n <StyledChatTitleIconWrapper>\n <StyledChatCloseButton\n onClick={resetChat}\n aria-label=\"Reset chat history\"\n title=\"Reset chat history\"\n >\n <RotateCcw size={18} />\n </StyledChatCloseButton>\n <StyledChatCloseButton\n onClick={closeChat}\n aria-label=\"Close chat\"\n title=\"Close chat\"\n >\n <X />\n </StyledChatCloseButton>\n </StyledChatTitleIconWrapper>\n </StyledChatTitle>\n {answer &&\n answer.map((a, i) => (\n <React.Fragment key={i}>\n <StyledAnswer $isAnswer={a.answer ?? false}>\n {a.answer && a.mdx ? (\n <MDXRemote {...a.mdx} components={mdxComponents} />\n ) : (\n a.text\n )}\n </StyledAnswer>\n {a.answer && a.sources && a.sources.length > 0 && (\n <StyledSources>\n {a.sources.map((src) => {\n const slug = src.uri\n .replace(\"docs://\", \"\")\n .replace(/^\\/+/, \"\");\n const href = slug ? `/${slug}/` : \"/\";\n const label = slug\n ? slug\n .split(\"/\")\n .pop()!\n .replace(/-/g, \" \")\n .replace(/\\b\\w/g, (c: string) => c.toUpperCase())\n : \"Home\";\n return (\n <StyledSourceLink\n key={src.id}\n href={href}\n onClick={() => {\n if (window.innerWidth <= 992) {\n closeChat();\n }\n }}\n >\n {label}\n </StyledSourceLink>\n );\n })}\n </StyledSources>\n )}\n </React.Fragment>\n ))}\n {loading && (\n <StyledLoading>\n Answering<span>.</span>\n <span>.</span>\n <span>.</span>\n </StyledLoading>\n )}\n {error && (\n <StyledError>\n <strong>Error:</strong> {error}\n </StyledError>\n )}\n <div ref={endRef} />\n </StyledChat>\n\n <StyledChatForm onSubmit={ask} $isVisible={isOpen}>\n <RainbowInput\n id=\"chat-bottom-input\"\n inputRef={chatInputRef}\n value={question}\n onChange={(e) => setQuestion(e.target.value)}\n placeholder=\"Ask AI Assistant...\"\n autoComplete=\"off\"\n aria-label=\"Ask a follow-up question\"\n />\n <StyledRainbowButton\n type=\"submit\"\n disabled={loading || question.trim() === \"\"}\n $hasContent={question.trim().length > 0}\n aria-label={loading ? \"Loading response\" : \"Submit question\"}\n >\n {loading ? <LoaderPinwheel className=\"loading\" /> : <ArrowUp />}\n </StyledRainbowButton>\n </StyledChatForm>\n </>\n );\n}\n\nconst ChatContext = createContext<{\n isOpen: boolean;\n setIsOpen: (isOpen: boolean) => void;\n isChatActive: boolean;\n question: string;\n setQuestion: (q: string) => void;\n loading: boolean;\n error: string | null;\n answer: Answer[];\n setAnswer: (answers: Answer[]) => void;\n ask: (e: React.FormEvent) => void;\n closeChat: () => void;\n resetChat: () => void;\n chatInputRef: React.RefObject<HTMLInputElement | null>;\n}>({\n isOpen: false,\n setIsOpen: () => {},\n isChatActive: false,\n question: \"\",\n setQuestion: () => {},\n loading: false,\n error: null,\n answer: [],\n setAnswer: () => {},\n ask: () => {},\n closeChat: () => {},\n resetChat: () => {},\n chatInputRef: { current: null },\n});\n\ninterface ChatContextProviderProps {\n children: React.ReactNode;\n isChatActive: boolean;\n}\n\nconst ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {\n const [isOpen, setIsOpen] = useState(false);\n const [question, setQuestion] = useState(\"\");\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const [answer, setAnswer] = useState<Answer[]>([]);\n const abortRef = useRef<AbortController | null>(null);\n const chatInputRef = useRef<HTMLInputElement | null>(null);\n\n async function ask(e: React.FormEvent) {\n e.preventDefault();\n if (loading || question.trim() === \"\") return;\n const currentQuestion = question;\n setQuestion(\"\");\n setIsOpen(true);\n setLoading(true);\n setError(null);\n\n const mergedQuestions =\n answer.length > 0\n ? [...answer, { text: currentQuestion, answer: false }]\n : [{ text: currentQuestion, answer: false }];\n\n setAnswer(mergedQuestions);\n\n const controller = new AbortController();\n abortRef.current = controller;\n\n try {\n const history = answer\n .filter((a) => a.text.trim() !== \"\")\n .map((a) => ({\n role: a.answer ? (\"assistant\" as const) : (\"user\" as const),\n content: a.text,\n }));\n\n const res = await fetch(\"/api/rag\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ question: currentQuestion, history }),\n signal: controller.signal,\n });\n\n if (!res.ok) {\n const errorData = await res.json();\n throw new Error(errorData.error || \"Request failed\");\n }\n\n const reader = res.body?.getReader();\n const decoder = new TextDecoder();\n const contentParts: string[] = [];\n let sources: Source[] = [];\n if (!reader) {\n throw new Error(\"Failed to get response reader\");\n }\n\n const streamingAnswerIndex = mergedQuestions.length;\n setAnswer([...mergedQuestions, { text: \"\", answer: true }]);\n\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n\n buffer += decoder.decode(value, { stream: true });\n const parts = buffer.split(\"\\n\");\n buffer = parts.pop() ?? \"\";\n\n for (const line of parts) {\n if (line.startsWith(\"data: \")) {\n try {\n const data = JSON.parse(line.slice(6));\n\n if (data.type === \"metadata\") {\n const allSources: Source[] = data.data?.sources ?? [];\n const seen = new Set<string>();\n sources = allSources.filter((s: Source) => {\n if (s.score < 0.4 || seen.has(s.uri)) return false;\n seen.add(s.uri);\n return true;\n });\n } else if (data.type === \"content\") {\n contentParts.push(data.data);\n const streamedContent = contentParts.join(\"\");\n\n setAnswer((prev) => {\n const newAnswers = [...prev];\n newAnswers[streamingAnswerIndex] = {\n text: streamedContent,\n answer: true,\n sources,\n };\n return newAnswers;\n });\n } else if (data.type === \"error\") {\n throw new Error(data.data);\n } else if (data.type === \"done\") {\n const streamedContent = contentParts.join(\"\");\n let mdxSource: MDXRemoteSerializeResult | null = null;\n try {\n mdxSource = await serialize(streamedContent, {\n parseFrontmatter: false,\n mdxOptions: {\n remarkPlugins: [remarkGfm],\n rehypePlugins: [rehypeHighlight],\n format: \"md\",\n development: false,\n },\n });\n } catch (mdxError: unknown) {\n console.error(\"MDX serialization error:\", mdxError);\n }\n\n setAnswer((prev) => {\n const newAnswers = [...prev];\n newAnswers[streamingAnswerIndex] = {\n text: streamedContent,\n answer: true,\n mdx: mdxSource || undefined,\n sources,\n };\n return newAnswers;\n });\n }\n } catch (parseError) {\n if (\n parseError instanceof Error &&\n parseError.message !== \"Unknown error\"\n ) {\n console.error(\"Failed to parse SSE data:\", parseError);\n }\n }\n }\n }\n }\n } catch (err: unknown) {\n if (err instanceof DOMException && err.name === \"AbortError\") return;\n setError(err instanceof Error ? err.message : \"Unknown error\");\n } finally {\n abortRef.current = null;\n setLoading(false);\n }\n }\n\n function closeChat() {\n setIsOpen(false);\n }\n\n function resetChat() {\n abortRef.current?.abort();\n setLoading(false);\n setError(null);\n setAnswer([{ text: \"Hey there, how can I assist you?\", answer: true }]);\n }\n\n return (\n <ChatContext.Provider\n value={{\n isOpen,\n setIsOpen,\n isChatActive,\n question,\n setQuestion,\n loading,\n error,\n answer,\n setAnswer,\n ask,\n closeChat,\n resetChat,\n chatInputRef,\n }}\n >\n {children}\n </ChatContext.Provider>\n );\n};\n\nexport { Chat, ChtProvider, ChatContext, ChatButtonCTA };\n";
@@ -892,7 +892,15 @@ function Chat() {
892
892
  .replace(/\\b\\w/g, (c: string) => c.toUpperCase())
893
893
  : "Home";
894
894
  return (
895
- <StyledSourceLink key={src.id} href={href}>
895
+ <StyledSourceLink
896
+ key={src.id}
897
+ href={href}
898
+ onClick={() => {
899
+ if (window.innerWidth <= 992) {
900
+ closeChat();
901
+ }
902
+ }}
903
+ >
896
904
  {label}
897
905
  </StyledSourceLink>
898
906
  );
@@ -0,0 +1 @@
1
+ export declare const postHogProviderTemplate = "\"use client\";\n\nimport posthog from \"posthog-js\";\nimport { PostHogProvider as PHProvider } from \"posthog-js/react\";\nimport { Suspense, useEffect, useRef, useState } from \"react\";\nimport { usePathname, useSearchParams } from \"next/navigation\";\nimport rawAnalyticsConfig from \"@/analytics.json\";\n\ninterface AnalyticsConfig {\n provider?: string;\n posthog?: {\n key?: string;\n host?: string;\n };\n}\n\nconst analyticsConfig = rawAnalyticsConfig as AnalyticsConfig;\n\nconst posthogKey =\n analyticsConfig?.provider === \"posthog\" ? analyticsConfig.posthog?.key : null;\n\nfunction PostHogInit({ onReady }: { onReady: () => void }) {\n const initRef = useRef(false);\n\n useEffect(() => {\n if (initRef.current || !posthogKey) return;\n initRef.current = true;\n\n posthog.init(posthogKey, {\n api_host: \"/ingest\",\n ui_host: analyticsConfig.posthog?.host || \"https://us.posthog.com\",\n capture_pageview: false,\n capture_pageleave: true,\n loaded: onReady,\n });\n }, [onReady]);\n\n return null;\n}\n\nfunction PostHogPageviewTracker() {\n const pathname = usePathname();\n const searchParams = useSearchParams();\n\n useEffect(() => {\n if (pathname) {\n const url = searchParams?.size\n ? `${pathname}?${searchParams.toString()}`\n : pathname;\n posthog.capture(\"$pageview\", { $current_url: url });\n }\n }, [pathname, searchParams]);\n\n return null;\n}\n\nexport function PostHogProvider({ children }: { children: React.ReactNode }) {\n const [ready, setReady] = useState(false);\n\n if (!posthogKey) {\n return <>{children}</>;\n }\n\n return (\n <PHProvider client={posthog}>\n <PostHogInit onReady={() => setReady(true)} />\n <Suspense fallback={null}>{ready && <PostHogPageviewTracker />}</Suspense>\n {children}\n </PHProvider>\n );\n}\n";
@@ -0,0 +1,72 @@
1
+ export const postHogProviderTemplate = `"use client";
2
+
3
+ import posthog from "posthog-js";
4
+ import { PostHogProvider as PHProvider } from "posthog-js/react";
5
+ import { Suspense, useEffect, useRef, useState } from "react";
6
+ import { usePathname, useSearchParams } from "next/navigation";
7
+ import rawAnalyticsConfig from "@/analytics.json";
8
+
9
+ interface AnalyticsConfig {
10
+ provider?: string;
11
+ posthog?: {
12
+ key?: string;
13
+ host?: string;
14
+ };
15
+ }
16
+
17
+ const analyticsConfig = rawAnalyticsConfig as AnalyticsConfig;
18
+
19
+ const posthogKey =
20
+ analyticsConfig?.provider === "posthog" ? analyticsConfig.posthog?.key : null;
21
+
22
+ function PostHogInit({ onReady }: { onReady: () => void }) {
23
+ const initRef = useRef(false);
24
+
25
+ useEffect(() => {
26
+ if (initRef.current || !posthogKey) return;
27
+ initRef.current = true;
28
+
29
+ posthog.init(posthogKey, {
30
+ api_host: "/ingest",
31
+ ui_host: analyticsConfig.posthog?.host || "https://us.posthog.com",
32
+ capture_pageview: false,
33
+ capture_pageleave: true,
34
+ loaded: onReady,
35
+ });
36
+ }, [onReady]);
37
+
38
+ return null;
39
+ }
40
+
41
+ function PostHogPageviewTracker() {
42
+ const pathname = usePathname();
43
+ const searchParams = useSearchParams();
44
+
45
+ useEffect(() => {
46
+ if (pathname) {
47
+ const url = searchParams?.size
48
+ ? \`\${pathname}?\${searchParams.toString()}\`
49
+ : pathname;
50
+ posthog.capture("$pageview", { $current_url: url });
51
+ }
52
+ }, [pathname, searchParams]);
53
+
54
+ return null;
55
+ }
56
+
57
+ export function PostHogProvider({ children }: { children: React.ReactNode }) {
58
+ const [ready, setReady] = useState(false);
59
+
60
+ if (!posthogKey) {
61
+ return <>{children}</>;
62
+ }
63
+
64
+ return (
65
+ <PHProvider client={posthog}>
66
+ <PostHogInit onReady={() => setReady(true)} />
67
+ <Suspense fallback={null}>{ready && <PostHogPageviewTracker />}</Suspense>
68
+ {children}
69
+ </PHProvider>
70
+ );
71
+ }
72
+ `;